Notify Matrix for liked library albums

This commit is contained in:
shu 2026-05-02 04:05:00 +03:00
parent 8f2b349ed9
commit 49503a78c4

633
aoi.py
View file

@ -27,6 +27,12 @@ CONFIG_PATH = Path(os.environ.get("AOI_CONFIG", BASE_DIR / "config.json"))
STATE_DB = Path(os.environ.get("AOI_STATE_DB", BASE_DIR / "aoi.db")) STATE_DB = Path(os.environ.get("AOI_STATE_DB", BASE_DIR / "aoi.db"))
LOG_PATH = Path(os.environ.get("AOI_LOG", BASE_DIR / "aoi.log")) LOG_PATH = Path(os.environ.get("AOI_LOG", BASE_DIR / "aoi.log"))
STATE_LOCK = threading.Lock() STATE_LOCK = threading.Lock()
DASHBOARD_LOCK = threading.Lock()
DASHBOARD_CACHE: dict[str, tuple[float, dict]] = {}
RELEASE_COVER_CACHE: dict[str, str] = {}
DASHBOARD_CACHE_SECONDS = 60
ARTIST_SIGNAL_CACHE: dict[str, tuple[float, dict]] = {}
ARTIST_SIGNAL_TTL_SECONDS = 12 * 60 * 60
def _load_config() -> dict: def _load_config() -> dict:
@ -89,6 +95,32 @@ def parse_date(value: Any) -> datetime | None:
return None return None
def parse_timezone(value: Any) -> timezone:
text = str(value or "").strip()
match = re.fullmatch(r"UTC([+-])(\d{1,2})(?::(\d{2}))?", text)
if not match:
return timezone.utc
sign = 1 if match.group(1) == "+" else -1
hours = int(match.group(2))
minutes = int(match.group(3) or 0)
return timezone(sign * timedelta(hours=hours, minutes=minutes))
def human_delta(target: datetime | None, now: datetime | None = None) -> str:
if not target:
return ""
now = now or now_utc()
seconds = max(0, int((target.astimezone(timezone.utc) - now.astimezone(timezone.utc)).total_seconds()))
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes, _ = divmod(rem, 60)
if days:
return f"{days}d {hours}h"
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def strip_html(text: str) -> str: def strip_html(text: str) -> str:
text = re.sub(r"<br\s*/?>", "\n", text or "", flags=re.I) text = re.sub(r"<br\s*/?>", "\n", text or "", flags=re.I)
text = re.sub(r"<[^>]+>", "", text) text = re.sub(r"<[^>]+>", "", text)
@ -108,6 +140,13 @@ def clean_lastfm_text(text: str) -> str:
return re.sub(r"\s+", " ", strip_html(text)).strip() return re.sub(r"\s+", " ", strip_html(text)).strip()
def parse_int(value: Any) -> int:
try:
return int(str(value or "0").replace(",", "").strip() or 0)
except Exception:
return 0
def init_state() -> None: def init_state() -> None:
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn: with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
@ -313,6 +352,15 @@ class NavidromeClient:
def playlist(self, playlist_id: str) -> dict: def playlist(self, playlist_id: str) -> dict:
return self.get("getPlaylist", id=playlist_id).get("playlist", {}) or {} return self.get("getPlaylist", id=playlist_id).get("playlist", {}) or {}
def search3(self, query: str, song_count: int = 5, album_count: int = 0, artist_count: int = 0) -> dict:
return self.get(
"search3",
query=query,
songCount=song_count,
albumCount=album_count,
artistCount=artist_count,
)
def cover(self, cover_art: str | None) -> tuple[bytes, str, str] | None: def cover(self, cover_art: str | None) -> tuple[bytes, str, str] | None:
if not cover_art: if not cover_art:
return None return None
@ -402,6 +450,76 @@ def lastfm_artist_summary(artist: str) -> str:
return "" return ""
def lastfm_artist_popularity(artist: str) -> dict:
data = lastfm_request("artist.getinfo", {"artist": artist}, timeout=18)
artist_data = data.get("artist") or {}
stats = artist_data.get("stats") or {}
listeners = parse_int(stats.get("listeners"))
playcount = parse_int(stats.get("playcount"))
mbid = artist_data.get("mbid") or ""
if listeners or playcount or mbid:
return {"listeners": listeners, "playcount": playcount, "mbid": mbid}
data = lastfm_request("artist.search", {"artist": artist, "limit": 1}, timeout=18)
matches = ((data.get("results") or {}).get("artistmatches") or {}).get("artist") or []
if isinstance(matches, dict):
matches = [matches]
match = matches[0] if matches else {}
return {
"listeners": parse_int(match.get("listeners")),
"playcount": parse_int(match.get("playcount")),
"mbid": match.get("mbid") or "",
}
def listenbrainz_headers() -> dict:
token = (CFG.get("metadata") or {}).get("listenbrainz_token") or discovery_setting("lb_token")
headers = metadata_headers()
if token:
headers["Authorization"] = f"Token {token}"
return headers
def listenbrainz_artist_popularity(artist_mbid: str) -> dict:
if not artist_mbid:
return {"listen_count": 0}
try:
r = HTTP.get(
f"https://api.listenbrainz.org/1/popularity/top-recordings-for-artist/{artist_mbid}",
params={"count": 25},
headers=listenbrainz_headers(),
timeout=25,
)
if not r.ok:
return {"listen_count": 0}
data = r.json()
if isinstance(data, dict):
items = data.get("payload") or []
elif isinstance(data, list):
items = data
else:
items = []
return {"listen_count": sum(parse_int(item.get("listen_count")) for item in items if isinstance(item, dict))}
except Exception as e:
log(f"listenbrainz artist popularity failed: {e}")
return {"listen_count": 0}
def log10_signal(value: Any) -> float:
import math
return math.log10(max(parse_int(value), 0) + 1)
def artist_signal_score(track_count: int, lastfm: dict, listenbrainz: dict) -> float:
return (
track_count * 2.0
+ log10_signal(lastfm.get("listeners")) * 2.5
+ log10_signal(lastfm.get("playcount")) * 1.2
+ log10_signal(listenbrainz.get("listen_count")) * 2.0
)
def lastfm_album_search_url(artist: str, album: str) -> str: def lastfm_album_search_url(artist: str, album: str) -> str:
return f"https://www.last.fm/search/albums?q={quote_plus(f'{artist} {album}')}" return f"https://www.last.fm/search/albums?q={quote_plus(f'{artist} {album}')}"
@ -441,6 +559,17 @@ def lastfm_album_info(artist: str, album: str) -> dict[str, str]:
def lastfm_album_summary(artist: str, album: str) -> str: def lastfm_album_summary(artist: str, album: str) -> str:
return lastfm_album_info(artist, album).get("summary", "") return lastfm_album_info(artist, album).get("summary", "")
def lastfm_album_image(artist: str, album: str) -> str:
data = lastfm_request("album.getinfo", {"artist": artist, "album": album}, timeout=20)
images = (data.get("album") or {}).get("image") or []
for image in reversed(images):
url = image.get("#text") or ""
if url:
return url
return ""
def musicbrainz_release_hint(artist: str, album: str) -> dict: def musicbrainz_release_hint(artist: str, album: str) -> dict:
if not (CFG.get("metadata") or {}).get("musicbrainz_enabled", True): if not (CFG.get("metadata") or {}).get("musicbrainz_enabled", True):
return {} return {}
@ -499,6 +628,44 @@ def lastfm_tags_for_tracks(tracks: list[dict]) -> Counter:
return tags return tags
def ranked_playlist_artists(tracks: list[dict], limit: int = 5) -> list[dict]:
grouped: dict[str, dict] = {}
for track in tracks:
artist = (track.get("artist") or "").strip()
if not artist:
continue
key = re.sub(r"\s+", " ", artist.lower()).strip()
item = grouped.setdefault(key, {"name": artist, "track_count": 0, "mbids": set()})
item["track_count"] += 1
if track.get("mbid_artist"):
item["mbids"].add(track.get("mbid_artist"))
ranked = []
now = time.time()
for key, item in grouped.items():
cached = ARTIST_SIGNAL_CACHE.get(key)
if cached and now - cached[0] < ARTIST_SIGNAL_TTL_SECONDS:
signals = cached[1]
else:
lastfm = lastfm_artist_popularity(item["name"])
mbid = next(iter(item["mbids"]), "") or lastfm.get("mbid") or ""
listenbrainz = listenbrainz_artist_popularity(mbid)
signals = {"lastfm": lastfm, "listenbrainz": listenbrainz}
ARTIST_SIGNAL_CACHE[key] = (now, signals)
ranked.append({
"name": item["name"],
"track_count": item["track_count"],
"score": artist_signal_score(item["track_count"], signals["lastfm"], signals["listenbrainz"]),
"lastfm_listeners": signals["lastfm"].get("listeners", 0),
"lastfm_playcount": signals["lastfm"].get("playcount", 0),
"listenbrainz_listens": signals["listenbrainz"].get("listen_count", 0),
})
ranked.sort(key=lambda x: x["score"], reverse=True)
return ranked[:limit]
def album_plain_and_html(album: dict, library_label: str, songs: list[dict]) -> tuple[str, str]: def album_plain_and_html(album: dict, library_label: str, songs: list[dict]) -> tuple[str, str]:
artist = album.get("artist") or "Unknown artist" artist = album.get("artist") or "Unknown artist"
title = album.get("name") or album.get("album") or "Unknown album" title = album.get("name") or album.get("album") or "Unknown album"
@ -600,6 +767,118 @@ def poll_navidrome_albums(force: bool = False) -> dict:
return {"ok": True, "seen_new": seen, "sent": sent, "first_run": first_run} return {"ok": True, "seen_new": seen, "sent": sent, "first_run": first_run}
def _loose_text_key(value: Any) -> str:
text = str(value or "").casefold()
text = text.replace("", "'").replace("`", "'")
return re.sub(r"[^a-z0-9а-яё]+", "", text, flags=re.I)
def find_navidrome_album(artist: str, album: str) -> dict:
artist_key = _loose_text_key(artist)
album_key = _loose_text_key(album)
if not artist_key or not album_key:
return {}
try:
results = NAV.search3(f"{artist} {album}", song_count=0, album_count=8)
albums = (results.get("searchResult3") or {}).get("album") or []
matches = []
for item in albums:
item_artist_key = _loose_text_key(item.get("artist") or "")
item_album_key = _loose_text_key(item.get("name") or item.get("album") or "")
if (
item.get("id")
and (artist_key in item_artist_key or item_artist_key in artist_key)
and (album_key in item_album_key or item_album_key in album_key)
):
try:
full = NAV.album(str(item.get("id")))
song_count = len(full.get("song") or [])
merged = {**item, **full, "_song_count": song_count}
matches.append(merged)
except Exception:
item["_song_count"] = int(item.get("songCount") or 0)
matches.append(item)
if matches:
matches.sort(key=lambda item: int(item.get("_song_count") or item.get("songCount") or 0), reverse=True)
return matches[0]
except Exception as e:
log(f"liked album Navidrome search failed for {artist} - {album}: {e}")
return {}
def downloaded_library_album_rows(limit: int = 50) -> list[dict]:
conn = discovery_conn()
if not conn:
return []
try:
rows = conn.execute(
"""
SELECT id, playlist_id, artist, album, generation_id, moved_to_library, created_at
FROM downloaded_albums
WHERE moved_to_library=1
ORDER BY id DESC
LIMIT ?
""",
(limit,),
).fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def liked_album_plain_and_html(row: dict, full_album: dict, songs: list[dict]) -> tuple[str, str]:
album = dict(full_album or {})
album.setdefault("artist", row.get("artist") or "Unknown artist")
album.setdefault("name", row.get("album") or "Unknown album")
plain, formatted = album_plain_and_html(album, "liked", songs)
plain = plain.replace("Новый релиз в Navidrome / liked", "Лайкнутый альбом добавлен в библиотеку", 1)
formatted = formatted.replace("Новый релиз в Navidrome / liked", "Лайкнутый альбом добавлен в библиотеку", 1)
return plain, formatted
def poll_liked_library_albums(force: bool = False) -> dict:
polling_cfg = CFG.get("polling") or {}
baseline = polling_cfg.get("baseline_existing_on_first_run", True)
first_run = not get_cursor("liked_library_albums_baselined")
sent = 0
seen = 0
waiting = 0
for row in downloaded_library_album_rows(int(polling_cfg.get("liked_album_poll_size") or 50)):
key = f"{row.get('id')}:{row.get('created_at')}"
if already_sent("liked_library_album", key):
continue
seen += 1
if first_run and baseline and not force:
mark_sent("liked_library_album", key, row)
continue
artist = row.get("artist") or ""
album = row.get("album") or ""
nav_album = find_navidrome_album(artist, album)
if not nav_album:
waiting += 1
log(f"liked album {artist!r} - {album!r} not visible in Navidrome yet — deferring")
continue
full_album = nav_album if nav_album.get("song") else NAV.album(str(nav_album.get("id")))
songs = full_album.get("song") or []
plain, formatted = liked_album_plain_and_html(row, full_album or nav_album, songs)
image = NAV.cover((full_album or nav_album).get("coverArt"))
MATRIX.send_card(plain, formatted, image)
mark_sent(
"liked_library_album",
key,
{
"album": row,
"sent_at": now_utc().isoformat(),
"navidrome_id": nav_album.get("id"),
"song_count": len(songs),
},
)
sent += 1
time.sleep(1)
set_cursor("liked_library_albums_baselined", now_utc().isoformat())
return {"ok": True, "seen_new": seen, "sent": sent, "waiting": waiting, "first_run": first_run}
def discovery_conn() -> sqlite3.Connection | None: def discovery_conn() -> sqlite3.Connection | None:
db_path = (CFG.get("discovery") or {}).get("db_path") or "" db_path = (CFG.get("discovery") or {}).get("db_path") or ""
if not db_path or not Path(db_path).exists(): if not db_path or not Path(db_path).exists():
@ -645,6 +924,49 @@ def playlist_tracks(playlist_id: int) -> list[dict]:
conn.close() conn.close()
def playlist_download_stats(playlist_id: int) -> dict:
conn = discovery_conn()
if not conn:
return {"total": 0, "done": 0, "final": 0, "running": False, "done_track_ids": set()}
try:
playlist = conn.execute(
"SELECT download_running FROM playlists WHERE id=?",
(playlist_id,),
).fetchone()
rows = conn.execute(
"""
SELECT status, COUNT(*) AS count
FROM download_status
WHERE playlist_id=?
GROUP BY status
""",
(playlist_id,),
).fetchall()
done_rows = conn.execute(
"""
SELECT track_id
FROM download_status
WHERE playlist_id=? AND status='done' AND track_id IS NOT NULL
""",
(playlist_id,),
).fetchall()
counts = {str(row["status"]): int(row["count"]) for row in rows}
final = sum(counts.get(status, 0) for status in ("done", "error", "skipped", "failed"))
total = sum(counts.values())
return {
"total": total,
"done": counts.get("done", 0),
"error": counts.get("error", 0),
"skipped": counts.get("skipped", 0),
"failed": counts.get("failed", 0),
"final": final,
"running": bool(playlist["download_running"]) if playlist else False,
"done_track_ids": {int(row["track_id"]) for row in done_rows if row["track_id"] is not None},
}
finally:
conn.close()
def navidrome_find_playlist(name: str) -> dict | None: def navidrome_find_playlist(name: str) -> dict | None:
"""Return Navidrome playlist (basic info from getPlaylists) whose name matches `name` exactly, case-insensitive.""" """Return Navidrome playlist (basic info from getPlaylists) whose name matches `name` exactly, case-insensitive."""
try: try:
@ -679,6 +1001,23 @@ def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | None:
return navidrome_playlist_cover_for(pl) return navidrome_playlist_cover_for(pl)
def discovery_playlist_ready(pl: dict, nav_pl: dict | None, nav_min_tracks: int) -> tuple[bool, dict]:
stats = playlist_download_stats(int(pl["id"]))
if stats["running"]:
return False, {**stats, "reason": "download_running", "navidrome_tracks": int((nav_pl or {}).get("songCount") or 0)}
if stats["total"] <= 0 or stats["final"] < stats["total"]:
return False, {**stats, "reason": "download_not_final", "navidrome_tracks": int((nav_pl or {}).get("songCount") or 0)}
if stats["done"] <= 0:
return False, {**stats, "reason": "nothing_downloaded", "navidrome_tracks": int((nav_pl or {}).get("songCount") or 0)}
nav_song_count = int((nav_pl or {}).get("songCount") or 0)
required = max(nav_min_tracks, stats["done"])
if not nav_pl or nav_song_count < required:
return False, {**stats, "reason": "not_in_navidrome", "navidrome_tracks": nav_song_count, "required_navidrome_tracks": required}
return True, {**stats, "reason": "ready", "navidrome_tracks": nav_song_count, "required_navidrome_tracks": required}
PROFILE_LABELS = { PROFILE_LABELS = {
"deep_cuts": "глубокие находки", "deep_cuts": "глубокие находки",
"fresh_picks": "свежие рекомендации", "fresh_picks": "свежие рекомендации",
@ -686,7 +1025,7 @@ PROFILE_LABELS = {
} }
def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]: def playlist_plain_and_html(pl: dict, tracks: list[dict], readiness: dict | None = None) -> tuple[str, str]:
name = pl.get("name") or "Discovery" name = pl.get("name") or "Discovery"
created = pl.get("last_generated") or pl.get("created_at") or "" created = pl.get("last_generated") or pl.get("created_at") or ""
top_tracks = tracks[:8] top_tracks = tracks[:8]
@ -699,8 +1038,14 @@ def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]:
tag_text = ", ".join(tag for tag, _ in tags.most_common(6)) tag_text = ", ".join(tag for tag, _ in tags.most_common(6))
if not tag_text: if not tag_text:
tag_text = profile_text tag_text = profile_text
artists = Counter((t.get("artist") or "").strip() for t in tracks if t.get("artist")) ranked_artists = ranked_playlist_artists(tracks)
artist_text = ", ".join(a for a, _ in artists.most_common(5)) artist_text = ", ".join(a["name"] for a in ranked_artists)
download_text = ""
if readiness:
download_text = (
f"Скачано: {readiness.get('done', 0)}, "
f"в Navidrome: {readiness.get('navidrome_tracks', 0)}"
)
summary = ( summary = (
f"В этот раз уклон: {tag_text}. " f"В этот раз уклон: {tag_text}. "
f"Из заметного: {', '.join(line[2:] for line in top_lines[:4])}." f"Из заметного: {', '.join(line[2:] for line in top_lines[:4])}."
@ -710,6 +1055,7 @@ def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]:
"", "",
name, name,
f"Треков: {len(tracks)}", f"Треков: {len(tracks)}",
download_text,
f"Собран: {created}", f"Собран: {created}",
f"Профиль: {profile_text}", f"Профиль: {profile_text}",
f"Главные артисты: {artist_text or 'пока не выделяются'}", f"Главные артисты: {artist_text or 'пока не выделяются'}",
@ -724,6 +1070,7 @@ def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]:
f"<b>{html.escape(name)}</b>", f"<b>{html.escape(name)}</b>",
"<ul>", "<ul>",
f"<li>Треков: {len(tracks)}</li>", f"<li>Треков: {len(tracks)}</li>",
f"<li>{html.escape(download_text)}</li>" if download_text else "",
f"<li>Собран: {html.escape(str(created))}</li>", f"<li>Собран: {html.escape(str(created))}</li>",
f"<li>Профиль: {html.escape(profile_text)}</li>", f"<li>Профиль: {html.escape(profile_text)}</li>",
f"<li>Главные артисты: {html.escape(artist_text or 'пока не выделяются')}</li>", f"<li>Главные артисты: {html.escape(artist_text or 'пока не выделяются')}</li>",
@ -754,36 +1101,51 @@ def poll_discovery_playlists(force: bool = False) -> dict:
if already_sent("discovery_playlist", key): if already_sent("discovery_playlist", key):
continue continue
seen += 1 seen += 1
if first_run and baseline and not force:
mark_sent("discovery_playlist", key, pl)
continue
name = pl.get("name") or "" name = pl.get("name") or ""
nav_pl = navidrome_find_playlist(name) nav_pl = navidrome_find_playlist(name)
nav_song_count = int((nav_pl or {}).get("songCount") or 0) ready, readiness = discovery_playlist_ready(pl, nav_pl, nav_min_tracks)
if not nav_pl or nav_song_count < nav_min_tracks: nav_song_count = int(readiness.get("navidrome_tracks") or 0)
# Not yet visible in Navidrome — defer notification until the playlist if not ready:
# is actually playable. Do NOT mark_sent so the next poll re-checks. # Not yet downloaded or 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) stamp_dt = parse_dt(stamp)
age_hours = ((now_utc() - stamp_dt).total_seconds() / 3600) if stamp_dt else 0.0 age_hours = ((now_utc() - stamp_dt).total_seconds() / 3600) if stamp_dt else 0.0
if age_hours > stale_after_hours: if age_hours > stale_after_hours:
log( log(
f"discovery_playlist {name!r} stale: age={age_hours:.1f}h, " f"discovery_playlist {name!r} stale: age={age_hours:.1f}h, "
f"navidrome_present={bool(nav_pl)}, nav_songs={nav_song_count}" f"reason={readiness.get('reason')} navidrome_present={bool(nav_pl)}, "
f"nav_songs={nav_song_count} done={readiness.get('done', 0)}"
f"marking sent without notification (will not retry)" f"marking sent without notification (will not retry)"
) )
mark_sent("discovery_playlist", key, {"playlist": pl, "skipped_stale": True}) mark_sent("discovery_playlist", key, {
"playlist": pl,
"readiness": {k: v for k, v in readiness.items() if k != "done_track_ids"},
"skipped_stale": True,
})
else: else:
waiting += 1 waiting += 1
log( log(
f"discovery_playlist {name!r} not ready in Navidrome " f"discovery_playlist {name!r} not ready for notification "
f"(nav_present={bool(nav_pl)} nav_songs={nav_song_count} need>={nav_min_tracks} " f"(reason={readiness.get('reason')} nav_present={bool(nav_pl)} "
f"nav_songs={nav_song_count} done={readiness.get('done', 0)} "
f"need>={readiness.get('required_navidrome_tracks', nav_min_tracks)} "
f"age={age_hours:.1f}h) — deferring" f"age={age_hours:.1f}h) — deferring"
) )
continue continue
if first_run and baseline and not force:
mark_sent("discovery_playlist", key, {
"playlist": pl,
"readiness": {k: v for k, v in readiness.items() if k != "done_track_ids"},
"baselined_at": now_utc().isoformat(),
})
continue
tracks = playlist_tracks(int(pl["id"])) tracks = playlist_tracks(int(pl["id"]))
plain, formatted = playlist_plain_and_html(pl, tracks) done_track_ids = readiness.get("done_track_ids") or set()
ready_tracks = [track for track in tracks if int(track.get("id") or 0) in done_track_ids] or tracks
plain, formatted = playlist_plain_and_html(pl, ready_tracks, readiness)
image = navidrome_playlist_cover_for(nav_pl) image = navidrome_playlist_cover_for(nav_pl)
MATRIX.send_card(plain, formatted, image) MATRIX.send_card(plain, formatted, image)
mark_sent( mark_sent(
@ -794,6 +1156,7 @@ def poll_discovery_playlists(force: bool = False) -> dict:
"sent_at": now_utc().isoformat(), "sent_at": now_utc().isoformat(),
"navidrome_id": nav_pl.get("id"), "navidrome_id": nav_pl.get("id"),
"navidrome_song_count": nav_song_count, "navidrome_song_count": nav_song_count,
"readiness": {k: v for k, v in readiness.items() if k != "done_track_ids"},
}, },
) )
sent += 1 sent += 1
@ -927,12 +1290,251 @@ def healthz():
return jsonify({"ok": True}) return jsonify({"ok": True})
def sent_event_rows(kind: str, limit: int) -> list[dict]:
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
conn.execute("PRAGMA busy_timeout=30000")
rows = conn.execute(
"""
SELECT payload_json, created_at
FROM sent_events
WHERE kind=?
ORDER BY created_at DESC
LIMIT ?
""",
(kind, limit),
).fetchall()
out = []
for payload_json, created_at in rows:
try:
payload = json.loads(payload_json or "{}")
except Exception:
payload = {}
out.append({"payload": payload, "created_at": created_at})
return out
def navidrome_cover_url(cover_art: Any) -> str:
cover_art = str(cover_art or "").strip()
return f"/covers/navidrome/{cover_art}" if cover_art else ""
def dashboard_albums(limit: int) -> list[dict]:
items = []
for row in sent_event_rows("navidrome_album", limit):
album = row["payload"].get("album") or row["payload"]
items.append(
{
"artist": album.get("artist") or "Unknown artist",
"title": album.get("name") or album.get("album") or "Unknown album",
"year": album.get("year") or "",
"song_count": album.get("songCount") or album.get("song_count") or "",
"created": album.get("created") or row["created_at"],
"cover_url": navidrome_cover_url(album.get("coverArt")),
}
)
return items
def playlist_cover_art(nav_pl: dict) -> str:
cover_art = nav_pl.get("coverArt") or ""
if cover_art:
return cover_art
try:
full = NAV.playlist(str(nav_pl.get("id")))
cover_art = full.get("coverArt") or ""
if cover_art:
return cover_art
entries = full.get("entry") or []
if entries:
return entries[0].get("coverArt") or ""
except Exception as e:
if "playlist not found" not in str(e).lower():
log(f"dashboard playlist cover failed: {e}")
return ""
def playlist_track_cover_art(playlist_id: Any) -> str:
try:
for track in playlist_tracks(int(playlist_id))[:5]:
query = " ".join(str(v or "") for v in (track.get("artist"), track.get("track"))).strip()
if not query:
continue
results = NAV.search3(query, song_count=3)
songs = (results.get("searchResult3") or {}).get("song") or []
for song in songs:
if song.get("coverArt"):
return song.get("coverArt") or ""
except Exception as e:
log(f"dashboard playlist track cover failed: {e}")
return ""
def release_cover_art(artist: str, album: str) -> str:
cache_key = f"{artist}\0{album}".casefold()
if cache_key in RELEASE_COVER_CACHE:
return RELEASE_COVER_CACHE[cache_key]
try:
results = NAV.search3(f"{artist} {album}", song_count=0, album_count=3)
albums = (results.get("searchResult3") or {}).get("album") or []
artist_l = artist.casefold()
album_l = album.casefold()
for item in albums:
item_artist = str(item.get("artist") or "").casefold()
item_album = str(item.get("name") or "").casefold()
if item.get("coverArt") and (artist_l in item_artist or item_artist in artist_l) and (album_l in item_album or item_album in album_l):
RELEASE_COVER_CACHE[cache_key] = item.get("coverArt") or ""
return RELEASE_COVER_CACHE[cache_key]
for item in albums:
if item.get("coverArt"):
RELEASE_COVER_CACHE[cache_key] = item.get("coverArt") or ""
return RELEASE_COVER_CACHE[cache_key]
except Exception as e:
log(f"dashboard release cover failed: {e}")
RELEASE_COVER_CACHE[cache_key] = ""
return ""
def playlist_next_generation(playlist: dict) -> datetime | None:
now = now_utc()
interval = playlist.get("generation_interval_minutes")
last_generated = parse_dt(playlist.get("last_generated")) or parse_dt(playlist.get("created_at"))
if interval and last_generated:
try:
target = last_generated + timedelta(minutes=int(interval))
while target <= now:
target += timedelta(minutes=int(interval))
return target
except Exception:
pass
time_text = str(playlist.get("generation_time") or "").strip()
if not time_text:
return None
tz = parse_timezone(playlist.get("generation_timezone"))
local_now = now.astimezone(tz)
try:
hour, minute = [int(part) for part in time_text.split(":", 1)]
except Exception:
return None
days_raw = playlist.get("generation_days")
days: list[int] = []
if days_raw:
try:
parsed = json.loads(days_raw) if isinstance(days_raw, str) else days_raw
days = [int(day) for day in parsed]
except Exception:
days = []
for offset in range(0, 14):
candidate_date = local_now.date() + timedelta(days=offset)
candidate = datetime(candidate_date.year, candidate_date.month, candidate_date.day, hour, minute, tzinfo=tz)
# Python Monday is 0; stored schedule uses 1 for Monday.
weekday = candidate.weekday() + 1
if days and weekday not in days:
continue
if candidate > local_now:
return candidate.astimezone(timezone.utc)
return None
def dashboard_playlists(limit: int) -> list[dict]:
items = []
seen_names = set()
for row in sent_event_rows("discovery_playlist", limit * 3):
payload = row["payload"]
playlist = payload.get("playlist") or payload
name = playlist.get("name") or "Discovery"
name_key = name.casefold()
if name_key in seen_names:
continue
seen_names.add(name_key)
navidrome_id = payload.get("navidrome_id") or ""
cover_art = ""
if navidrome_id:
cover_art = playlist_cover_art({"id": navidrome_id})
if not cover_art:
nav_pl = navidrome_find_playlist(name)
if nav_pl:
cover_art = playlist_cover_art(nav_pl)
next_generation = playlist_next_generation(playlist)
items.append(
{
"name": name,
"track_count": playlist.get("track_count") or playlist.get("songCount") or "",
"generated": playlist.get("last_generated") or playlist.get("created_at") or row["created_at"],
"next_generation": next_generation.isoformat() if next_generation else "",
"next_generation_in": human_delta(next_generation) if next_generation else "",
"cover_url": navidrome_cover_url(cover_art),
}
)
if len(items) >= limit:
break
return items
def dashboard_releases(limit: int) -> list[dict]:
items = []
seen = set()
for row in sent_event_rows("release_out", limit * 2):
release = row["payload"]
artist = release.get("artist") or "Unknown artist"
album = release.get("album") or "Unknown release"
key = (artist.lower(), album.lower())
if key in seen:
continue
seen.add(key)
cover_art = release_cover_art(artist, album)
items.append(
{
"artist": artist,
"title": album,
"release_date": release.get("release_date") or "",
"status": release.get("status") or "",
"source": release.get("source") or "",
"cover_url": navidrome_cover_url(cover_art),
}
)
if len(items) >= limit:
break
return items
@app.get("/dashboard")
def dashboard():
album_limit = min(max(int(request.args.get("album_limit", 8)), 1), 24)
playlist_limit = min(max(int(request.args.get("playlist_limit", 8)), 1), 24)
release_limit = min(max(int(request.args.get("release_limit", 10)), 1), 24)
cache_key = f"{album_limit}:{playlist_limit}:{release_limit}"
now_ts = time.monotonic()
with DASHBOARD_LOCK:
cached = DASHBOARD_CACHE.get(cache_key)
if cached and now_ts - cached[0] < DASHBOARD_CACHE_SECONDS:
return jsonify(cached[1])
payload = {
"ok": True,
"albums": dashboard_albums(album_limit),
"playlists": dashboard_playlists(playlist_limit),
"releases": dashboard_releases(release_limit),
}
DASHBOARD_CACHE[cache_key] = (time.monotonic(), payload)
return jsonify(payload)
@app.post("/run/navidrome-albums") @app.post("/run/navidrome-albums")
def run_navidrome_albums(): def run_navidrome_albums():
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
return jsonify(poll_navidrome_albums(force=bool(payload.get("force")))) return jsonify(poll_navidrome_albums(force=bool(payload.get("force"))))
@app.post("/run/liked-albums")
def run_liked_albums():
payload = request.get_json(silent=True) or {}
return jsonify(poll_liked_library_albums(force=bool(payload.get("force"))))
@app.post("/run/discovery-playlists") @app.post("/run/discovery-playlists")
def run_discovery_playlists(): def run_discovery_playlists():
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
@ -949,6 +1551,7 @@ def start_background() -> None:
polling = CFG.get("polling") or {} polling = CFG.get("polling") or {}
tasks = [ tasks = [
("navidrome_albums", int(polling.get("navidrome_album_interval_seconds") or 300), poll_navidrome_albums), ("navidrome_albums", int(polling.get("navidrome_album_interval_seconds") or 300), poll_navidrome_albums),
("liked_library_albums", int(polling.get("liked_album_interval_seconds") or 300), poll_liked_library_albums),
("discovery_playlists", int(polling.get("discovery_playlist_interval_seconds") or 180), poll_discovery_playlists), ("discovery_playlists", int(polling.get("discovery_playlist_interval_seconds") or 180), poll_discovery_playlists),
("release_watch", int(polling.get("release_watch_interval_seconds") or 600), poll_release_watch), ("release_watch", int(polling.get("release_watch_interval_seconds") or 600), poll_release_watch),
] ]