Notify Matrix for liked library albums
This commit is contained in:
parent
8f2b349ed9
commit
49503a78c4
1 changed files with 618 additions and 15 deletions
633
aoi.py
633
aoi.py
|
|
@ -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"))
|
||||
LOG_PATH = Path(os.environ.get("AOI_LOG", BASE_DIR / "aoi.log"))
|
||||
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:
|
||||
|
|
@ -89,6 +95,32 @@ def parse_date(value: Any) -> datetime | 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:
|
||||
text = re.sub(r"<br\s*/?>", "\n", text or "", flags=re.I)
|
||||
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()
|
||||
|
||||
|
||||
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:
|
||||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
|
@ -313,6 +352,15 @@ class NavidromeClient:
|
|||
def playlist(self, playlist_id: str) -> dict:
|
||||
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:
|
||||
if not cover_art:
|
||||
return None
|
||||
|
|
@ -402,6 +450,76 @@ def lastfm_artist_summary(artist: str) -> str:
|
|||
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:
|
||||
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:
|
||||
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:
|
||||
if not (CFG.get("metadata") or {}).get("musicbrainz_enabled", True):
|
||||
return {}
|
||||
|
|
@ -499,6 +628,44 @@ def lastfm_tags_for_tracks(tracks: list[dict]) -> Counter:
|
|||
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]:
|
||||
artist = album.get("artist") or "Unknown artist"
|
||||
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}
|
||||
|
||||
|
||||
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:
|
||||
db_path = (CFG.get("discovery") or {}).get("db_path") or ""
|
||||
if not db_path or not Path(db_path).exists():
|
||||
|
|
@ -645,6 +924,49 @@ def playlist_tracks(playlist_id: int) -> list[dict]:
|
|||
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:
|
||||
"""Return Navidrome playlist (basic info from getPlaylists) whose name matches `name` exactly, case-insensitive."""
|
||||
try:
|
||||
|
|
@ -679,6 +1001,23 @@ def navidrome_playlist_cover(name: str) -> tuple[bytes, str, str] | None:
|
|||
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 = {
|
||||
"deep_cuts": "глубокие находки",
|
||||
"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"
|
||||
created = pl.get("last_generated") or pl.get("created_at") or ""
|
||||
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))
|
||||
if not tag_text:
|
||||
tag_text = profile_text
|
||||
artists = Counter((t.get("artist") or "").strip() for t in tracks if t.get("artist"))
|
||||
artist_text = ", ".join(a for a, _ in artists.most_common(5))
|
||||
ranked_artists = ranked_playlist_artists(tracks)
|
||||
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 = (
|
||||
f"В этот раз уклон: {tag_text}. "
|
||||
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,
|
||||
f"Треков: {len(tracks)}",
|
||||
download_text,
|
||||
f"Собран: {created}",
|
||||
f"Профиль: {profile_text}",
|
||||
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>",
|
||||
"<ul>",
|
||||
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(profile_text)}</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):
|
||||
continue
|
||||
seen += 1
|
||||
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.
|
||||
ready, readiness = discovery_playlist_ready(pl, nav_pl, nav_min_tracks)
|
||||
nav_song_count = int(readiness.get("navidrome_tracks") or 0)
|
||||
if not ready:
|
||||
# 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)
|
||||
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"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)"
|
||||
)
|
||||
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:
|
||||
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"discovery_playlist {name!r} not ready for notification "
|
||||
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"
|
||||
)
|
||||
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"]))
|
||||
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)
|
||||
MATRIX.send_card(plain, formatted, image)
|
||||
mark_sent(
|
||||
|
|
@ -794,6 +1156,7 @@ def poll_discovery_playlists(force: bool = False) -> dict:
|
|||
"sent_at": now_utc().isoformat(),
|
||||
"navidrome_id": nav_pl.get("id"),
|
||||
"navidrome_song_count": nav_song_count,
|
||||
"readiness": {k: v for k, v in readiness.items() if k != "done_track_ids"},
|
||||
},
|
||||
)
|
||||
sent += 1
|
||||
|
|
@ -927,12 +1290,251 @@ def healthz():
|
|||
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")
|
||||
def run_navidrome_albums():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
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")
|
||||
def run_discovery_playlists():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
|
@ -949,6 +1551,7 @@ def start_background() -> None:
|
|||
polling = CFG.get("polling") or {}
|
||||
tasks = [
|
||||
("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),
|
||||
("release_watch", int(polling.get("release_watch_interval_seconds") or 600), poll_release_watch),
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue