Spotify: SPOTIFY key watches the Christian EDM 2026 playlist during services; hold, restart key and auto-fix play it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ There are two parts:
|
|||||||
|--------|-------|-------|-------|-------|-------|-------|-------|-------|
|
|--------|-------|-------|-------|-------|-------|-------|-------|-------|
|
||||||
| row 0 | UP | **ALL** | REC | AUDIO | RF | SHOW | VIDEO | STREAM |
|
| row 0 | UP | **ALL** | REC | AUDIO | RF | SHOW | VIDEO | STREAM |
|
||||||
| row 1 | page | HD1 | HD2 | HD3 | HD4 | HD5 | HD6 | TASCAM |
|
| row 1 | page | HD1 | HD2 | HD3 | HD4 | HD5 | HD6 | TASCAM |
|
||||||
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | |
|
| row 2 | DOWN | X32 | DANTE | MIDI | APPS | VISTA | RESTART PAGE | SPOTIFY |
|
||||||
| row 3 | ALARMS | ATEM | OBS | RESI | NET | INGEST | LED | ARENA |
|
| row 3 | ALARMS | ATEM | OBS | RESI | NET | INGEST | LED | ARENA |
|
||||||
|
|
||||||
Row 0 holds the group buttons. Each one shows the first problem in its group, plus "+N" if there are more:
|
Row 0 holds the group buttons. Each one shows the first problem in its group, plus "+N" if there are more:
|
||||||
@@ -21,7 +21,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
|||||||
- REC: HD1–6 and the Tascam.
|
- REC: HD1–6 and the Tascam.
|
||||||
- AUDIO: X32 and Dante.
|
- AUDIO: X32 and Dante.
|
||||||
- RF: all six Shure units (AD4Q ×2, ULXD4, PSM1000 ×3).
|
- RF: all six Shure units (AD4Q ×2, ULXD4, PSM1000 ×3).
|
||||||
- SHOW: MIDI, APPS and VISTA.
|
- SHOW: MIDI, APPS, VISTA and SPOTIFY.
|
||||||
- VIDEO: ATEM, LED and Arena.
|
- VIDEO: ATEM, LED and Arena.
|
||||||
- STREAM: OBS and Resi.
|
- STREAM: OBS and Resi.
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ Row 0 holds the group buttons. Each one shows the first problem in its group, pl
|
|||||||
|
|
||||||
## Auto-fix on Sundays (06:30–13:30)
|
## Auto-fix on Sundays (06:30–13:30)
|
||||||
|
|
||||||
During that window, with ALARMS on, the panel fixes a problem by itself once it has lasted 20 s, but only with **gentle fixes**: Companion reconnects, HyperDeck off/on, the Tascam reconnect, MIDI reconnects, starting rtpMIDI, and starting Vista, Resolume, OBS, Ableton or Church Translate if they are closed, plus DVS on one Mac that dropped out of Dante. The key shows **AUTO-FIXING**.
|
During that window, with ALARMS on, the panel fixes a problem by itself once it has lasted 20 s, but only with **gentle fixes**: Companion reconnects, HyperDeck off/on, the Tascam reconnect, MIDI reconnects, starting rtpMIDI, and starting Vista, Resolume, OBS, Ableton or Church Translate if they are closed, playing the Spotify playlist, plus DVS on one Mac that dropped out of Dante. The key shows **AUTO-FIXING**.
|
||||||
|
|
||||||
- At most 2 tries per problem, 90 s apart; then it is yours to hold.
|
- At most 2 tries per problem, 90 s apart; then it is yours to hold.
|
||||||
- It waits while the network is having a blip, and does nothing to Dante when several devices vanish at once (that is NA Ingest's view or the Dante network, not the Macs).
|
- It waits while the network is having a blip, and does nothing to Dante when several devices vanish at once (that is NA Ingest's view or the Dante network, not the Macs).
|
||||||
@@ -89,6 +89,7 @@ Tap **RESTART PAGE** on the status page (row 2). Each key restarts one thing **e
|
|||||||
| PRODCOM | ProdCom on NA Ingest (lobby captions blank for 5–15 s) |
|
| PRODCOM | ProdCom on NA Ingest (lobby captions blank for 5–15 s) |
|
||||||
| PRODDASH / CAPTIONS / BRIDGE | that service on NA Ingest |
|
| PRODDASH / CAPTIONS / BRIDGE | that service on NA Ingest |
|
||||||
| TASCAM | reconnects Companion to the recorder (see TASCAM above) |
|
| TASCAM | reconnects Companion to the recorder (see TASCAM above) |
|
||||||
|
| SPOTIFY | plays the Christian EDM 2026 playlist on NA Ingest (opens Spotify first if it is closed) |
|
||||||
|
|
||||||
Add or move keys in `"restart_page"` in `config.json`, then `python3 make_page.py` and `python3 push_page.py --which restart`. `push_page.py` refuses any page that holds real buttons unless it already is that page.
|
Add or move keys in `"restart_page"` in `config.json`, then `python3 make_page.py` and `python3 push_page.py --which restart`. `push_page.py` refuses any page that holds real buttons unless it already is that page.
|
||||||
|
|
||||||
@@ -119,6 +120,7 @@ The **page number key** on every page follows the ALL button. It's green when ev
|
|||||||
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
|
| RESI | decoder (RossTalk connection and ping, 10.1.10.203); **encoder** (ping and its web page, 10.1.10.202, LAOEncoder); resi-studio-cues; Resi cloud reachable. Only shows they are on the network, not that the encoder is streaming. |
|
||||||
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
|
| NET | NA Ingest's link; gateway 10.1.0.1 pings; **4+ connections from 3+ groups dropping within 10 s** (what happened Sunday 10/5 at 10:21). A blip **flashes amber** for 5 minutes, then goes by itself; press NET to stop the flashing, hold NET 2 s to clear it now. |
|
||||||
| INGEST | the bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
|
| INGEST | the bridge (makes ProPresenter's stage screen count down to the next Resi cue), lobby captions server and caption screen, **ProdCom** (transcription, answering on :24480), ProdDash (running and its page on :24500); free disk |
|
||||||
|
| SPOTIFY | Spotify on NA Ingest during Sunday services (06:30–13:30) must be playing the **Christian EDM 2026** playlist (`spotify:playlist:2uTpJVYQLFnKF1WnSOuco8`): **SPOTIFY CLOSED**, **NOT PLAYING**, or **WRONG PLAYLIST** (the song list comes from Spotify's public playlist page, refreshed every 6 h; it shows the first 100 of the playlist's songs, so the alarm needs 3 songs in a row that are not on it). Outside services it just shows PLAYING / PAUSED / CLOSED. Hold 2 s (and Sunday auto-fix) plays the playlist, opening Spotify first if needed. |
|
||||||
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. |
|
| RF (group) | Shure connections; battery ≤1 bar red, 2 bars amber; RF interference (only while that transmitter is on); PSM RF muted; each receiver pinged every 3 s ("AXIENT 1 NO PING" in about 6–9 s; Companion's Shure modules take 18–27 s to notice). The address is the one set on its Companion connection, read from Companion's settings every minute, so changing it there is enough; `"host"` in `config.json` is only the fallback. |
|
||||||
|
|
||||||
A problem has to last 3 seconds before it shows, so one bad reading doesn't trigger an alarm. Recovery shows immediately.
|
A problem has to last 3 seconds before it shows, so one bad reading doesn't trigger an alarm. Recovery shows immediately.
|
||||||
|
|||||||
+9
-2
@@ -72,6 +72,8 @@
|
|||||||
"atem_midi": { "kind": "local", "short": "ATEM MIDI", "restart_ok": true,
|
"atem_midi": { "kind": "local", "short": "ATEM MIDI", "restart_ok": true,
|
||||||
"label": "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
|
"label": "turn off MIDI control in ATEM Software Control (quits and reopens the app; the switcher is not affected)",
|
||||||
"run": "was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; [ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; [ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]" },
|
"run": "was=$(pgrep -f \"ATEM Software Control.app/Contents/MacOS\"); pkill -TERM -f \"ATEM Software Control.app/Contents/MacOS\"; i=0; while pgrep -fq \"ATEM Software Control.app/Contents/MacOS\" && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; defaults write \"com.blackmagic-design.ATEM Software Control\" midiEnabled -bool false; [ -z \"$was\" ] || open -g -a \"ATEM Software Control\"; [ \"$(defaults read \"com.blackmagic-design.ATEM Software Control\" midiEnabled)\" = 0 ]" },
|
||||||
|
"spotify_play": { "kind": "local", "short": "SPOTIFY", "label": "play the Christian EDM 2026 playlist in Spotify on NA Ingest (opens Spotify if it is closed)",
|
||||||
|
"run": "pgrep -xq Spotify || { open -g -a Spotify; sleep 8; }; osascript -e 'tell application \"Spotify\" to play track \"spotify:playlist:2uTpJVYQLFnKF1WnSOuco8\"'; sleep 3; [ \"$(osascript -e 'tell application \"Spotify\" to player state as text')\" = playing ]" },
|
||||||
"proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)",
|
"proddash": { "kind": "local", "short": "PRODDASH", "label": "restart ProdDash (its launcher stops its server cleanly)",
|
||||||
"run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" },
|
"run": "pkill -TERM -x ProdDash; i=0; while pgrep -xq ProdDash && [ $i -lt 20 ]; do sleep 0.5; i=$((i+1)); done; open -g /Applications/ProdDash.app" },
|
||||||
"prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)",
|
"prodcom": { "kind": "local", "short": "PRODCOM", "label": "restart ProdCom (transcription; lobby captions blank for 5-15 s)",
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
"restart_connections": true,
|
"restart_connections": true,
|
||||||
"fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs",
|
"fixes": ["hyperdeck", "tascam_reconnect", "midi_reconnect", "rtpmidi_start", "vista", "arena", "obs",
|
||||||
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl",
|
"ableton", "translate", "dvs_prop", "dvs_tracks", "dvs_translation", "solo_afl",
|
||||||
"atem_audio", "atem_camera", "atem_midi"]
|
"atem_audio", "atem_camera", "atem_midi", "spotify_play"]
|
||||||
},
|
},
|
||||||
|
|
||||||
"restart_page": {
|
"restart_page": {
|
||||||
@@ -112,6 +114,7 @@
|
|||||||
{ "id": "rs_propres", "label": "PROPRESENTER", "pos": [1, 1], "fix": "propresenter" },
|
{ "id": "rs_propres", "label": "PROPRESENTER", "pos": [1, 1], "fix": "propresenter" },
|
||||||
{ "id": "rs_ableton", "label": "ABLETON", "pos": [1, 2], "fix": "ableton_restart", "watch": ["ableton", "conn:ableton"] },
|
{ "id": "rs_ableton", "label": "ABLETON", "pos": [1, 2], "fix": "ableton_restart", "watch": ["ableton", "conn:ableton"] },
|
||||||
{ "id": "rs_translate", "label": "TRANSLATE", "pos": [1, 3], "fix": "translate_restart", "watch": ["translate"] },
|
{ "id": "rs_translate", "label": "TRANSLATE", "pos": [1, 3], "fix": "translate_restart", "watch": ["translate"] },
|
||||||
|
{ "id": "rs_spotify", "label": "SPOTIFY", "pos": [1, 4], "fix": "spotify_play" },
|
||||||
{ "id": "rs_prodcom", "label": "PRODCOM", "pos": [2, 1], "fix": "prodcom" },
|
{ "id": "rs_prodcom", "label": "PRODCOM", "pos": [2, 1], "fix": "prodcom" },
|
||||||
{ "id": "rs_proddash", "label": "PRODDASH", "pos": [2, 2], "fix": "proddash" },
|
{ "id": "rs_proddash", "label": "PRODDASH", "pos": [2, 2], "fix": "proddash" },
|
||||||
{ "id": "rs_captions", "label": "CAPTIONS", "pos": [2, 3], "fix": "captions", "watch": ["caption_screen"] },
|
{ "id": "rs_captions", "label": "CAPTIONS", "pos": [2, 3], "fix": "captions", "watch": ["caption_screen"] },
|
||||||
@@ -125,7 +128,7 @@
|
|||||||
{ "id": "rec", "label": "REC", "pos": [0, 2], "members": ["hd1", "hd2", "hd3", "hd4", "hd5", "hd6", "tascam"] },
|
{ "id": "rec", "label": "REC", "pos": [0, 2], "members": ["hd1", "hd2", "hd3", "hd4", "hd5", "hd6", "tascam"] },
|
||||||
{ "id": "audio", "label": "AUDIO", "pos": [0, 3], "members": ["x32", "dante"] },
|
{ "id": "audio", "label": "AUDIO", "pos": [0, 3], "members": ["x32", "dante"] },
|
||||||
{ "id": "rf", "label": "RF", "pos": [0, 4], "members": ["axient1", "axient2", "ulxd", "psm1", "psm2", "psm3"] },
|
{ "id": "rf", "label": "RF", "pos": [0, 4], "members": ["axient1", "axient2", "ulxd", "psm1", "psm2", "psm3"] },
|
||||||
{ "id": "show", "label": "SHOW", "pos": [0, 5], "members": ["midi", "apps", "vista"] },
|
{ "id": "show", "label": "SHOW", "pos": [0, 5], "members": ["midi", "apps", "vista", "spotify"] },
|
||||||
{ "id": "video", "label": "VIDEO", "pos": [0, 6], "members": ["atem", "led", "arena"] },
|
{ "id": "video", "label": "VIDEO", "pos": [0, 6], "members": ["atem", "led", "arena"] },
|
||||||
{ "id": "stream", "label": "STREAM", "pos": [0, 7], "members": ["obs", "resi"] },
|
{ "id": "stream", "label": "STREAM", "pos": [0, 7], "members": ["obs", "resi"] },
|
||||||
|
|
||||||
@@ -166,6 +169,10 @@
|
|||||||
{ "type": "mdns", "instance": "NA Tracks", "name": "TRACKS MIDI" },
|
{ "type": "mdns", "instance": "NA Tracks", "name": "TRACKS MIDI" },
|
||||||
{ "type": "midi_rx", "source": "Network NA Ingest Listen", "name": "TRACKS IN" }
|
{ "type": "midi_rx", "source": "Network NA Ingest Listen", "name": "TRACKS IN" }
|
||||||
] },
|
] },
|
||||||
|
{ "id": "spotify", "label": "SPOTIFY", "pos": [2, 7], "checks": [
|
||||||
|
{ "type": "spotify", "playlist": "spotify:playlist:2uTpJVYQLFnKF1WnSOuco8", "name": "Christian EDM 2026", "fix": "spotify_play",
|
||||||
|
"windows": [{ "days": ["sun"], "start": "06:30", "end": "13:30" }] }
|
||||||
|
] },
|
||||||
{ "id": "vista", "label": "VISTA", "pos": [2, 5], "checks": [
|
{ "id": "vista", "label": "VISTA", "pos": [2, 5], "checks": [
|
||||||
{ "type": "artnet", "source": "10.1.10.206", "max_silence_seconds": 5, "fix": "vista" }
|
{ "type": "artnet", "source": "10.1.10.206", "max_silence_seconds": 5, "fix": "vista" }
|
||||||
] },
|
] },
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
+78
-3
@@ -152,6 +152,11 @@ class Snapshot:
|
|||||||
self.conn_hosts = {} # Companion connection label -> its "host" setting
|
self.conn_hosts = {} # Companion connection label -> its "host" setting
|
||||||
self.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it
|
self.prefs = {} # (preferences domain, key) -> value as "defaults read" prints it
|
||||||
self.atem = None # atem_watch.js's latest report
|
self.atem = None # atem_watch.js's latest report
|
||||||
|
self.spotify = None # {"running", "state", "track", "name", "artist"} on NA Ingest
|
||||||
|
self.spotify_recent = deque(maxlen=3) # last distinct tracks played
|
||||||
|
self.spotify_tracks = set() # songs known to be in the service playlist
|
||||||
|
self.spotify_complete = False # ...and that is all of them
|
||||||
|
self.spotify_tracks_time = 0.0
|
||||||
self.midi_time = 0.0
|
self.midi_time = 0.0
|
||||||
self.mdns = set() # Bonjour _apple-midi._udp instance names
|
self.mdns = set() # Bonjour _apple-midi._udp instance names
|
||||||
self.mdns_time = 0.0
|
self.mdns_time = 0.0
|
||||||
@@ -418,6 +423,31 @@ def check_http(chk, snap, ctx):
|
|||||||
return [("http", Result(OK))]
|
return [("http", Result(OK))]
|
||||||
|
|
||||||
|
|
||||||
|
def check_spotify(chk, snap, ctx):
|
||||||
|
"""Spotify on NA Ingest must be playing the service playlist during the check's "windows"
|
||||||
|
(Sunday service). Outside them the button just says what it is doing."""
|
||||||
|
sp_ = snap.spotify
|
||||||
|
if sp_ is None:
|
||||||
|
return [("spotify", Result(OK))] # not read yet
|
||||||
|
state = "CLOSED" if not sp_.get("running") else (sp_.get("state") or "?").upper()
|
||||||
|
if not in_windows(chk.get("windows", []), ctx["now"]):
|
||||||
|
return [("spotify", Result(OK, info=state))]
|
||||||
|
if not sp_.get("running"):
|
||||||
|
return [("spotify", Result(ERR, "SPOTIFY CLOSED", named=True))]
|
||||||
|
if state != "PLAYING":
|
||||||
|
return [("spotify", Result(ERR, "NOT PLAYING"))]
|
||||||
|
known = snap.spotify_tracks
|
||||||
|
if known:
|
||||||
|
recent = list(snap.spotify_recent)
|
||||||
|
if snap.spotify_complete:
|
||||||
|
wrong = bool(recent) and recent[-1] not in known
|
||||||
|
else: # only part of the playlist is known: 3 strangers in a row
|
||||||
|
wrong = len(recent) >= 3 and all(t not in known for t in recent[-3:])
|
||||||
|
if wrong:
|
||||||
|
return [("spotify", Result(ERR, "WRONG PLAYLIST"))]
|
||||||
|
return [("spotify", Result(OK, info="PLAYING"))]
|
||||||
|
|
||||||
|
|
||||||
def check_pref(chk, snap, ctx):
|
def check_pref(chk, snap, ctx):
|
||||||
"""An app setting on NA Ingest, e.g. ATEM Software Control's "Enable MIDI control" must stay off."""
|
"""An app setting on NA Ingest, e.g. ATEM Software Control's "Enable MIDI control" must stay off."""
|
||||||
value = snap.prefs.get((chk["domain"], chk["key"]))
|
value = snap.prefs.get((chk["domain"], chk["key"]))
|
||||||
@@ -507,7 +537,7 @@ def check_remote(chk, snap, ctx):
|
|||||||
CHECKS = {
|
CHECKS = {
|
||||||
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
|
"conn": check_conn, "hyperdeck": check_hyperdeck, "dante": check_dante, "shure": check_shure,
|
||||||
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
|
"psm": check_psm, "tascam": check_tascam, "ping": check_ping, "launchd": check_launchd,
|
||||||
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "pref": check_pref,
|
"process": check_process, "http": check_http, "disk": check_disk, "midi_peer": check_midi_peer, "pref": check_pref, "spotify": check_spotify,
|
||||||
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
|
"mdns": check_mdns, "midi_rx": check_midi_rx, "remote": check_remote, "artnet": check_artnet,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1205,9 +1235,10 @@ class Engine:
|
|||||||
self.autolog.write(now, label, "problem: %s - fixing: %s (try %d of %d)" % (
|
self.autolog.write(now, label, "problem: %s - fixing: %s (try %d of %d)" % (
|
||||||
", ".join(faults) or "?", "; ".join(self.describe(k, w) for _, k, w, _ in plan),
|
", ".join(faults) or "?", "; ".join(self.describe(k, w) for _, k, w, _ in plan),
|
||||||
try_no, a.get("max_tries", 2)))
|
try_no, a.get("max_tries", 2)))
|
||||||
|
started = time.time()
|
||||||
self.run_plan(t["id"], plan, snap, comp, now, auto=True,
|
self.run_plan(t["id"], plan, snap, comp, now, auto=True,
|
||||||
on_done=(lambda outcome, label=label: self.autolog.write(time.time(), label, "-> " + outcome))
|
on_done=(lambda outcome, label=label, started=started: self.autolog.write(
|
||||||
if self.autolog else None)
|
now + (time.time() - started), label, "-> " + outcome)) if self.autolog else None)
|
||||||
done.append(t["id"])
|
done.append(t["id"])
|
||||||
forget = a.get("forget_seconds", 600)
|
forget = a.get("forget_seconds", 600)
|
||||||
for ident in list(self.auto_tries):
|
for ident in list(self.auto_tries):
|
||||||
@@ -1562,6 +1593,49 @@ def poll_conn_hosts(snap, cfg):
|
|||||||
log.warning("could not read connection addresses from Companion: %s", e)
|
log.warning("could not read connection addresses from Companion: %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
SPOTIFY_STATE = ('tell application "Spotify" to return (player state as text) & "|" & (id of current track) '
|
||||||
|
'& "|" & (name of current track) & "|" & (artist of current track)')
|
||||||
|
|
||||||
|
|
||||||
|
def playlist_tracks(playlist_uri):
|
||||||
|
"""(set of track uris, complete?) from Spotify's public pages: the embed preview lists up to
|
||||||
|
100 songs, the playlist page says how many there are."""
|
||||||
|
pid = playlist_uri.split(":")[-1]
|
||||||
|
req = lambda url: urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}),
|
||||||
|
timeout=10, context=_UNVERIFIED).read().decode("utf-8", "replace")
|
||||||
|
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
|
||||||
|
req("https://open.spotify.com/embed/playlist/" + pid))
|
||||||
|
entity = json.loads(m.group(1))["props"]["pageProps"]["state"]["data"]["entity"]
|
||||||
|
tracks = {t["uri"] for t in entity.get("trackList", []) if t.get("uri")}
|
||||||
|
n = re.search(r'music:song_count" content="(\d+)"', req("https://open.spotify.com/playlist/" + pid))
|
||||||
|
return tracks, bool(n) and len(tracks) >= int(n.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
def poll_spotify(snap, cfg):
|
||||||
|
checks = [c for t in cfg["tiles"] for c in t.get("checks", []) if c["type"] == "spotify"]
|
||||||
|
if not checks:
|
||||||
|
return
|
||||||
|
if time.time() - snap.spotify_tracks_time > 6 * 3600: # the playlist changes weekly
|
||||||
|
try:
|
||||||
|
snap.spotify_tracks, snap.spotify_complete = playlist_tracks(checks[0]["playlist"])
|
||||||
|
except (OSError, ValueError, KeyError, AttributeError, TypeError) as e:
|
||||||
|
log.warning("Spotify playlist list: %s", e)
|
||||||
|
snap.spotify_tracks_time = time.time()
|
||||||
|
# Never "tell" Spotify unless it is running: that would open it.
|
||||||
|
running = subprocess.run(["/usr/bin/pgrep", "-xq", "Spotify"], timeout=5).returncode == 0
|
||||||
|
if not running:
|
||||||
|
snap.spotify = {"running": False}
|
||||||
|
return
|
||||||
|
r = subprocess.run(["/usr/bin/osascript", "-e", SPOTIFY_STATE], capture_output=True, text=True, timeout=10)
|
||||||
|
if r.returncode != 0:
|
||||||
|
snap.spotify = {"running": True, "state": None, "error": r.stderr.strip()[-120:]}
|
||||||
|
return
|
||||||
|
parts = (r.stdout.strip().split("|") + ["", "", "", ""])[:4]
|
||||||
|
snap.spotify = {"running": True, "state": parts[0], "track": parts[1], "name": parts[2], "artist": parts[3]}
|
||||||
|
if parts[1] and (not snap.spotify_recent or snap.spotify_recent[-1] != parts[1]):
|
||||||
|
snap.spotify_recent.append(parts[1])
|
||||||
|
|
||||||
|
|
||||||
def poll_midi(snap, cfg):
|
def poll_midi(snap, cfg):
|
||||||
import coremidi
|
import coremidi
|
||||||
snap.midi, snap.midi_time = coremidi.sessions(), time.time()
|
snap.midi, snap.midi_time = coremidi.sessions(), time.time()
|
||||||
@@ -2222,6 +2296,7 @@ def start_pollers(cfg, snap, stop, data_dir=None):
|
|||||||
Periodic("variables", 1.0, lambda: poll_variables(c2, snap, fast), stop),
|
Periodic("variables", 1.0, lambda: poll_variables(c2, snap, fast), stop),
|
||||||
Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), stop),
|
Periodic("slow-variables", 3.0, lambda: poll_variables(c3, snap, slow), stop),
|
||||||
Periodic("conn-hosts", 60.0, lambda: poll_conn_hosts(snap, cfg), stop),
|
Periodic("conn-hosts", 60.0, lambda: poll_conn_hosts(snap, cfg), stop),
|
||||||
|
Periodic("spotify", 3.0, lambda: poll_spotify(snap, cfg), stop),
|
||||||
Periodic("pings", 3.0, lambda: poll_pings(snap, ping_hosts(cfg, snap), iface), stop),
|
Periodic("pings", 3.0, lambda: poll_pings(snap, ping_hosts(cfg, snap), iface), stop),
|
||||||
Periodic("midi", 0.5, lambda: poll_midi(snap, cfg), stop),
|
Periodic("midi", 0.5, lambda: poll_midi(snap, cfg), stop),
|
||||||
Periodic("system", 10.0, lambda: poll_system(snap, cfg), stop),
|
Periodic("system", 10.0, lambda: poll_system(snap, cfg), stop),
|
||||||
|
|||||||
@@ -108,6 +108,31 @@ class TestChecks(unittest.TestCase):
|
|||||||
self.assertEqual(tiles["hd1"]["x"], "HD1\n28h")
|
self.assertEqual(tiles["hd1"]["x"], "HD1\n28h")
|
||||||
self.assertEqual(tiles["dante"]["x"], "DANTE\n19/19")
|
self.assertEqual(tiles["dante"]["x"], "DANTE\n19/19")
|
||||||
|
|
||||||
|
def test_spotify(self):
|
||||||
|
chk = next(c for t in CONFIG["tiles"] for c in t.get("checks", []) if c["type"] == "spotify")
|
||||||
|
s = self.snap
|
||||||
|
msg = lambda ctx=self.ctx: sp.check_spotify(chk, s, ctx)[0][1]
|
||||||
|
self.assertEqual(msg().level, sp.OK) # not read yet
|
||||||
|
s.spotify = {"running": False}
|
||||||
|
self.assertEqual(msg().msg, "SPOTIFY CLOSED")
|
||||||
|
self.assertEqual(msg({"now": WEDNESDAY}).level, sp.OK) # only during services
|
||||||
|
s.spotify = {"running": True, "state": "paused", "track": "spotify:track:a"}
|
||||||
|
self.assertEqual(msg().msg, "NOT PLAYING")
|
||||||
|
s.spotify["state"] = "playing"
|
||||||
|
s.spotify_tracks = {"spotify:track:a", "spotify:track:b"}
|
||||||
|
s.spotify_recent.extend(["spotify:track:x", "spotify:track:y"])
|
||||||
|
self.assertEqual(msg().level, sp.OK) # list incomplete: 2 unknown is fine
|
||||||
|
s.spotify_recent.append("spotify:track:z")
|
||||||
|
self.assertEqual(msg().msg, "WRONG PLAYLIST")
|
||||||
|
s.spotify_recent.append("spotify:track:a")
|
||||||
|
self.assertEqual(msg().level, sp.OK)
|
||||||
|
s.spotify_complete = True
|
||||||
|
s.spotify_recent.append("spotify:track:q")
|
||||||
|
self.assertEqual(msg().msg, "WRONG PLAYLIST") # full list: one unknown is enough
|
||||||
|
fix = CONFIG["fixes"][chk["fix"]]
|
||||||
|
self.assertIn(chk["playlist"], fix["run"])
|
||||||
|
self.assertIn(chk["fix"], CONFIG["auto_fix"]["fixes"])
|
||||||
|
|
||||||
def test_connection_states(self):
|
def test_connection_states(self):
|
||||||
c = self.snap.conns["ATEM"]
|
c = self.snap.conns["ATEM"]
|
||||||
c["category"], c["message"] = "error", "Network error: connect ETIMEDOUT 10.1.10.2:9910"
|
c["category"], c["message"] = "error", "Network error: connect ETIMEDOUT 10.1.10.2:9910"
|
||||||
|
|||||||
Reference in New Issue
Block a user