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:
parent
5fa857b9cc
commit
8f2b349ed9
2 changed files with 73 additions and 16 deletions
85
aoi.py
85
aoi.py
|
|
@ -645,19 +645,23 @@ def playlist_tracks(playlist_id: int) -> list[dict]:
|
|||
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:
|
||||
needle = name.strip().lower()
|
||||
candidates = NAV.playlists()
|
||||
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:
|
||||
needle = (name or "").strip().lower()
|
||||
if not needle:
|
||||
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"):
|
||||
return NAV.cover(full.get("coverArt"))
|
||||
entries = full.get("entry") or []
|
||||
|
|
@ -668,6 +672,13 @@ def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | 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 = {
|
||||
"deep_cuts": "глубокие находки",
|
||||
"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:
|
||||
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")
|
||||
sent = 0
|
||||
seen = 0
|
||||
waiting = 0
|
||||
for row in playlist_rows():
|
||||
pl = dict(row)
|
||||
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:
|
||||
mark_sent("discovery_playlist", key, pl)
|
||||
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"]))
|
||||
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)
|
||||
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
|
||||
time.sleep(1)
|
||||
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]:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue