Initial commit: Aoi (Аой-тян) — Matrix notifier for Navidrome and discovery-playlist
- aoi.py: poller for Navidrome (main + anime libraries), discovery playlists, release watch for liked/rated artists - Last.fm enrichment (bio, tags); Bandcamp search links - config.example.json: safe template; config.json gitignored - deploy/aoi.service: systemd unit (production) - assets/banner.png + aoi-avatar.png: persona banner + bot avatar - Russian README in line with sibling bots (rada/watcher)
This commit is contained in:
commit
5fa857b9cc
8 changed files with 1155 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
config.json
|
||||
*.db
|
||||
*.db-*
|
||||
*.log
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
*.tmp
|
||||
|
||||
|
||||
*.db.bak*
|
||||
160
README.md
Normal file
160
README.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
<p align="center">
|
||||
<img src="assets/banner.png" alt="Aoi banner" width="40%">
|
||||
</p>
|
||||
|
||||
# Aoi
|
||||
|
||||
**Aoi** (Аой-тян) — домашний Matrix-бот для музыкальной библиотеки. Она следит за Navidrome и discovery-playlist, аккуратно докладывает о новых альбомах, свежих Discovery-плейлистах и предстоящих релизах любимых артистов, обогащая карточки данными Last.fm и ссылками на внешние источники.
|
||||
|
||||
## Что умеет
|
||||
|
||||
| Блок | Что происходит |
|
||||
| --- | --- |
|
||||
| Navidrome albums | Поллинг библиотек `main` и `anime` — новые альбомы превращаются в карточки с обложкой, описанием, тегами и рейтингом. |
|
||||
| Discovery playlists | Свежесгенерированные плейлисты discovery-playlist (имя содержит «Discovery») приходят в Matrix отдельным постом. |
|
||||
| Release watch | Аналитика по любимым/высоко оцененным артистам: анонсы и фактические релизы из дискографии, отслеживаемой discovery-playlist. |
|
||||
| Rich-описания | Last.fm-биография, теги, обложка из `album.getInfo`, ссылки на Last.fm и поиск Bandcamp. |
|
||||
| Baseline | На первом запуске можно зафиксировать всё уже существующее как «уже видела», чтобы не получить спам с историей. |
|
||||
| State & dedupe | SQLite хранит увиденные альбомы, плейлисты и релизы — повторов не будет. |
|
||||
|
||||
## Как устроена логика
|
||||
|
||||
Aoi не делает запросов к LLM и не пытается «угадывать» — она опирается на конкретные источники и кеширует увиденное:
|
||||
|
||||
1. По расписанию опрашивает Navidrome и discovery-playlist (раздельные интервалы для альбомов, плейлистов и релизов).
|
||||
2. Сравнивает с локальным state и забирает только новые сущности.
|
||||
3. Обогащает карточки Last.fm-данными при наличии ключа.
|
||||
4. Шлёт карточку в Matrix-комнату с обложкой, описанием и ссылками.
|
||||
5. Помечает сущность как обработанную в SQLite.
|
||||
|
||||
Каждый источник работает независимо, так что падение Navidrome не валит discovery, и наоборот.
|
||||
|
||||
## Сообщения в Matrix
|
||||
|
||||
Карточка нового альбома выглядит примерно так:
|
||||
|
||||
```text
|
||||
🎧 Новый альбом в Navidrome
|
||||
Artist — Album Title (2026)
|
||||
|
||||
━━ ОПИСАНИЕ ━━
|
||||
Краткая биография/описание альбома из Last.fm…
|
||||
|
||||
━━ ТЕГИ ━━
|
||||
shoegaze · indie · dream-pop
|
||||
|
||||
🔗 Last.fm: https://www.last.fm/music/Artist/Album
|
||||
🔗 Bandcamp: https://bandcamp.com/search?q=Artist+Album
|
||||
```
|
||||
|
||||
Discovery-плейлисты приходят отдельной карточкой со списком треков, release watch — отдельной с пометкой типа релиза (`announced`, `released`).
|
||||
|
||||
## Файлы проекта
|
||||
|
||||
| Файл | Назначение |
|
||||
| --- | --- |
|
||||
| `aoi.py` | Основной сервис. |
|
||||
| `config.example.json` | Безопасный пример конфигурации. |
|
||||
| `config.json` | Локальная конфигурация с секретами, не коммитится. |
|
||||
| `requirements.txt` | Python-зависимости. |
|
||||
| `deploy/aoi.service` | systemd unit для production. |
|
||||
| `assets/banner.png` | Баннер README. |
|
||||
| `aoi.db` | SQLite-состояние, не коммитится. |
|
||||
| `aoi.log` | Лог сервиса, не коммитится. |
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Создать конфиг из примера:
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
Заполнить:
|
||||
|
||||
- `matrix.homeserver`, `matrix.room_id`, `matrix.access_token`, `matrix.user_id`;
|
||||
- `bot.name` (по умолчанию `Аой-тян`) и `bot.avatar_path` для Matrix-профиля;
|
||||
- `navidrome.url`, `navidrome.username`, `navidrome.password`, идентификаторы библиотек;
|
||||
- `discovery.db_path` — путь к SQLite базе discovery-playlist;
|
||||
- `metadata.lastfm_api_key` — ключ Last.fm для био и тегов;
|
||||
- `polling.*` — интервалы опроса источников;
|
||||
- `polling.baseline_existing_on_first_run` — `true` на первом запуске, чтобы не спамить историей.
|
||||
|
||||
Production-путь:
|
||||
|
||||
```text
|
||||
/storage/scripts/aoi
|
||||
```
|
||||
|
||||
## Установка
|
||||
|
||||
В production Aoi использует виртуальное окружение Watcher (общие зависимости):
|
||||
|
||||
```bash
|
||||
cd /storage/scripts/watcher
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r /storage/scripts/aoi/requirements.txt
|
||||
```
|
||||
|
||||
Можно завести отдельный venv — тогда обновить `deploy/aoi.service`.
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
/storage/scripts/watcher/.venv/bin/python /storage/scripts/aoi/aoi.py
|
||||
```
|
||||
|
||||
Healthcheck:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:18323/healthz
|
||||
```
|
||||
|
||||
Ожидаемый ответ:
|
||||
|
||||
```json
|
||||
{"ok":true}
|
||||
```
|
||||
|
||||
## systemd
|
||||
|
||||
Unit хранится в репозитории:
|
||||
|
||||
```text
|
||||
deploy/aoi.service
|
||||
```
|
||||
|
||||
Установка или обновление:
|
||||
|
||||
```bash
|
||||
sudo cp /storage/scripts/aoi/deploy/aoi.service /etc/systemd/system/aoi.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable aoi.service
|
||||
sudo systemctl restart aoi.service
|
||||
```
|
||||
|
||||
Операции:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart aoi.service
|
||||
systemctl --no-pager -l status aoi.service
|
||||
journalctl -u aoi.service -f
|
||||
tail -f /storage/scripts/aoi/aoi.log
|
||||
```
|
||||
|
||||
## Ручные команды
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:18323/run/navidrome-albums
|
||||
curl -X POST http://127.0.0.1:18323/run/discovery-playlists
|
||||
curl -X POST http://127.0.0.1:18323/run/release-watch
|
||||
```
|
||||
|
||||
## Что не коммитить
|
||||
|
||||
- `config.json`
|
||||
- `*.db`
|
||||
- `*.log`
|
||||
- `.venv/`
|
||||
- `__pycache__/`
|
||||
BIN
aoi-avatar.png
Normal file
BIN
aoi-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
921
aoi.py
Normal file
921
aoi.py
Normal file
|
|
@ -0,0 +1,921 @@
|
|||
"""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_playlist_cover(name: str) -> tuple[bytes, str, str] | None:
|
||||
try:
|
||||
needle = name.strip().lower()
|
||||
candidates = NAV.playlists()
|
||||
chosen = None
|
||||
for pl in candidates:
|
||||
pl_name = (pl.get("name") or "").strip().lower()
|
||||
if pl_name == needle or needle in pl_name or pl_name in needle:
|
||||
chosen = pl
|
||||
break
|
||||
if not chosen:
|
||||
return None
|
||||
full = NAV.playlist(str(chosen.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
|
||||
|
||||
|
||||
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:
|
||||
baseline = (CFG.get("polling") or {}).get("baseline_existing_on_first_run", True)
|
||||
first_run = not get_cursor("discovery_playlists_baselined")
|
||||
sent = 0
|
||||
seen = 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
|
||||
tracks = playlist_tracks(int(pl["id"]))
|
||||
plain, formatted = playlist_plain_and_html(pl, tracks)
|
||||
image = navidrome_playlist_cover(pl.get("name") or "")
|
||||
MATRIX.send_card(plain, formatted, image)
|
||||
mark_sent("discovery_playlist", key, {"playlist": pl, "sent_at": now_utc().isoformat()})
|
||||
sent += 1
|
||||
time.sleep(1)
|
||||
set_cursor("discovery_playlists_baselined", now_utc().isoformat())
|
||||
return {"ok": True, "seen_new": seen, "sent": sent, "first_run": first_run}
|
||||
|
||||
|
||||
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)
|
||||
BIN
assets/banner.png
Normal file
BIN
assets/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 529 KiB |
42
config.example.json
Normal file
42
config.example.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"matrix": {
|
||||
"homeserver": "https://tuwu.vespahomelab.ru",
|
||||
"access_token": "REPLACE_ME",
|
||||
"room_id": "!roomid:tuwu.vespahomelab.ru",
|
||||
"user_id": "@aoi:tuwu.vespahomelab.ru"
|
||||
},
|
||||
"bot": {
|
||||
"name": "Аой-тян",
|
||||
"avatar_path": ""
|
||||
},
|
||||
"navidrome": {
|
||||
"url": "http://192.168.31.216:4533",
|
||||
"username": "shu",
|
||||
"password": "REPLACE_ME",
|
||||
"main_library_id": 1,
|
||||
"anime_library_id": 3,
|
||||
"discovery_library_id": 4,
|
||||
"album_poll_size": 40
|
||||
},
|
||||
"discovery": {
|
||||
"db_path": "/data/discovery.db",
|
||||
"playlist_name_contains": "Discovery"
|
||||
},
|
||||
"metadata": {
|
||||
"lastfm_api_key": "",
|
||||
"musicbrainz_enabled": true,
|
||||
"proxy": "",
|
||||
"user_agent": "AoiNotifier/1.0 (https://tuwu.vespahomelab.ru)"
|
||||
},
|
||||
"polling": {
|
||||
"navidrome_album_interval_seconds": 300,
|
||||
"discovery_playlist_interval_seconds": 180,
|
||||
"release_watch_interval_seconds": 600,
|
||||
"baseline_existing_on_first_run": true
|
||||
},
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
"port": 18323
|
||||
}
|
||||
}
|
||||
|
||||
17
deploy/aoi.service
Normal file
17
deploy/aoi.service
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
[Unit]
|
||||
Description=Aoi Navidrome notifier
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/storage/scripts/aoi
|
||||
ExecStart=/storage/scripts/watcher/.venv/bin/python /storage/scripts/aoi/aoi.py
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
TimeoutStopSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
flask==3.0.3
|
||||
requests==2.32.3
|
||||
waitress==3.0.2
|
||||
Loading…
Add table
Add a link
Reference in a new issue