diff --git a/aoi.py b/aoi.py index b6b73e3..9a78aa9 100644 --- a/aoi.py +++ b/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]: diff --git a/config.example.json b/config.example.json index 432193e..e303321 100644 --- a/config.example.json +++ b/config.example.json @@ -32,7 +32,9 @@ "navidrome_album_interval_seconds": 300, "discovery_playlist_interval_seconds": 180, "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": { "host": "0.0.0.0",