From 32a36ec17dd491a90010faa90ed3bf48e7b1518f Mon Sep 17 00:00:00 2001 From: Andy Stuhl Date: Fri, 8 Mar 2024 23:50:42 -0500 Subject: [PATCH] update with fallback script, web server plan --- .gitignore | 1 + Pipfile | 3 +- Pipfile.lock | 159 +++++++++++++++++++++++++++++++++++++++++++---- README.md | 18 +----- tuner.py | 140 ++++++++++++++++++++++------------------- tunerwebnotes.md | 12 ++++ 6 files changed, 241 insertions(+), 92 deletions(-) mode change 100755 => 100644 tuner.py create mode 100644 tunerwebnotes.md diff --git a/.gitignore b/.gitignore index 66ad414..4d1f732 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ lastplayedqueue .cache .cache/* +tuner.sh diff --git a/Pipfile b/Pipfile index ca937da..fd986a2 100644 --- a/Pipfile +++ b/Pipfile @@ -5,8 +5,9 @@ name = "pypi" [packages] recurring-ical-events = "*" +pyradios = "*" [dev-packages] [requires] -python_version = "3.10" +python_version = "3" diff --git a/Pipfile.lock b/Pipfile.lock index d8dc080..6873593 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,11 +1,11 @@ { "_meta": { "hash": { - "sha256": "f571171dc12e1cf40d7ceadf4496659cc572eb4953c01d3d1430446cf0d8d20c" + "sha256": "0dd29fd62761000012dd8452a43854bd8a1fc68bc76875356103a9c7f435678d" }, "pipfile-spec": 6, "requires": { - "python_version": "3.10" + "python_version": "3.11" }, "sources": [ { @@ -16,13 +16,134 @@ ] }, "default": { + "certifi": { + "hashes": [ + "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082", + "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.7.22" + }, + "charset-normalizer": { + "hashes": [ + "sha256:06cf46bdff72f58645434d467bf5228080801298fbba19fe268a01b4534467f5", + "sha256:0c8c61fb505c7dad1d251c284e712d4e0372cef3b067f7ddf82a7fa82e1e9a93", + "sha256:10b8dd31e10f32410751b3430996f9807fc4d1587ca69772e2aa940a82ab571a", + "sha256:1171ef1fc5ab4693c5d151ae0fdad7f7349920eabbaca6271f95969fa0756c2d", + "sha256:17a866d61259c7de1bdadef418a37755050ddb4b922df8b356503234fff7932c", + "sha256:1d6bfc32a68bc0933819cfdfe45f9abc3cae3877e1d90aac7259d57e6e0f85b1", + "sha256:1ec937546cad86d0dce5396748bf392bb7b62a9eeb8c66efac60e947697f0e58", + "sha256:223b4d54561c01048f657fa6ce41461d5ad8ff128b9678cfe8b2ecd951e3f8a2", + "sha256:2465aa50c9299d615d757c1c888bc6fef384b7c4aec81c05a0172b4400f98557", + "sha256:28f512b9a33235545fbbdac6a330a510b63be278a50071a336afc1b78781b147", + "sha256:2c092be3885a1b7899cd85ce24acedc1034199d6fca1483fa2c3a35c86e43041", + "sha256:2c4c99f98fc3a1835af8179dcc9013f93594d0670e2fa80c83aa36346ee763d2", + "sha256:31445f38053476a0c4e6d12b047b08ced81e2c7c712e5a1ad97bc913256f91b2", + "sha256:31bbaba7218904d2eabecf4feec0d07469284e952a27400f23b6628439439fa7", + "sha256:34d95638ff3613849f473afc33f65c401a89f3b9528d0d213c7037c398a51296", + "sha256:352a88c3df0d1fa886562384b86f9a9e27563d4704ee0e9d56ec6fcd270ea690", + "sha256:39b70a6f88eebe239fa775190796d55a33cfb6d36b9ffdd37843f7c4c1b5dc67", + "sha256:3c66df3f41abee950d6638adc7eac4730a306b022570f71dd0bd6ba53503ab57", + "sha256:3f70fd716855cd3b855316b226a1ac8bdb3caf4f7ea96edcccc6f484217c9597", + "sha256:3f9bc2ce123637a60ebe819f9fccc614da1bcc05798bbbaf2dd4ec91f3e08846", + "sha256:3fb765362688821404ad6cf86772fc54993ec11577cd5a92ac44b4c2ba52155b", + "sha256:45f053a0ece92c734d874861ffe6e3cc92150e32136dd59ab1fb070575189c97", + "sha256:46fb9970aa5eeca547d7aa0de5d4b124a288b42eaefac677bde805013c95725c", + "sha256:4cb50a0335382aac15c31b61d8531bc9bb657cfd848b1d7158009472189f3d62", + "sha256:4e12f8ee80aa35e746230a2af83e81bd6b52daa92a8afaef4fea4a2ce9b9f4fa", + "sha256:4f3100d86dcd03c03f7e9c3fdb23d92e32abbca07e7c13ebd7ddfbcb06f5991f", + "sha256:4f6e2a839f83a6a76854d12dbebde50e4b1afa63e27761549d006fa53e9aa80e", + "sha256:4f861d94c2a450b974b86093c6c027888627b8082f1299dfd5a4bae8e2292821", + "sha256:501adc5eb6cd5f40a6f77fbd90e5ab915c8fd6e8c614af2db5561e16c600d6f3", + "sha256:520b7a142d2524f999447b3a0cf95115df81c4f33003c51a6ab637cbda9d0bf4", + "sha256:548eefad783ed787b38cb6f9a574bd8664468cc76d1538215d510a3cd41406cb", + "sha256:555fe186da0068d3354cdf4bbcbc609b0ecae4d04c921cc13e209eece7720727", + "sha256:55602981b2dbf8184c098bc10287e8c245e351cd4fdcad050bd7199d5a8bf514", + "sha256:58e875eb7016fd014c0eea46c6fa92b87b62c0cb31b9feae25cbbe62c919f54d", + "sha256:5a3580a4fdc4ac05f9e53c57f965e3594b2f99796231380adb2baaab96e22761", + "sha256:5b70bab78accbc672f50e878a5b73ca692f45f5b5e25c8066d748c09405e6a55", + "sha256:5ceca5876032362ae73b83347be8b5dbd2d1faf3358deb38c9c88776779b2e2f", + "sha256:61f1e3fb621f5420523abb71f5771a204b33c21d31e7d9d86881b2cffe92c47c", + "sha256:633968254f8d421e70f91c6ebe71ed0ab140220469cf87a9857e21c16687c034", + "sha256:63a6f59e2d01310f754c270e4a257426fe5a591dc487f1983b3bbe793cf6bac6", + "sha256:63accd11149c0f9a99e3bc095bbdb5a464862d77a7e309ad5938fbc8721235ae", + "sha256:6db3cfb9b4fcecb4390db154e75b49578c87a3b9979b40cdf90d7e4b945656e1", + "sha256:71ef3b9be10070360f289aea4838c784f8b851be3ba58cf796262b57775c2f14", + "sha256:7ae8e5142dcc7a49168f4055255dbcced01dc1714a90a21f87448dc8d90617d1", + "sha256:7b6cefa579e1237ce198619b76eaa148b71894fb0d6bcf9024460f9bf30fd228", + "sha256:800561453acdecedaac137bf09cd719c7a440b6800ec182f077bb8e7025fb708", + "sha256:82ca51ff0fc5b641a2d4e1cc8c5ff108699b7a56d7f3ad6f6da9dbb6f0145b48", + "sha256:851cf693fb3aaef71031237cd68699dded198657ec1e76a76eb8be58c03a5d1f", + "sha256:854cc74367180beb327ab9d00f964f6d91da06450b0855cbbb09187bcdb02de5", + "sha256:87071618d3d8ec8b186d53cb6e66955ef2a0e4fa63ccd3709c0c90ac5a43520f", + "sha256:871d045d6ccc181fd863a3cd66ee8e395523ebfbc57f85f91f035f50cee8e3d4", + "sha256:8aee051c89e13565c6bd366813c386939f8e928af93c29fda4af86d25b73d8f8", + "sha256:8af5a8917b8af42295e86b64903156b4f110a30dca5f3b5aedea123fbd638bff", + "sha256:8ec8ef42c6cd5856a7613dcd1eaf21e5573b2185263d87d27c8edcae33b62a61", + "sha256:91e43805ccafa0a91831f9cd5443aa34528c0c3f2cc48c4cb3d9a7721053874b", + "sha256:9505dc359edb6a330efcd2be825fdb73ee3e628d9010597aa1aee5aa63442e97", + "sha256:985c7965f62f6f32bf432e2681173db41336a9c2611693247069288bcb0c7f8b", + "sha256:9a74041ba0bfa9bc9b9bb2cd3238a6ab3b7618e759b41bd15b5f6ad958d17605", + "sha256:9edbe6a5bf8b56a4a84533ba2b2f489d0046e755c29616ef8830f9e7d9cf5728", + "sha256:a15c1fe6d26e83fd2e5972425a772cca158eae58b05d4a25a4e474c221053e2d", + "sha256:a66bcdf19c1a523e41b8e9d53d0cedbfbac2e93c649a2e9502cb26c014d0980c", + "sha256:ae4070f741f8d809075ef697877fd350ecf0b7c5837ed68738607ee0a2c572cf", + "sha256:ae55d592b02c4349525b6ed8f74c692509e5adffa842e582c0f861751701a673", + "sha256:b578cbe580e3b41ad17b1c428f382c814b32a6ce90f2d8e39e2e635d49e498d1", + "sha256:b891a2f68e09c5ef989007fac11476ed33c5c9994449a4e2c3386529d703dc8b", + "sha256:baec8148d6b8bd5cee1ae138ba658c71f5b03e0d69d5907703e3e1df96db5e41", + "sha256:bb06098d019766ca16fc915ecaa455c1f1cd594204e7f840cd6258237b5079a8", + "sha256:bc791ec3fd0c4309a753f95bb6c749ef0d8ea3aea91f07ee1cf06b7b02118f2f", + "sha256:bd28b31730f0e982ace8663d108e01199098432a30a4c410d06fe08fdb9e93f4", + "sha256:be4d9c2770044a59715eb57c1144dedea7c5d5ae80c68fb9959515037cde2008", + "sha256:c0c72d34e7de5604df0fde3644cc079feee5e55464967d10b24b1de268deceb9", + "sha256:c0e842112fe3f1a4ffcf64b06dc4c61a88441c2f02f373367f7b4c1aa9be2ad5", + "sha256:c15070ebf11b8b7fd1bfff7217e9324963c82dbdf6182ff7050519e350e7ad9f", + "sha256:c2000c54c395d9e5e44c99dc7c20a64dc371f777faf8bae4919ad3e99ce5253e", + "sha256:c30187840d36d0ba2893bc3271a36a517a717f9fd383a98e2697ee890a37c273", + "sha256:cb7cd68814308aade9d0c93c5bd2ade9f9441666f8ba5aa9c2d4b389cb5e2a45", + "sha256:cd805513198304026bd379d1d516afbf6c3c13f4382134a2c526b8b854da1c2e", + "sha256:d0bf89afcbcf4d1bb2652f6580e5e55a840fdf87384f6063c4a4f0c95e378656", + "sha256:d9137a876020661972ca6eec0766d81aef8a5627df628b664b234b73396e727e", + "sha256:dbd95e300367aa0827496fe75a1766d198d34385a58f97683fe6e07f89ca3e3c", + "sha256:dced27917823df984fe0c80a5c4ad75cf58df0fbfae890bc08004cd3888922a2", + "sha256:de0b4caa1c8a21394e8ce971997614a17648f94e1cd0640fbd6b4d14cab13a72", + "sha256:debb633f3f7856f95ad957d9b9c781f8e2c6303ef21724ec94bea2ce2fcbd056", + "sha256:e372d7dfd154009142631de2d316adad3cc1c36c32a38b16a4751ba78da2a397", + "sha256:ecd26be9f112c4f96718290c10f4caea6cc798459a3a76636b817a0ed7874e42", + "sha256:edc0202099ea1d82844316604e17d2b175044f9bcb6b398aab781eba957224bd", + "sha256:f194cce575e59ffe442c10a360182a986535fd90b57f7debfaa5c845c409ecc3", + "sha256:f5fb672c396d826ca16a022ac04c9dce74e00a1c344f6ad1a0fdc1ba1f332213", + "sha256:f6a02a3c7950cafaadcd46a226ad9e12fc9744652cc69f9e5534f98b47f3bbcf", + "sha256:fe81b35c33772e56f4b6cf62cf4aedc1762ef7162a31e6ac7fe5e40d0149eb67" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.1" + }, "icalendar": { "hashes": [ - "sha256:35faeae20e58d0fe26e33c04bbfd08b424192d61ae3e1da1d7611ba06ab33570", - "sha256:41f2f450707a503365a81c22954be4053779994c8ff01fc11bf4ce33a724092e" + "sha256:34f0ca020b804758ddf316eb70d1d46f769bce64638d5a080cb65dd46cfee642", + "sha256:6e392c2f301b6b5f49433e14c905db3de444b12876f3345f1856a75e9cd8be6f" ], "markers": "python_version >= '3.7'", - "version": "==5.0.1" + "version": "==5.0.10" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "pyradios": { + "hashes": [ + "sha256:52a83fa3099fda9a29021c9a9c05087525a95c01825a44236b83fde380693617", + "sha256:c26c840efb9fbf02adb44c71bf07e9c840f0c6e40088a8eabfcdb56d2772c1b5" + ], + "index": "pypi", + "markers": "python_version >= '3.6'", + "version": "==2.0.0" }, "python-dateutil": { "hashes": [ @@ -34,18 +155,26 @@ }, "pytz": { "hashes": [ - "sha256:222439474e9c98fced559f1709d89e6c9cbf8d79c794ff3eb9f8800064291427", - "sha256:e89512406b793ca39f5971bc999cc538ce125c0e51c27941bef4568b460095e2" + "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b", + "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7" ], - "version": "==2022.6" + "version": "==2023.3.post1" }, "recurring-ical-events": { "hashes": [ - "sha256:0aed6a5965c719e50aa2e5f6be896688cdb2c2da7087fae928ad4ac05ff0785f", - "sha256:c2dd86ccbb03891ce17f353f79d0a10779199270dbe3cb63b4f8fe387360dea5" + "sha256:3d3e6c65c760a1ddeb35b445e23b3d079bc04c8fdfea18efc909d6f07cb5960b", + "sha256:9cddb5ac83b08f06c836c76211eb5f7804fda224cdf9f2cbb3efbb2a5ce07947" ], "index": "pypi", - "version": "==1.0.3b0" + "version": "==2.1.0" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" }, "six": { "hashes": [ @@ -55,6 +184,14 @@ "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, + "urllib3": { + "hashes": [ + "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84", + "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.7" + }, "x-wr-timezone": { "hashes": [ "sha256:c05cb34b9b58a4607a788db086dcae5766728e4b94e0672870dc5593a6e13fe6", diff --git a/README.md b/README.md index 61b53f0..a4db055 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,12 @@ pipenv install #### Usage ``` -pipenv run python tuner.py [--fallback http://example.com/audiostream.mp3] http://example.com/radio.ics +pipenv run python tuner.py http://example.com/radio.ics [--default http://example.com/audiostream.mp3] [--fallback bash somescript.sh] ``` ### How to use -1. Find a radio show (or bandcamp album, mp3 file, etc. -- see listening queue section below) you'd like to tune in to when it's on. +1. Find a radio show (or bandcamp album, mp3 file, etc. – see listening queue section below) you'd like to tune in to when it's on. 2. Make an event for the show in a web calendar (e.g. Google calendar). Find a streaming audio URL where you can listen to the show and paste that URL into the event's `location` field. 3. Copy your calendar's .ics URL (for a Google calendar, go to the calendar's settings and look under "Integrate calendar" for an "address in iCal format" field). 4. To start playing audio, paste your .ics URL as an argument to the script in your terminal: `pipenv python tuner.py ` @@ -27,23 +27,11 @@ pipenv run python tuner.py [--fallback http://example.com/audiostream.mp3] http: This is a listening helper that prioritizes tuning in to weekly radio shows via internet radio streams. If you add these shows as repeating events to a web calendar that publishes an iCalendar (.ics) endpoint, for instance a Google calendar, then this script can retrieve them and switch between the streams according to schedule. [Here's mine](https://calendar.google.com/calendar/u/0?cid=YWJkOTY0YThiOTQ1MDBkZjNjNWI1ZTIwYzdjNjRlNmU3YTM4NTY1ODZlNWNjNDFkODZmOTYzNWVhZmM0MTgyNUBncm91cC5jYWxlbmRhci5nb29nbGUuY29t), for example. -#### Listening queue - -When nothing is found on the schedule at the current time, the script will rotate through a set of calendar entries it identifies as listening queue items rather than scheduled shows. For now, the way I designate queue items is very inelegant: in Google calendar, create an all-day event and set it to repeat daily, putting the URL in its `location` field as usual (if your mpv installation supports youtube-dl, a surprising number of URL sources including Bandcamp and Mixcloud pages should work) -- in the iCalendar format, Google will mark an all-day event as "transparent," and the script interprets that to mean queue item. Simply delete the recurring event to remove its queue item. - #### Alternate uses -This script tries to do as little as possible in passing URLs from the iCalendar file through to the mpv player. While I wrote it with just audio in mind, there are no guard rails against queuing video sources; or, for that matter, experimenting with calendar event settings in between or outside the envisioned "weekly show / listening queue" dichotomy. Just keep in mind it's untested for any of that. +This script tries to do as little as possible in passing URLs from the iCalendar file through to the mpv player. While I wrote it with just audio in mind, there are no guard rails against queuing video sources. Just keep in mind it's untested for any of that. ### Why This is a little tool I wrote for myself with the primary goal of catching weekly radio shows that friends produce or that I'm otherwise interested in. The secondary goal was to have a one-click way to start playing interesting audio when I have a moment to listen and don't want to spend time thinking about what to put on. Now I add things to my radio calendar when I hear about them and, when the timing works out, catch them by listening through the tuner script. -### What else - -Additions might include: - -- Cache the most recently used iCalendar URL in a local file so it doesn't have to be specified each time the script is launched -- Support iCalendar's `VTODO` record type for listening queue entries (Google and other web calendar services don't support this, but it would be internally much more elegant) -- Set up or find a directory of station stream URLs so the full URL doesn't need to be entered into each event's `location` every time -- A helper, probably as a separate tool, for adding shows and queue items via CalDAV or (more likely) via an .ics file that gets published within a static (Jekyll) site diff --git a/tuner.py b/tuner.py old mode 100755 new mode 100644 index d63996b..d9cb32d --- a/tuner.py +++ b/tuner.py @@ -1,19 +1,25 @@ from datetime import datetime, timedelta, timezone from icalendar import Calendar, vText -from time import sleep +from pyradios import RadioBrowser import recurring_ical_events import urllib.request +from urllib.parse import urlparse +from glob import glob import os import subprocess import argparse -parser = argparse.ArgumentParser(description='Play audio various web sources using an iCalendar URL as a scheduler') -parser.add_argument('ical_url', metavar='https://example.com/radio.ics', help='The .ics endpoint where a series of events with audio URLs in their `location` fields can be retrieved.') -parser.add_argument('--fallback', dest='fallback_url', action='store', help='A fallback audio source URL to play when nothing is on the schedule/queue.') +parser = argparse.ArgumentParser(description='Play audio from internet radio streams or other locations using an iCalendar URL as a scheduler') +parser.add_argument('ical_url', metavar='https://example.com/radio.ics', help='The .ics endpoint where a series of events with station names, URLs, or filepaths in their `location` fields can be retrieved.') +parser.add_argument('--fallback', '-f', dest='fallback_command', metavar='bash ../some-script.sh', help='A command to execute in the intervals between scheduled events (overrides --default).', action='extend', nargs='+', type=str) +parser.add_argument('--default', '-d', dest='default_location', action='store', help='A default station, URL, or file to play when nothing is on the schedule/queue and --fallback is not set.') args = parser.parse_args() -fallback_url = args.fallback_url -ical_url = args.ical_url # TODO store last used ical url in a local file for convenience? +default_location = args.default_location +fallback_command = args.fallback_command +ical_url = args.ical_url # TODO check local config file for ical url + +rb = RadioBrowser() # convenience function for working with icalendar library's vText format def get_text(e, k): @@ -23,6 +29,7 @@ def get_start(e): return e['DTSTART'].dt.timestamp() # used as comparator to sort events +# TODO rename get_priority and factor in transparency field def get_created(e): return e['CREATED'].dt.timestamp() @@ -34,7 +41,7 @@ def print_event(e): # main function; waits for player to finish and then calls itself def tuner(): - url = fallback_url + location = None event_to_play = None queue_event_will_play = False @@ -46,57 +53,28 @@ def tuner(): print("Found", len(events), "events") - # split events into scheduled shows vs listening queue items; use the TRANSP property, which google cal sets to TRANSPARENT for all-day events (my current solution for adding listening queue items to a cal), to determine the difference - # TODO also support using the VTODO feature for listening queue items, which would be the preferred way of doing it with a custom .ics-building (web providers including google cal don't support VTODO) - queue_events = [] - scheduled_events = [] - for e in events: - if get_text(e, "TRANSP") == "TRANSPARENT": - queue_events.append(e) - else: - scheduled_events.append(e) - - if len(scheduled_events) > 0: - print("Found", len(scheduled_events), "scheduled event(s):") - scheduled_events.sort(key=get_created, reverse=True) - list(map(print_event, scheduled_events)) + if len(events) > 0: + print("Found", len(events), "scheduled event(s):") + # TODO update get_created to get_priority + events.sort(key=get_created, reverse=True) + list(map(print_event, events)) print() - event_to_play = scheduled_events[0] - url = get_text(event_to_play, "LOCATION") - print("Selected URL:", url) - elif len(queue_events) > 0: - print("Found no scheduled events but", len(queue_events), "queue entries:") - queue_events.sort(key=get_created, reverse=True) - list(map(print_event, queue_events)) - print() - event_to_play = queue_events[0] - queue_event_will_play = True - - # check for a file that stores last played queue url - try: - with open('lastplayedqueue', encoding="utf-8") as f: - last_played_url = f.readline() - print("Found in lastplayedqueue:", last_played_url) - if last_played_url != "None": - # if it's set, iterate through until it's found in list, at which point select the next - for i in range(0, len(queue_events)): - if get_text(queue_events[i], "LOCATION") == last_played_url: - # wrap around to first in list if match is last item - event_to_play = queue_events[(i + 1) % len(queue_events)] - except FileNotFoundError: - print("Found no lastplayedqueue file") - url = get_text(event_to_play, "LOCATION") - f = open('lastplayedqueue', 'w', encoding="utf-8") - f.write(url) - f.close() - print("Wrote to lastplayedqueue:", url) + event_index = 0 + event_to_play = events[event_index] + location = get_text(event_to_play, "LOCATION") + while event_to_play and not location: + print('Found an event, but it lacks a location!') + print(get_text(event_to_play, 'SUMMARY')) + event_index += 1 + try: + event_to_play = events[event_index] + location = get_text(event_to_play, "LOCATION") + except IndexError: + print('No current event had its location field set.') - else: - print("Found no scheduled or queue events; using fallback url", fallback_url) - duration = timedelta(hours=24) # (arbitrary) - if not queue_event_will_play: + if event_to_play and not queue_event_will_play: event_end = event_to_play['DTEND'].dt # this errors if checked for an all-day event, which under current approach shouldn't happen duration = event_end - now @@ -125,34 +103,66 @@ def tuner(): duration = next_event['DTSTART'].dt - now print("Next event:", get_text(next_event, 'SUMMARY'), "at", next_event['DTSTART'].dt.time()) print("(Revised current duration to", duration, ")") + + if location: + + # check if location is a valid url or a glob that matches any files + location_parsed = urlparse(location) + if not all([location_parsed.scheme, location_parsed.netloc]) and len(glob(location)) < 1: + + # if it doesn't appear to be a url or path, run a search with RadioBrowser: + print('Checking RadioBrowser for', location) + stations = rb.search(name=location, limit=1, hidebroken=True, order='clickcount', reverse=True) + if len(stations) > 0: + print('Found a station!') + print(stations[0]["name"]) + location = stations[0]["url_resolved"] + else: + print('Found no stations. Clearing `location` value.') + location = None - if event_to_play: + if location: print() print("~~~ Now playing ~~~") - if queue_event_will_play: - print("Listening queue:") - print(" ", get_text(event_to_play, 'SUMMARY')) - print(" ", url) - else: - print(get_text(event_to_play, 'SUMMARY')) - print(url) + print(get_text(event_to_play, 'SUMMARY')) + print(location) print("From", now.time(), "to", (now + duration).time()) print("~~~~~~~~~~~~~~~~~~~") print() - if url: # setting timeout in subprocess.run terminates mpv after [duration], which is necessary because for some sources, e.g. bandcamp albums, it ignores --length arg. adding a couple seconds to let the process terminate itself (when mpv does consider --length) feels respectful. grace_period = 2 try: - subprocess.run(['mpv', '--length=' + str(duration.total_seconds()), url], timeout=(duration.total_seconds() + grace_period)) + subprocess.run(['mpv', '--length=' + str(duration.total_seconds()), location], timeout=(duration.total_seconds() + grace_period)) except subprocess.TimeoutExpired: print("Reached timeout of", duration.total_seconds() + grace_period, "seconds") + # TODO catch network error or failure by mpv to play location (are they distinct errors?); in latter case, try the next overlapping event in the list instead of repeating the whole loop; if there's no next event in the list, report problem and move along to fallback and default options; similar error catching should happen there, with default exiting the whole program on a network failure # start over tuner() + elif fallback_command: + print("Nothing on the schedule for now, but a fallback command was passed:") + print(' '.join(fallback_command)) + try: + subprocess.run(fallback_command, timeout=(duration.total_seconds())) + except subprocess.TimeoutExpired: + print("Reached timeout of", duration.total_seconds(), "seconds") + + tuner() + + elif default_location: + print("Nothing on the schedule for now and no fallback command, but a default location was passed:") + print(default_location) + try: + subprocess.run(['mpv', '--length=' + str(duration.total_seconds()), default_location], timeout=(duration.total_seconds())) + except subprocess.TimeoutExpired: + print("Reached timeout of", duration.total_seconds(), "seconds") + + tuner() + else: - print("Nothing found in schedule/queue and no fallback URL provided. Exiting.") + print("Nothing found in schedule/queue and no default location provided. Exiting.") tuner() diff --git a/tunerwebnotes.md b/tunerwebnotes.md new file mode 100644 index 0000000..1dc5315 --- /dev/null +++ b/tunerwebnotes.md @@ -0,0 +1,12 @@ +- (client loads root endpoint, enters calendar url in text field, location updates to calendar endpoint) +- client loads calendar endpoint, with ics url as path +- server tries to fetch, parse ics url; shows html error page if fails +- if parse succeeds, server derives now playing from ics data +- server sends html page with info to display, audio element whose source = audio/{url} (upgrade: instead of audio tag, use js icecast player https://github.com/eshaz/icecast-metadata-js/tree/master/src/icecast-metadata-player), data element with reload-at timestamp +- client's browser loads audio endpoint, with remote url as path +- script in webpage consults the reload-at data element, sets a timeout accordingly, reloads the page when it fires +- start back over: client loads calendar endpoint + +KEY QUESTIONS: + +- will time refresh + autoplay work on a phone browser tab running in background / with screen off? -- 2.45.2