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.
976 lines
35 KiB
Python
976 lines
35 KiB
Python
"""Aoi-chan: Matrix notifications for Navidrome and discovery-playlist."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import html
|
||
import json
|
||
import mimetypes
|
||
import os
|
||
import re
|
||
import sqlite3
|
||
import threading
|
||
import time
|
||
import traceback
|
||
import uuid
|
||
from collections import Counter
|
||
from datetime import datetime, timezone, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import quote_plus
|
||
|
||
import requests
|
||
from flask import Flask, jsonify, request
|
||
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent
|
||
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()
|
||
|
||
|
||
def _load_config() -> dict:
|
||
example = BASE_DIR / "config.example.json"
|
||
path = CONFIG_PATH if CONFIG_PATH.exists() else example
|
||
with path.open("r", encoding="utf-8-sig") as f:
|
||
return json.load(f)
|
||
|
||
|
||
CFG = _load_config()
|
||
|
||
|
||
def log(message: str) -> None:
|
||
line = f"{datetime.now(timezone.utc).isoformat()} {message}"
|
||
print(line, flush=True)
|
||
try:
|
||
with LOG_PATH.open("a", encoding="utf-8") as f:
|
||
f.write(line + "\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def now_utc() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def parse_dt(value: Any) -> datetime | None:
|
||
if not value:
|
||
return None
|
||
if isinstance(value, datetime):
|
||
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
|
||
text = str(value).strip()
|
||
if not text:
|
||
return None
|
||
text = text.replace("Z", "+00:00")
|
||
for candidate in (text, text.split(".")[0]):
|
||
try:
|
||
dt = datetime.fromisoformat(candidate)
|
||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||
except Exception:
|
||
pass
|
||
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d", "%Y-%m", "%Y"):
|
||
try:
|
||
return datetime.strptime(text[: len(fmt)], fmt).replace(tzinfo=timezone.utc)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def parse_date(value: Any) -> datetime | None:
|
||
dt = parse_dt(value)
|
||
if dt:
|
||
return dt
|
||
text = str(value or "").strip()
|
||
for fmt in ("%Y", "%Y-%m", "%Y-%m-%d"):
|
||
try:
|
||
return datetime.strptime(text, fmt).replace(tzinfo=timezone.utc)
|
||
except Exception:
|
||
pass
|
||
return None
|
||
|
||
|
||
def strip_html(text: str) -> str:
|
||
text = re.sub(r"<br\s*/?>", "\n", text or "", flags=re.I)
|
||
text = re.sub(r"<[^>]+>", "", text)
|
||
return html.unescape(text).strip()
|
||
|
||
|
||
def compact(text: str, limit: int = 700) -> str:
|
||
text = re.sub(r"\s+", " ", strip_html(text)).strip()
|
||
if len(text) <= limit:
|
||
return text
|
||
return text[: limit - 1].rstrip() + "…"
|
||
|
||
|
||
def clean_lastfm_text(text: str) -> str:
|
||
text = re.sub(r"Read more on Last\.fm.*$", "", text or "", flags=re.I | re.S)
|
||
text = re.sub(r"User-contributed text.*$", "", text, flags=re.I | re.S)
|
||
return re.sub(r"\s+", " ", strip_html(text)).strip()
|
||
|
||
|
||
def init_state() -> None:
|
||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||
conn.execute("PRAGMA journal_mode=WAL")
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS sent_events (
|
||
kind TEXT NOT NULL,
|
||
event_key TEXT NOT NULL,
|
||
payload_json TEXT,
|
||
created_at TEXT NOT NULL,
|
||
PRIMARY KEY(kind, event_key)
|
||
)
|
||
"""
|
||
)
|
||
conn.execute(
|
||
"""
|
||
CREATE TABLE IF NOT EXISTS cursors (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT,
|
||
updated_at TEXT NOT NULL
|
||
)
|
||
"""
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def already_sent(kind: str, key: str) -> bool:
|
||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
row = conn.execute(
|
||
"SELECT 1 FROM sent_events WHERE kind=? AND event_key=?",
|
||
(kind, key),
|
||
).fetchone()
|
||
return bool(row)
|
||
|
||
|
||
def mark_sent(kind: str, key: str, payload: dict | None = None) -> None:
|
||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.execute(
|
||
"""
|
||
INSERT OR IGNORE INTO sent_events(kind,event_key,payload_json,created_at)
|
||
VALUES(?,?,?,?)
|
||
""",
|
||
(kind, key, json.dumps(payload or {}, ensure_ascii=False), now_utc().isoformat()),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def get_cursor(key: str) -> str:
|
||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
row = conn.execute("SELECT value FROM cursors WHERE key=?", (key,)).fetchone()
|
||
return row[0] if row else ""
|
||
|
||
|
||
def set_cursor(key: str, value: str) -> None:
|
||
with STATE_LOCK, sqlite3.connect(STATE_DB, timeout=30) as conn:
|
||
conn.execute("PRAGMA busy_timeout=30000")
|
||
conn.execute(
|
||
"""
|
||
INSERT INTO cursors(key,value,updated_at) VALUES(?,?,?)
|
||
ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at
|
||
""",
|
||
(key, value, now_utc().isoformat()),
|
||
)
|
||
conn.commit()
|
||
|
||
|
||
def request_session() -> requests.Session:
|
||
sess = requests.Session()
|
||
proxy = (CFG.get("metadata") or {}).get("proxy") or ""
|
||
if proxy:
|
||
sess.proxies.update({"http": proxy, "https": proxy})
|
||
return sess
|
||
|
||
|
||
HTTP = request_session()
|
||
LASTFM_API_KEY_CACHE: str | None = None
|
||
|
||
|
||
class MatrixClient:
|
||
def __init__(self, cfg: dict):
|
||
self.homeserver = cfg["homeserver"].rstrip("/")
|
||
self.room_id = cfg["room_id"]
|
||
self.token = cfg["access_token"]
|
||
self.user_id = cfg.get("user_id") or ""
|
||
|
||
@property
|
||
def headers(self) -> dict:
|
||
return {"Authorization": f"Bearer {self.token}"}
|
||
|
||
def set_profile(self) -> None:
|
||
bot = CFG.get("bot") or {}
|
||
name = bot.get("name")
|
||
if name and self.user_id:
|
||
url = f"{self.homeserver}/_matrix/client/v3/profile/{self.user_id}/displayname"
|
||
r = requests.put(url, headers=self.headers, json={"displayname": name}, timeout=20)
|
||
if not r.ok:
|
||
log(f"matrix display name update failed: {r.status_code} {r.text[:200]}")
|
||
|
||
avatar_path = bot.get("avatar_path") or ""
|
||
if avatar_path:
|
||
path = Path(avatar_path)
|
||
if path.exists() and self.user_id:
|
||
mxc = self.upload_file(path.read_bytes(), path.name, mimetypes.guess_type(path.name)[0] or "image/jpeg")
|
||
if mxc:
|
||
url = f"{self.homeserver}/_matrix/client/v3/profile/{self.user_id}/avatar_url"
|
||
r = requests.put(url, headers=self.headers, json={"avatar_url": mxc}, timeout=20)
|
||
if not r.ok:
|
||
log(f"matrix avatar update failed: {r.status_code} {r.text[:200]}")
|
||
|
||
def upload_file(self, data: bytes, filename: str, content_type: str) -> str:
|
||
r = requests.post(
|
||
f"{self.homeserver}/_matrix/media/v3/upload",
|
||
headers={**self.headers, "Content-Type": content_type},
|
||
params={"filename": filename},
|
||
data=data,
|
||
timeout=45,
|
||
)
|
||
r.raise_for_status()
|
||
return r.json()["content_uri"]
|
||
|
||
def send_text(self, text: str, formatted_html: str | None = None) -> dict:
|
||
body = {"msgtype": "m.text", "body": text}
|
||
if formatted_html:
|
||
body["format"] = "org.matrix.custom.html"
|
||
body["formatted_body"] = formatted_html
|
||
txn = uuid.uuid4().hex
|
||
url = f"{self.homeserver}/_matrix/client/v3/rooms/{self.room_id}/send/m.room.message/{txn}"
|
||
r = requests.put(url, headers=self.headers, json=body, timeout=30)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
|
||
def send_image_bytes(self, data: bytes, filename: str, content_type: str = "image/jpeg") -> dict:
|
||
mxc = self.upload_file(data, filename, content_type)
|
||
body = {
|
||
"msgtype": "m.image",
|
||
"body": filename,
|
||
"url": mxc,
|
||
"info": {"mimetype": content_type, "size": len(data)},
|
||
}
|
||
txn = uuid.uuid4().hex
|
||
url = f"{self.homeserver}/_matrix/client/v3/rooms/{self.room_id}/send/m.room.message/{txn}"
|
||
r = requests.put(url, headers=self.headers, json=body, timeout=30)
|
||
r.raise_for_status()
|
||
return r.json()
|
||
|
||
def send_card(self, text: str, formatted_html: str, image: tuple[bytes, str, str] | None = None) -> None:
|
||
if image:
|
||
try:
|
||
self.send_image_bytes(*image)
|
||
except Exception as e:
|
||
log(f"matrix image send failed: {e}")
|
||
self.send_text(text, formatted_html)
|
||
|
||
|
||
class NavidromeClient:
|
||
def __init__(self, cfg: dict):
|
||
self.base_url = cfg["url"].rstrip("/")
|
||
self.username = cfg["username"]
|
||
self.password = cfg["password"]
|
||
|
||
def _params(self) -> dict:
|
||
return {
|
||
"u": self.username,
|
||
"p": self.password,
|
||
"v": "1.16.1",
|
||
"c": "AoiNotifier",
|
||
"f": "json",
|
||
}
|
||
|
||
def get(self, endpoint: str, **params) -> dict:
|
||
r = requests.get(
|
||
f"{self.base_url}/rest/{endpoint}",
|
||
params={**self._params(), **params},
|
||
timeout=35,
|
||
)
|
||
r.raise_for_status()
|
||
data = r.json().get("subsonic-response", {})
|
||
if data.get("status") != "ok":
|
||
raise RuntimeError(f"Navidrome {endpoint} failed: {data}")
|
||
return data
|
||
|
||
def ping(self) -> bool:
|
||
try:
|
||
return self.get("ping").get("status") == "ok"
|
||
except Exception:
|
||
return False
|
||
|
||
def newest_albums(self, music_folder_id: int, size: int) -> list[dict]:
|
||
data = self.get("getAlbumList2", type="newest", size=size, musicFolderId=music_folder_id)
|
||
return data.get("albumList2", {}).get("album", []) or []
|
||
|
||
def album(self, album_id: str) -> dict:
|
||
return self.get("getAlbum", id=album_id).get("album", {}) or {}
|
||
|
||
def playlists(self) -> list[dict]:
|
||
data = self.get("getPlaylists")
|
||
return data.get("playlists", {}).get("playlist", []) or []
|
||
|
||
def playlist(self, playlist_id: str) -> dict:
|
||
return self.get("getPlaylist", id=playlist_id).get("playlist", {}) or {}
|
||
|
||
def cover(self, cover_art: str | None) -> tuple[bytes, str, str] | None:
|
||
if not cover_art:
|
||
return None
|
||
try:
|
||
r = requests.get(
|
||
f"{self.base_url}/rest/getCoverArt",
|
||
params={**self._params(), "id": cover_art, "size": 900},
|
||
timeout=35,
|
||
)
|
||
r.raise_for_status()
|
||
ctype = r.headers.get("Content-Type") or "image/jpeg"
|
||
ext = mimetypes.guess_extension(ctype.split(";")[0]) or ".jpg"
|
||
return (r.content, f"cover{ext}", ctype)
|
||
except Exception as e:
|
||
log(f"cover fetch failed: {e}")
|
||
return None
|
||
|
||
|
||
MATRIX = MatrixClient(CFG["matrix"])
|
||
NAV = NavidromeClient(CFG["navidrome"])
|
||
|
||
|
||
def metadata_headers() -> dict:
|
||
ua = (CFG.get("metadata") or {}).get("user_agent") or "AoiNotifier/1.0"
|
||
return {"User-Agent": ua, "Accept": "application/json"}
|
||
|
||
|
||
def discovery_setting(key: str) -> str:
|
||
db_path = (CFG.get("discovery") or {}).get("db_path") or ""
|
||
if not db_path or not Path(db_path).exists():
|
||
return ""
|
||
try:
|
||
with sqlite3.connect(db_path) as conn:
|
||
row = conn.execute("SELECT value FROM user_settings WHERE key=?", (key,)).fetchone()
|
||
return str(row[0] or "") if row else ""
|
||
except Exception as e:
|
||
log(f"discovery setting lookup failed for {key}: {e}")
|
||
return ""
|
||
|
||
|
||
def lastfm_api_key() -> str:
|
||
global LASTFM_API_KEY_CACHE
|
||
if LASTFM_API_KEY_CACHE is not None:
|
||
return LASTFM_API_KEY_CACHE
|
||
LASTFM_API_KEY_CACHE = (CFG.get("metadata") or {}).get("lastfm_api_key") or discovery_setting("lastfm_api_key")
|
||
return LASTFM_API_KEY_CACHE
|
||
|
||
|
||
def lastfm_request(method: str, params: dict, timeout: int = 25) -> dict:
|
||
api_key = lastfm_api_key()
|
||
if not api_key:
|
||
return {}
|
||
req_params = {
|
||
"method": method,
|
||
"api_key": api_key,
|
||
"format": "json",
|
||
"autocorrect": 1,
|
||
**params,
|
||
}
|
||
try:
|
||
r = HTTP.get("https://ws.audioscrobbler.com/2.0/", params=req_params, timeout=timeout)
|
||
if not r.ok:
|
||
return {}
|
||
return r.json()
|
||
except Exception as e:
|
||
log(f"lastfm {method} failed: {e}")
|
||
return {}
|
||
|
||
|
||
def lastfm_album_tags(artist: str, album: str, limit: int = 6) -> list[str]:
|
||
data = lastfm_request("album.getTopTags", {"artist": artist, "album": album}, timeout=20)
|
||
tags = []
|
||
for tag in ((data.get("toptags") or {}).get("tag") or [])[:limit]:
|
||
name = (tag.get("name") or "").strip()
|
||
if name and len(name) <= 32 and not name.isdigit():
|
||
tags.append(name)
|
||
return tags
|
||
|
||
|
||
def lastfm_artist_summary(artist: str) -> str:
|
||
for params in ({"artist": artist, "lang": "ru"}, {"artist": artist}):
|
||
data = lastfm_request("artist.getinfo", params, timeout=20)
|
||
bio = ((data.get("artist") or {}).get("bio") or {})
|
||
summary = clean_lastfm_text(bio.get("summary") or bio.get("content") or "")
|
||
if summary:
|
||
return summary
|
||
return ""
|
||
|
||
|
||
def lastfm_album_search_url(artist: str, album: str) -> str:
|
||
return f"https://www.last.fm/search/albums?q={quote_plus(f'{artist} {album}')}"
|
||
|
||
|
||
def bandcamp_album_search_url(artist: str, album: str) -> str:
|
||
return f"https://bandcamp.com/search?q={quote_plus(f'{artist} {album}')}&item_type=a"
|
||
|
||
|
||
def album_source_links(artist: str, album: str, lastfm_url: str = "") -> list[dict[str, str]]:
|
||
return [
|
||
{"label": "Last.fm", "url": lastfm_url or lastfm_album_search_url(artist, album)},
|
||
{"label": "Bandcamp", "url": bandcamp_album_search_url(artist, album)},
|
||
]
|
||
|
||
|
||
def lastfm_album_info(artist: str, album: str) -> dict[str, str]:
|
||
for params in ({"artist": artist, "album": album, "lang": "ru"}, {"artist": artist, "album": album}):
|
||
data = lastfm_request("album.getinfo", params)
|
||
album_data = data.get("album") or {}
|
||
wiki = album_data.get("wiki") or {}
|
||
summary = clean_lastfm_text(wiki.get("summary") or wiki.get("content") or "")
|
||
if summary:
|
||
return {"summary": summary, "url": album_data.get("url") or ""}
|
||
|
||
tags = lastfm_album_tags(artist, album)
|
||
artist_summary = lastfm_artist_summary(artist)
|
||
parts = []
|
||
if tags:
|
||
parts.append("Last.fm описывает релиз тегами: " + ", ".join(tags) + ".")
|
||
if artist_summary:
|
||
parts.append(artist_summary)
|
||
if parts:
|
||
return {"summary": " ".join(parts), "url": ""}
|
||
return {"summary": "", "url": ""}
|
||
|
||
|
||
def lastfm_album_summary(artist: str, album: str) -> str:
|
||
return lastfm_album_info(artist, album).get("summary", "")
|
||
|
||
def musicbrainz_release_hint(artist: str, album: str) -> dict:
|
||
if not (CFG.get("metadata") or {}).get("musicbrainz_enabled", True):
|
||
return {}
|
||
try:
|
||
query = f'artist:"{artist}" AND release:"{album}"'
|
||
r = HTTP.get(
|
||
"https://musicbrainz.org/ws/2/release/",
|
||
params={"query": query, "fmt": "json", "limit": 3},
|
||
headers=metadata_headers(),
|
||
timeout=25,
|
||
)
|
||
if not r.ok:
|
||
return {}
|
||
releases = r.json().get("releases") or []
|
||
if not releases:
|
||
return {}
|
||
rel = releases[0]
|
||
return {
|
||
"date": rel.get("date") or "",
|
||
"country": rel.get("country") or "",
|
||
"status": rel.get("status") or "",
|
||
"score": rel.get("score") or 0,
|
||
}
|
||
except Exception as e:
|
||
log(f"musicbrainz lookup failed: {e}")
|
||
return {}
|
||
|
||
|
||
def lastfm_tags_for_tracks(tracks: list[dict]) -> Counter:
|
||
api_key = lastfm_api_key()
|
||
tags = Counter()
|
||
if not api_key:
|
||
return tags
|
||
for tr in tracks[:8]:
|
||
try:
|
||
r = HTTP.get(
|
||
"https://ws.audioscrobbler.com/2.0/",
|
||
params={
|
||
"method": "track.getTopTags",
|
||
"artist": tr.get("artist") or "",
|
||
"track": tr.get("track") or tr.get("title") or "",
|
||
"api_key": api_key,
|
||
"format": "json",
|
||
"autocorrect": 1,
|
||
},
|
||
timeout=20,
|
||
)
|
||
if not r.ok:
|
||
continue
|
||
for tag in ((r.json().get("toptags") or {}).get("tag") or [])[:5]:
|
||
name = (tag.get("name") or "").lower().strip()
|
||
if name and len(name) <= 32:
|
||
tags[name] += 1
|
||
except Exception:
|
||
continue
|
||
return tags
|
||
|
||
|
||
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"
|
||
year = album.get("year") or ""
|
||
duration = int(album.get("duration") or sum(int(s.get("duration") or 0) for s in songs) or 0)
|
||
minutes = duration // 60 if duration else 0
|
||
lastfm_info = lastfm_album_info(artist, title)
|
||
summary = lastfm_info.get("summary") or ""
|
||
links = album_source_links(artist, title, lastfm_info.get("url") or "")
|
||
hint = musicbrainz_release_hint(artist, title)
|
||
release_date = album.get("created") or album.get("starred") or hint.get("date") or ""
|
||
track_lines = []
|
||
for idx, song in enumerate(songs[:30], 1):
|
||
name = song.get("title") or "Untitled"
|
||
dur = int(song.get("duration") or 0)
|
||
suffix = f" ({dur // 60}:{dur % 60:02d})" if dur else ""
|
||
track_lines.append(f"{idx:02d}. {name}{suffix}")
|
||
if len(songs) > 30:
|
||
track_lines.append(f"... и еще {len(songs) - 30}")
|
||
|
||
header = f"🎧 Новый релиз в Navidrome / {library_label}"
|
||
facts = [
|
||
f"{artist} — {title}" + (f" ({year})" if year else ""),
|
||
f"Треков: {len(songs)}" + (f", длительность: {minutes} мин." if minutes else ""),
|
||
]
|
||
if release_date:
|
||
facts.append(f"Дата/добавление: {release_date}")
|
||
if hint.get("country") or hint.get("status"):
|
||
facts.append("MusicBrainz: " + ", ".join(x for x in [hint.get("country"), hint.get("status")] if x))
|
||
if summary:
|
||
description_fact = f"Описание: {summary}"
|
||
else:
|
||
description_fact = "Описание: пока не нашла нормальное описание, оставляю чистый треклист."
|
||
facts.append(description_fact)
|
||
facts.append("Ссылки: " + ", ".join(f"{link['label']}: {link['url']}" for link in links))
|
||
plain = "\n".join([header, "", *facts, "", "━━ ТРЕКЛИСТ ━━", *track_lines])
|
||
|
||
html_parts = [
|
||
f"<h3>{html.escape(header)}</h3>",
|
||
f"<b>{html.escape(artist)} — {html.escape(title)}</b>" + (f" ({html.escape(str(year))})" if year else ""),
|
||
"<ul>",
|
||
f"<li>Треков: {len(songs)}" + (f", длительность: {minutes} мин." if minutes else "") + "</li>",
|
||
]
|
||
if release_date:
|
||
html_parts.append(f"<li>Дата/добавление: {html.escape(str(release_date))}</li>")
|
||
if hint.get("country") or hint.get("status"):
|
||
html_parts.append(f"<li>MusicBrainz: {html.escape(', '.join(x for x in [hint.get('country'), hint.get('status')] if x))}</li>")
|
||
html_parts.append("</ul>")
|
||
html_parts.append(f"<p>{html.escape(description_fact)}</p>")
|
||
html_parts.append(
|
||
"<p>Ссылки: "
|
||
+ ", ".join(
|
||
f'<a href="{html.escape(link["url"], quote=True)}">{html.escape(link["label"])}</a>'
|
||
for link in links
|
||
)
|
||
+ "</p>"
|
||
)
|
||
html_parts.append("<p><b>Треклист</b></p><ol>")
|
||
for line in track_lines:
|
||
html_parts.append(f"<li>{html.escape(line[4:] if re.match(r'^\\d\\d\\. ', line) else line)}</li>")
|
||
html_parts.append("</ol>")
|
||
return plain, "\n".join(html_parts)
|
||
|
||
|
||
def poll_navidrome_albums(force: bool = False) -> dict:
|
||
cfg = CFG["navidrome"]
|
||
libraries = [
|
||
("main", int(cfg.get("main_library_id") or 0), "main"),
|
||
("anime", int(cfg.get("anime_library_id") or 0), "anime"),
|
||
]
|
||
size = int(cfg.get("album_poll_size") or 40)
|
||
baseline = (CFG.get("polling") or {}).get("baseline_existing_on_first_run", True)
|
||
first_run = not get_cursor("navidrome_albums_baselined")
|
||
sent = 0
|
||
seen = 0
|
||
for kind, folder_id, label in libraries:
|
||
if not folder_id:
|
||
continue
|
||
for album in NAV.newest_albums(folder_id, size):
|
||
album_id = str(album.get("id") or "")
|
||
if not album_id:
|
||
continue
|
||
key = f"{kind}:{album_id}"
|
||
if already_sent("navidrome_album", key):
|
||
continue
|
||
seen += 1
|
||
if first_run and baseline and not force:
|
||
mark_sent("navidrome_album", key, album)
|
||
continue
|
||
full_album = NAV.album(album_id)
|
||
songs = full_album.get("song") or []
|
||
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)
|
||
mark_sent("navidrome_album", key, {"album": album, "sent_at": now_utc().isoformat()})
|
||
sent += 1
|
||
time.sleep(1)
|
||
set_cursor("navidrome_albums_baselined", now_utc().isoformat())
|
||
return {"ok": True, "seen_new": seen, "sent": sent, "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():
|
||
log(f"discovery db not found: {db_path}")
|
||
return None
|
||
conn = sqlite3.connect(db_path)
|
||
conn.row_factory = sqlite3.Row
|
||
return conn
|
||
|
||
|
||
def playlist_rows(limit: int = 20) -> list[sqlite3.Row]:
|
||
conn = discovery_conn()
|
||
if not conn:
|
||
return []
|
||
try:
|
||
return conn.execute(
|
||
"""
|
||
SELECT * FROM playlists
|
||
ORDER BY COALESCE(last_generated, created_at) DESC
|
||
LIMIT ?
|
||
""",
|
||
(limit,),
|
||
).fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def playlist_tracks(playlist_id: int) -> list[dict]:
|
||
conn = discovery_conn()
|
||
if not conn:
|
||
return []
|
||
try:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM playlist_tracks
|
||
WHERE playlist_id=?
|
||
ORDER BY score DESC, added_at DESC
|
||
""",
|
||
(playlist_id,),
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
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:
|
||
needle = (name or "").strip().lower()
|
||
if not needle:
|
||
return None
|
||
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 []
|
||
if entries:
|
||
return NAV.cover(entries[0].get("coverArt"))
|
||
except Exception as e:
|
||
log(f"navidrome playlist cover failed: {e}")
|
||
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": "свежие рекомендации",
|
||
"comfort_zone": "комфортная зона",
|
||
}
|
||
|
||
|
||
def playlist_plain_and_html(pl: dict, tracks: list[dict]) -> tuple[str, str]:
|
||
name = pl.get("name") or "Discovery"
|
||
created = pl.get("last_generated") or pl.get("created_at") or ""
|
||
top_tracks = tracks[:8]
|
||
top_lines = [f"• {t.get('artist')} — {t.get('track')}" for t in top_tracks]
|
||
profile_counts = Counter((t.get("profile") or "").strip() for t in tracks if t.get("profile"))
|
||
profile_text = ", ".join(
|
||
f"{PROFILE_LABELS.get(k, k)}: {v}" for k, v in profile_counts.most_common()
|
||
) or "смешанный режим"
|
||
tags = lastfm_tags_for_tracks(tracks)
|
||
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))
|
||
summary = (
|
||
f"В этот раз уклон: {tag_text}. "
|
||
f"Из заметного: {', '.join(line[2:] for line in top_lines[:4])}."
|
||
)
|
||
plain = "\n".join([
|
||
"🧭 Новый Discovery-плейлист",
|
||
"",
|
||
name,
|
||
f"Треков: {len(tracks)}",
|
||
f"Собран: {created}",
|
||
f"Профиль: {profile_text}",
|
||
f"Главные артисты: {artist_text or 'пока не выделяются'}",
|
||
"",
|
||
summary,
|
||
"",
|
||
"━━ ИНТЕРЕСНОЕ ━━",
|
||
*top_lines,
|
||
])
|
||
formatted = "\n".join([
|
||
"<h3>🧭 Новый Discovery-плейлист</h3>",
|
||
f"<b>{html.escape(name)}</b>",
|
||
"<ul>",
|
||
f"<li>Треков: {len(tracks)}</li>",
|
||
f"<li>Собран: {html.escape(str(created))}</li>",
|
||
f"<li>Профиль: {html.escape(profile_text)}</li>",
|
||
f"<li>Главные артисты: {html.escape(artist_text or 'пока не выделяются')}</li>",
|
||
"</ul>",
|
||
f"<p>{html.escape(summary)}</p>",
|
||
"<p><b>Интересное</b></p><ul>",
|
||
*[f"<li>{html.escape(line[2:])}</li>" for line in top_lines],
|
||
"</ul>",
|
||
])
|
||
return plain, formatted
|
||
|
||
|
||
def poll_discovery_playlists(force: bool = False) -> dict:
|
||
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 ""
|
||
if not stamp:
|
||
continue
|
||
key = f"{pl.get('id')}:{stamp}:{pl.get('iteration_number', 0)}"
|
||
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.
|
||
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_for(nav_pl)
|
||
MATRIX.send_card(plain, formatted, image)
|
||
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,
|
||
"waiting": waiting,
|
||
"first_run": first_run,
|
||
}
|
||
|
||
|
||
def release_rows(limit: int = 120) -> list[dict]:
|
||
conn = discovery_conn()
|
||
if not conn:
|
||
return []
|
||
try:
|
||
rows = conn.execute(
|
||
"""
|
||
SELECT * FROM artist_release_watch
|
||
ORDER BY COALESCE(downloaded_at, first_seen_at) DESC
|
||
LIMIT ?
|
||
""",
|
||
(limit,),
|
||
).fetchall()
|
||
return [dict(r) for r in rows]
|
||
finally:
|
||
conn.close()
|
||
|
||
|
||
def release_plain_and_html(row: dict, mode: str) -> tuple[str, str]:
|
||
artist = row.get("artist") or "Unknown artist"
|
||
album = row.get("album") or "Unknown release"
|
||
date = row.get("release_date") or "дата уточняется"
|
||
source = row.get("source") or "discovery-playlist"
|
||
tracks = int(row.get("desired_track_count") or 0)
|
||
status = row.get("status") or ""
|
||
if mode == "out":
|
||
title = "📀 Релиз вышел"
|
||
lead = f"{artist} — {album} уже можно забирать."
|
||
else:
|
||
title = "📡 Анонс релиза отслеживаемого артиста"
|
||
lead = f"{artist} — {album} появился в release watch."
|
||
facts = [
|
||
lead,
|
||
f"Дата релиза: {date}",
|
||
f"Статус: {status}",
|
||
f"Источник: {source}",
|
||
]
|
||
if tracks:
|
||
facts.append(f"Ожидаемый треклист: {tracks} треков")
|
||
if row.get("error_message"):
|
||
facts.append(f"Заметка: {compact(row.get('error_message'), 240)}")
|
||
plain = "\n".join([title, "", *facts])
|
||
formatted = "\n".join([
|
||
f"<h3>{html.escape(title)}</h3>",
|
||
f"<p><b>{html.escape(artist)} — {html.escape(album)}</b></p>",
|
||
"<ul>",
|
||
*[f"<li>{html.escape(f)}</li>" for f in facts[1:]],
|
||
"</ul>",
|
||
])
|
||
return plain, formatted
|
||
|
||
|
||
def release_is_out(row: dict) -> bool:
|
||
dt = parse_date(row.get("release_date"))
|
||
return bool(dt and dt.date() == now_utc().date())
|
||
|
||
|
||
def release_is_announcement(row: dict) -> bool:
|
||
dt = parse_date(row.get("release_date"))
|
||
return bool(dt and dt.date() > now_utc().date())
|
||
|
||
|
||
def poll_release_watch(force: bool = False) -> dict:
|
||
baseline = (CFG.get("polling") or {}).get("baseline_existing_on_first_run", True)
|
||
first_run = not get_cursor("release_watch_baselined")
|
||
announced = 0
|
||
out = 0
|
||
skipped = 0
|
||
for row in release_rows():
|
||
row_id = row.get("id")
|
||
first_seen = row.get("first_seen_at") or ""
|
||
announce_key = f"{row_id}:{first_seen}"
|
||
if release_is_announcement(row) and not already_sent("release_announcement", announce_key):
|
||
if first_run and baseline and not force:
|
||
mark_sent("release_announcement", announce_key, row)
|
||
else:
|
||
plain, formatted = release_plain_and_html(row, "announce")
|
||
MATRIX.send_card(plain, formatted, None)
|
||
mark_sent("release_announcement", announce_key, row)
|
||
announced += 1
|
||
time.sleep(1)
|
||
elif not release_is_announcement(row):
|
||
skipped += 1
|
||
|
||
if release_is_out(row):
|
||
out_stamp = row.get("downloaded_at") or row.get("release_date") or row.get("last_checked_at") or ""
|
||
out_key = f"{row_id}:{out_stamp}:{row.get('status')}"
|
||
if already_sent("release_out", out_key):
|
||
continue
|
||
if first_run and baseline and not force:
|
||
mark_sent("release_out", out_key, row)
|
||
else:
|
||
plain, formatted = release_plain_and_html(row, "out")
|
||
MATRIX.send_card(plain, formatted, None)
|
||
mark_sent("release_out", out_key, row)
|
||
out += 1
|
||
time.sleep(1)
|
||
set_cursor("release_watch_baselined", now_utc().isoformat())
|
||
return {"ok": True, "announced": announced, "out": out, "skipped_not_current_or_future": skipped, "first_run": first_run}
|
||
|
||
|
||
def loop_forever(name: str, interval: int, fn) -> None:
|
||
while True:
|
||
try:
|
||
result = fn()
|
||
log(f"{name}: {result}")
|
||
except Exception as e:
|
||
log(f"{name} failed: {e}\n{traceback.format_exc()}")
|
||
time.sleep(interval)
|
||
|
||
|
||
app = Flask(__name__)
|
||
|
||
|
||
@app.get("/healthz")
|
||
def healthz():
|
||
return jsonify({"ok": True})
|
||
|
||
|
||
@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/discovery-playlists")
|
||
def run_discovery_playlists():
|
||
payload = request.get_json(silent=True) or {}
|
||
return jsonify(poll_discovery_playlists(force=bool(payload.get("force"))))
|
||
|
||
|
||
@app.post("/run/release-watch")
|
||
def run_release_watch():
|
||
payload = request.get_json(silent=True) or {}
|
||
return jsonify(poll_release_watch(force=bool(payload.get("force"))))
|
||
|
||
|
||
def start_background() -> None:
|
||
polling = CFG.get("polling") or {}
|
||
tasks = [
|
||
("navidrome_albums", int(polling.get("navidrome_album_interval_seconds") or 300), poll_navidrome_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),
|
||
]
|
||
for name, interval, fn in tasks:
|
||
threading.Thread(target=loop_forever, args=(name, interval, fn), daemon=True).start()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
init_state()
|
||
try:
|
||
MATRIX.set_profile()
|
||
except Exception as e:
|
||
log(f"matrix profile setup failed: {e}")
|
||
if not NAV.ping():
|
||
log("warning: Navidrome ping failed")
|
||
start_background()
|
||
server = CFG.get("server") or {}
|
||
host = server.get("host") or "0.0.0.0"
|
||
port = int(server.get("port") or 18323)
|
||
try:
|
||
from waitress import serve
|
||
|
||
serve(app, host=host, port=port)
|
||
except Exception:
|
||
app.run(host=host, port=port)
|