diff --git a/aoi.py b/aoi.py
index bd271d4..dc78d39 100644
--- a/aoi.py
+++ b/aoi.py
@@ -19,7 +19,7 @@ from typing import Any
from urllib.parse import quote_plus
import requests
-from flask import Flask, jsonify, request
+from flask import Flask, Response, jsonify, request
BASE_DIR = Path(__file__).resolve().parent
@@ -121,6 +121,24 @@ def human_delta(target: datetime | None, now: datetime | None = None) -> str:
return f"{minutes}m"
+def relative_datetime_label(value: Any, tz_value: Any = None, now: datetime | None = None) -> str:
+ dt = parse_dt(value)
+ if not dt:
+ return ""
+ tz = parse_timezone(tz_value or "UTC+03:00")
+ local_dt = dt.astimezone(tz)
+ local_now = (now or now_utc()).astimezone(tz)
+ day_delta = (local_dt.date() - local_now.date()).days
+ time_text = local_dt.strftime("%H:%M")
+ if day_delta == -1:
+ return f"yesterday {time_text}"
+ if day_delta == 0:
+ return f"today {time_text}"
+ if day_delta == 1:
+ return f"tomorrow {time_text}"
+ return local_dt.strftime("%Y-%m-%d %H:%M")
+
+
def strip_html(text: str) -> str:
text = re.sub(r"
", "\n", text or "", flags=re.I)
text = re.sub(r"<[^>]+>", "", text)
@@ -342,6 +360,14 @@ class NavidromeClient:
data = self.get("getAlbumList2", type="newest", size=size, musicFolderId=music_folder_id)
return data.get("albumList2", {}).get("album", []) or []
+ def albums_by_year(self, size: int, music_folder_id: int | None = None) -> list[dict]:
+ year = datetime.now(timezone.utc).year + 1
+ params = {"type": "byYear", "fromYear": year, "toYear": 1900, "size": size}
+ if music_folder_id:
+ params["musicFolderId"] = music_folder_id
+ data = self.get("getAlbumList2", **params)
+ return data.get("albumList2", {}).get("album", []) or []
+
def album(self, album_id: str) -> dict:
return self.get("getAlbum", id=album_id).get("album", {}) or {}
@@ -757,6 +783,19 @@ def poll_navidrome_albums(force: bool = False) -> dict:
continue
full_album = NAV.album(album_id)
songs = full_album.get("song") or []
+ duplicate_source = matching_downloaded_library_album(full_album or album)
+ if duplicate_source and not force:
+ mark_sent(
+ "navidrome_album",
+ key,
+ {
+ "album": album,
+ "sent_at": now_utc().isoformat(),
+ "skipped_duplicate_kind": "liked_library_album",
+ "downloaded_album": duplicate_source,
+ },
+ )
+ continue
plain, formatted = album_plain_and_html(full_album or album, label, songs)
image = NAV.cover((full_album or album).get("coverArt"))
MATRIX.send_card(plain, formatted, image)
@@ -826,6 +865,34 @@ def downloaded_library_album_rows(limit: int = 50) -> list[dict]:
conn.close()
+def matching_downloaded_library_album(album: dict, limit: int = 80) -> dict:
+ artist_key = _loose_text_key(album.get("artist") or "")
+ album_key = _loose_text_key(album.get("name") or album.get("album") or "")
+ if not artist_key or not album_key:
+ return {}
+ for row in downloaded_library_album_rows(limit):
+ row_artist_key = _loose_text_key(row.get("artist") or "")
+ row_album_key = _loose_text_key(row.get("album") or "")
+ if artist_key == row_artist_key and album_key == row_album_key:
+ return row
+ return {}
+
+
+def suppress_navidrome_album_notification(album_id: Any, source_row: dict) -> None:
+ album_id = str(album_id or "").strip()
+ if not album_id:
+ return
+ payload = {
+ "album": source_row,
+ "sent_at": now_utc().isoformat(),
+ "skipped_duplicate_kind": "liked_library_album",
+ }
+ for kind in ("main", "anime"):
+ key = f"{kind}:{album_id}"
+ if not already_sent("navidrome_album", key):
+ mark_sent("navidrome_album", key, payload)
+
+
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")
@@ -863,6 +930,7 @@ def poll_liked_library_albums(force: bool = False) -> dict:
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)
+ suppress_navidrome_album_notification(nav_album.get("id"), row)
mark_sent(
"liked_library_album",
key,
@@ -1319,20 +1387,213 @@ def navidrome_cover_url(cover_art: Any) -> str:
return f"/covers/navidrome/{cover_art}" if cover_art else ""
+def dashboard_album_item(album: dict, fallback_created: str = "") -> dict:
+ return {
+ "artist": album.get("artist") or album.get("displayArtist") 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 fallback_created,
+ "cover_url": navidrome_cover_url(album.get("coverArt")),
+ }
+
+
+def dashboard_music_folder_ids() -> list[int]:
+ cfg = CFG.get("navidrome") or {}
+ ids = []
+ for key in ("main_library_id", "anime_library_id"):
+ try:
+ value = int(cfg.get(key) or 0)
+ except Exception:
+ value = 0
+ if value and value not in ids:
+ ids.append(value)
+ return ids
+
+
+def dashboard_allowed_album_ids(album_ids: list[str]) -> set[str]:
+ ids = [str(album_id) for album_id in album_ids if album_id]
+ if not ids:
+ return set()
+ library_ids = dashboard_music_folder_ids()
+ db_path = Path((CFG.get("navidrome") or {}).get("db_path") or "/storage-1/qbit/navidrome/navidrome.db")
+ if not db_path.exists():
+ return set(ids)
+ try:
+ placeholders = ",".join("?" for _ in ids)
+ library_placeholders = ",".join("?" for _ in library_ids)
+ with sqlite3.connect(db_path) as db:
+ rows = db.execute(
+ f"SELECT id FROM album WHERE id IN ({placeholders}) AND library_id IN ({library_placeholders})",
+ [*ids, *library_ids],
+ ).fetchall()
+ return {row[0] for row in rows}
+ except Exception as e:
+ log(f"dashboard album library filter failed: {e}")
+ return set(ids)
+
+
+def navidrome_db_path() -> Path:
+ return Path((CFG.get("navidrome") or {}).get("db_path") or "/storage-1/qbit/navidrome/navidrome.db")
+
+
+def _album_year(row: sqlite3.Row) -> int | str:
+ for key in ("min_original_year", "max_year", "min_year"):
+ try:
+ value = int(row[key] or 0)
+ except Exception:
+ value = 0
+ if value:
+ return value
+ return ""
+
+
+def _album_release_date_from_row(row: sqlite3.Row) -> str:
+ for key in ("original_date", "release_date", "date"):
+ value = str(row[key] or "").strip()
+ if value:
+ return value
+ year = _album_year(row)
+ return str(year) if year else ""
+
+
+def _dashboard_album_from_db_row(row: sqlite3.Row) -> dict:
+ return {
+ "artist": row["album_artist"] or "Unknown artist",
+ "title": row["name"] or "Unknown album",
+ "year": _album_year(row),
+ "song_count": row["song_count"] or "",
+ "created": row["imported_at"] or row["created_at"] or "",
+ "cover_url": navidrome_cover_url(row["id"]),
+ }
+
+
+def dashboard_recent_album_rows(limit: int) -> list[sqlite3.Row]:
+ db_path = navidrome_db_path()
+ if not db_path.exists():
+ return []
+ library_ids = dashboard_music_folder_ids()
+ placeholders = ",".join("?" for _ in library_ids)
+ with sqlite3.connect(db_path) as db:
+ db.row_factory = sqlite3.Row
+ return db.execute(
+ f"""
+ SELECT id, name, album_artist, song_count, library_id, imported_at, created_at,
+ min_year, max_year, min_original_year, original_date, release_date, date
+ FROM album
+ WHERE library_id IN ({placeholders})
+ ORDER BY datetime(NULLIF(imported_at, '0000-00-00 00:00:00')) DESC,
+ datetime(created_at) DESC
+ LIMIT ?
+ """,
+ [*library_ids, limit],
+ ).fetchall()
+
+
+def dashboard_release_album_rows(limit: int) -> list[sqlite3.Row]:
+ db_path = navidrome_db_path()
+ if not db_path.exists():
+ return []
+ library_ids = dashboard_music_folder_ids()
+ placeholders = ",".join("?" for _ in library_ids)
+ with sqlite3.connect(db_path) as db:
+ db.row_factory = sqlite3.Row
+ return db.execute(
+ f"""
+ SELECT id, name, album_artist, song_count, library_id, imported_at, created_at,
+ min_year, max_year, min_original_year, original_date, release_date, date
+ FROM album
+ WHERE library_id IN ({placeholders})
+ AND COALESCE(NULLIF(original_date, ''), NULLIF(release_date, ''), NULLIF(date, ''),
+ CASE
+ WHEN min_original_year > 0 THEN printf('%04d', min_original_year)
+ WHEN max_year > 0 THEN printf('%04d', max_year)
+ WHEN min_year > 0 THEN printf('%04d', min_year)
+ ELSE ''
+ END) != ''
+ ORDER BY COALESCE(NULLIF(original_date, ''), NULLIF(release_date, ''), NULLIF(date, ''),
+ CASE
+ WHEN min_original_year > 0 THEN printf('%04d', min_original_year)
+ WHEN max_year > 0 THEN printf('%04d', max_year)
+ WHEN min_year > 0 THEN printf('%04d', min_year)
+ ELSE ''
+ END) DESC,
+ datetime(NULLIF(imported_at, '0000-00-00 00:00:00')) DESC
+ LIMIT ?
+ """,
+ [*library_ids, limit],
+ ).fetchall()
+
+
+def _date_parts(value: dict) -> tuple[int, int, int]:
+ try:
+ year = int(value.get("year") or 0)
+ month = int(value.get("month") or 1)
+ day = int(value.get("day") or 1)
+ return year, month, day
+ except Exception:
+ return 0, 1, 1
+
+
+def album_release_date(album: dict) -> str:
+ for key in ("originalReleaseDate", "releaseDate"):
+ parts = album.get(key) or {}
+ if not isinstance(parts, dict):
+ continue
+ year, month, day = _date_parts(parts)
+ if year:
+ if parts.get("month") and parts.get("day"):
+ return f"{year:04d}-{month:02d}-{day:02d}"
+ if parts.get("month"):
+ return f"{year:04d}-{month:02d}"
+ return f"{year:04d}"
+ if album.get("year"):
+ return str(album.get("year"))
+ return ""
+
+
+def album_release_sort_key(album: dict) -> tuple[int, int, int, str]:
+ for key in ("originalReleaseDate", "releaseDate"):
+ parts = album.get(key) or {}
+ if isinstance(parts, dict):
+ year, month, day = _date_parts(parts)
+ if year:
+ return year, month, day, str(album.get("created") or "")
+ try:
+ year = int(album.get("year") or 0)
+ except Exception:
+ year = 0
+ return year, 1, 1, str(album.get("created") or "")
+
+
def dashboard_albums(limit: int) -> list[dict]:
+ try:
+ rows = dashboard_recent_album_rows(limit)
+ if rows:
+ return [_dashboard_album_from_db_row(row) for row in rows]
+ except Exception as e:
+ log(f"dashboard recent albums db query failed: {e}")
+
+ albums_by_id: dict[str, dict] = {}
+ size = max(limit * 4, 40)
+ for folder_id in dashboard_music_folder_ids():
+ try:
+ for album in NAV.newest_albums(folder_id, size):
+ album_id = str(album.get("id") or "")
+ if album_id:
+ albums_by_id[album_id] = album
+ except Exception as e:
+ log(f"dashboard newest albums failed for folder {folder_id}: {e}")
+ if albums_by_id:
+ allowed_ids = dashboard_allowed_album_ids(list(albums_by_id.keys()))
+ albums = [album for album_id, album in albums_by_id.items() if album_id in allowed_ids]
+ albums = sorted(albums, key=lambda item: item.get("created") or "", reverse=True)
+ return [dashboard_album_item(album) for album in albums[:limit]]
+
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")),
- }
- )
+ items.append(dashboard_album_item(album, row["created_at"]))
return items
@@ -1397,6 +1658,33 @@ def release_cover_art(artist: str, album: str) -> str:
def playlist_next_generation(playlist: dict) -> datetime | None:
now = now_utc()
+ time_text = str(playlist.get("generation_time") or "").strip()
+ 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 = []
+
+ if time_text and days:
+ 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:
+ hour = minute = -1
+ if hour >= 0 and minute >= 0:
+ 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)
+ # discovery-playlist stores Python weekday indices: Monday=0, Sunday=6.
+ if candidate.weekday() not in days:
+ continue
+ if candidate > local_now:
+ return candidate.astimezone(timezone.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:
@@ -1407,42 +1695,43 @@ def playlist_next_generation(playlist: dict) -> datetime | None:
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 playlist_row in playlist_rows(limit):
+ playlist = dict(playlist_row)
+ name = playlist.get("name") or "Discovery"
+ name_key = name.casefold()
+ if name_key in seen_names:
+ continue
+ seen_names.add(name_key)
+ cover_art = ""
+ nav_pl = navidrome_find_playlist(name)
+ if nav_pl:
+ cover_art = playlist_cover_art(nav_pl)
+ if not cover_art:
+ cover_art = playlist_track_cover_art(playlist.get("id"))
+ generated = playlist.get("last_generated") or playlist.get("created_at") or ""
+ next_generation = playlist_next_generation(playlist)
+ tz_value = playlist.get("generation_timezone") or "UTC+03:00"
+ items.append(
+ {
+ "name": name,
+ "track_count": playlist.get("track_count") or playlist.get("songCount") or "",
+ "generated": generated,
+ "generated_label": relative_datetime_label(generated, tz_value),
+ "next_generation": next_generation.isoformat() if next_generation else "",
+ "next_generation_label": relative_datetime_label(next_generation, tz_value) 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:
+ return items
+
for row in sent_event_rows("discovery_playlist", limit * 3):
payload = row["payload"]
playlist = payload.get("playlist") or payload
@@ -1460,12 +1749,16 @@ def dashboard_playlists(limit: int) -> list[dict]:
if nav_pl:
cover_art = playlist_cover_art(nav_pl)
next_generation = playlist_next_generation(playlist)
+ tz_value = playlist.get("generation_timezone") or "UTC+03:00"
+ generated = playlist.get("last_generated") or playlist.get("created_at") or row["created_at"]
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"],
+ "generated": generated,
+ "generated_label": relative_datetime_label(generated, tz_value),
"next_generation": next_generation.isoformat() if next_generation else "",
+ "next_generation_label": relative_datetime_label(next_generation, tz_value) if next_generation else "",
"next_generation_in": human_delta(next_generation) if next_generation else "",
"cover_url": navidrome_cover_url(cover_art),
}
@@ -1476,6 +1769,51 @@ def dashboard_playlists(limit: int) -> list[dict]:
def dashboard_releases(limit: int) -> list[dict]:
+ try:
+ rows = dashboard_release_album_rows(limit)
+ if rows:
+ return [
+ {
+ "artist": row["album_artist"] or "Unknown artist",
+ "title": row["name"] or "Unknown release",
+ "release_date": _album_release_date_from_row(row),
+ "status": "in library",
+ "source": "navidrome",
+ "cover_url": navidrome_cover_url(row["id"]),
+ }
+ for row in rows
+ ]
+ except Exception as e:
+ log(f"dashboard releases db query failed: {e}")
+
+ albums_by_id: dict[str, dict] = {}
+ size = max(limit * 10, 120)
+ for folder_id in dashboard_music_folder_ids():
+ try:
+ for album in NAV.albums_by_year(size, folder_id):
+ album_id = str(album.get("id") or "")
+ if album_id:
+ albums_by_id[album_id] = album
+ except Exception as e:
+ log(f"dashboard library releases failed for folder {folder_id}: {e}")
+ if albums_by_id:
+ allowed_ids = dashboard_allowed_album_ids(list(albums_by_id.keys()))
+ albums = [album for album_id, album in albums_by_id.items() if album_id in allowed_ids]
+ albums = sorted(albums, key=album_release_sort_key, reverse=True)
+ items = []
+ for album in albums[:limit]:
+ items.append(
+ {
+ "artist": album.get("artist") or album.get("displayArtist") or "Unknown artist",
+ "title": album.get("name") or "Unknown release",
+ "release_date": album_release_date(album),
+ "status": "in library",
+ "source": "navidrome",
+ "cover_url": navidrome_cover_url(album.get("coverArt")),
+ }
+ )
+ return items
+
items = []
seen = set()
for row in sent_event_rows("release_out", limit * 2):
@@ -1523,6 +1861,19 @@ def dashboard():
return jsonify(payload)
+@app.get("/covers/navidrome/")
+def navidrome_cover_proxy(cover_art: str):
+ image = NAV.cover(cover_art)
+ if not image:
+ return Response(status=404)
+ data, _filename, content_type = image
+ return Response(
+ data,
+ mimetype=content_type,
+ headers={"Cache-Control": "public, max-age=86400"},
+ )
+
+
@app.post("/run/navidrome-albums")
def run_navidrome_albums():
payload = request.get_json(silent=True) or {}
diff --git a/config.example.json b/config.example.json
index e303321..d872501 100644
--- a/config.example.json
+++ b/config.example.json
@@ -10,7 +10,7 @@
"avatar_path": ""
},
"navidrome": {
- "url": "http://192.168.31.216:4533",
+ "url": "http://192.168.31.173:4533",
"username": "shu",
"password": "REPLACE_ME",
"main_library_id": 1,
@@ -41,4 +41,3 @@
"port": 18323
}
}
-