Gate discovery-playlist notifications on Navidrome availability

Aoi was sending Matrix cards as soon as a playlist row appeared in the
discovery-playlist SQLite, before Navidrome had scanned and indexed it.
That produced 'ready' notifications for playlists that were not yet
playable.

The gate now requires the matching playlist (exact name match,
case-insensitive) to be visible in Navidrome via getPlaylists with at
least N entries (default 1, knob 'polling.discovery_min_tracks_in_navidrome')
before the notification fires. If a playlist stays missing from
Navidrome past 'polling.discovery_stale_after_hours' (default 48h), it
is marked sent without notification so we don't loop forever.

Refactored navidrome_playlist_cover into navidrome_find_playlist (name
match) and navidrome_playlist_cover_for (cover for an already-resolved
playlist), so the gate and the cover lookup share a single Navidrome
roundtrip.
This commit is contained in:
Rinka Makise-Okabe 2026-04-28 21:25:35 +03:00
parent 5fa857b9cc
commit 8f2b349ed9
2 changed files with 73 additions and 16 deletions

85
aoi.py
View file

@ -645,19 +645,23 @@ def playlist_tracks(playlist_id: int) -> list[dict]:
conn.close() conn.close()
def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | None: def navidrome_find_playlist(name: str) -> dict | None:
"""Return Navidrome playlist (basic info from getPlaylists) whose name matches `name` exactly, case-insensitive."""
try: try:
needle = name.strip().lower() needle = (name or "").strip().lower()
candidates = NAV.playlists() if not needle:
chosen = None
for pl in candidates:
pl_name = (pl.get("name") or "").strip().lower()
if pl_name == needle or needle in pl_name or pl_name in needle:
chosen = pl
break
if not chosen:
return None return None
full = NAV.playlist(str(chosen.get("id"))) for pl in NAV.playlists():
if (pl.get("name") or "").strip().lower() == needle:
return pl
except Exception as e:
log(f"navidrome lookup failed for {name!r}: {e}")
return None
def navidrome_playlist_cover_for(nav_pl: dict) -> tuple[bytes, str, str] | None:
try:
full = NAV.playlist(str(nav_pl.get("id")))
if full.get("coverArt"): if full.get("coverArt"):
return NAV.cover(full.get("coverArt")) return NAV.cover(full.get("coverArt"))
entries = full.get("entry") or [] entries = full.get("entry") or []
@ -668,6 +672,13 @@ def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | None:
return None return None
def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | None:
pl = navidrome_find_playlist(name)
if not pl:
return None
return navidrome_playlist_cover_for(pl)
PROFILE_LABELS = { PROFILE_LABELS = {
"deep_cuts": "глубокие находки", "deep_cuts": "глубокие находки",
"fresh_picks": "свежие рекомендации", "fresh_picks": "свежие рекомендации",
@ -726,10 +737,14 @@ def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]:
def poll_discovery_playlists(force: bool = False) -> dict: def poll_discovery_playlists(force: bool = False) -> dict:
baseline = (CFG.get("polling") or {}).get("baseline_existing_on_first_run", True) polling_cfg = CFG.get("polling") or {}
baseline = polling_cfg.get("baseline_existing_on_first_run", True)
nav_min_tracks = int(polling_cfg.get("discovery_min_tracks_in_navidrome", 1))
stale_after_hours = float(polling_cfg.get("discovery_stale_after_hours", 48))
first_run = not get_cursor("discovery_playlists_baselined") first_run = not get_cursor("discovery_playlists_baselined")
sent = 0 sent = 0
seen = 0 seen = 0
waiting = 0
for row in playlist_rows(): for row in playlist_rows():
pl = dict(row) pl = dict(row)
stamp = pl.get("last_generated") or pl.get("created_at") or "" stamp = pl.get("last_generated") or pl.get("created_at") or ""
@ -742,15 +757,55 @@ def poll_discovery_playlists(force: bool = False) -> dict:
if first_run and baseline and not force: if first_run and baseline and not force:
mark_sent("discovery_playlist", key, pl) mark_sent("discovery_playlist", key, pl)
continue continue
name = pl.get("name") or ""
nav_pl = navidrome_find_playlist(name)
nav_song_count = int((nav_pl or {}).get("songCount") or 0)
if not nav_pl or nav_song_count < nav_min_tracks:
# Not yet visible in Navidrome — defer notification until the playlist
# is actually playable. Do NOT mark_sent so the next poll re-checks.
stamp_dt = parse_dt(stamp)
age_hours = ((now_utc() - stamp_dt).total_seconds() / 3600) if stamp_dt else 0.0
if age_hours > stale_after_hours:
log(
f"discovery_playlist {name!r} stale: age={age_hours:.1f}h, "
f"navidrome_present={bool(nav_pl)}, nav_songs={nav_song_count}"
f"marking sent without notification (will not retry)"
)
mark_sent("discovery_playlist", key, {"playlist": pl, "skipped_stale": True})
else:
waiting += 1
log(
f"discovery_playlist {name!r} not ready in Navidrome "
f"(nav_present={bool(nav_pl)} nav_songs={nav_song_count} need>={nav_min_tracks} "
f"age={age_hours:.1f}h) — deferring"
)
continue
tracks = playlist_tracks(int(pl["id"])) tracks = playlist_tracks(int(pl["id"]))
plain, formatted = playlist_plain_and_html(pl, tracks) plain, formatted = playlist_plain_and_html(pl, tracks)
image = navidrome_playlist_cover(pl.get("name") or "") image = navidrome_playlist_cover_for(nav_pl)
MATRIX.send_card(plain, formatted, image) MATRIX.send_card(plain, formatted, image)
mark_sent("discovery_playlist", key, {"playlist": pl, "sent_at": now_utc().isoformat()}) mark_sent(
"discovery_playlist",
key,
{
"playlist": pl,
"sent_at": now_utc().isoformat(),
"navidrome_id": nav_pl.get("id"),
"navidrome_song_count": nav_song_count,
},
)
sent += 1 sent += 1
time.sleep(1) time.sleep(1)
set_cursor("discovery_playlists_baselined", now_utc().isoformat()) set_cursor("discovery_playlists_baselined", now_utc().isoformat())
return {"ok": True, "seen_new": seen, "sent": sent, "first_run": first_run} return {
"ok": True,
"seen_new": seen,
"sent": sent,
"waiting": waiting,
"first_run": first_run,
}
def release_rows(limit: int = 120) -> list[dict]: def release_rows(limit: int = 120) -> list[dict]:

View file

@ -32,7 +32,9 @@
"navidrome_album_interval_seconds": 300, "navidrome_album_interval_seconds": 300,
"discovery_playlist_interval_seconds": 180, "discovery_playlist_interval_seconds": 180,
"release_watch_interval_seconds": 600, "release_watch_interval_seconds": 600,
"baseline_existing_on_first_run": true "baseline_existing_on_first_run": true,
"discovery_min_tracks_in_navidrome": 1,
"discovery_stale_after_hours": 48
}, },
"server": { "server": {
"host": "0.0.0.0", "host": "0.0.0.0",