"""Index des médias et des guets, en SQLite. Les fichiers vivent sur le disque ; la base ne porte que leurs métadonnées et l'état de la synchronisation. Un client ne demande que ce qui a changé depuis son dernier passage, d'où le champ `updated_at` sur chaque ligne. """ from __future__ import annotations import sqlite3 import time import uuid from dataclasses import asdict, dataclass from pathlib import Path SCHEMA = """ CREATE TABLE IF NOT EXISTS media ( id TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT NOT NULL DEFAULT '', source TEXT NOT NULL, origin TEXT NOT NULL DEFAULT '', filename TEXT NOT NULL, mime TEXT NOT NULL DEFAULT 'audio/mpeg', size INTEGER NOT NULL DEFAULT 0, duration_ms INTEGER NOT NULL DEFAULT 0, artwork_url TEXT, state TEXT NOT NULL DEFAULT 'pending', error TEXT, remote_url TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS media_updated ON media(updated_at); CREATE TABLE IF NOT EXISTS watches ( id TEXT PRIMARY KEY, query TEXT NOT NULL, created_at INTEGER NOT NULL, scans INTEGER NOT NULL DEFAULT 0, last_scan INTEGER, found_at INTEGER, found_on TEXT, found_title TEXT ); """ @dataclass class Media: id: str title: str artist: str source: str origin: str filename: str mime: str size: int duration_ms: int artwork_url: str | None state: str error: str | None remote_url: str | None created_at: int updated_at: int def public(self) -> dict: """Vue destinée au client : ni chemin local, ni URL d'origine.""" data = asdict(self) data.pop("remote_url") data.pop("filename") return data def now() -> int: return int(time.time() * 1000) class Storage: def __init__(self, db_path: Path) -> None: db_path.parent.mkdir(parents=True, exist_ok=True) self._db = sqlite3.connect(db_path, check_same_thread=False) self._db.row_factory = sqlite3.Row self._db.executescript(SCHEMA) self._db.commit() # --- Médias --------------------------------------------------------- def add_media(self, *, title: str, artist: str, source: str, origin: str, filename: str, remote_url: str | None, mime: str = "audio/mpeg", duration_ms: int = 0, artwork_url: str | None = None, state: str = "pending") -> str: media_id = uuid.uuid4().hex stamp = now() self._db.execute( "INSERT INTO media (id,title,artist,source,origin,filename,mime,size,duration_ms," "artwork_url,state,error,remote_url,created_at,updated_at) " "VALUES (?,?,?,?,?,?,?,0,?,?,?,NULL,?,?,?)", (media_id, title, artist, source, origin, filename, mime, duration_ms, artwork_url, state, remote_url, stamp, stamp), ) self._db.commit() return media_id def update_media(self, media_id: str, **fields) -> None: if not fields: return fields["updated_at"] = now() assignments = ", ".join(f"{key} = ?" for key in fields) self._db.execute( f"UPDATE media SET {assignments} WHERE id = ?", (*fields.values(), media_id) ) self._db.commit() def media(self, media_id: str) -> Media | None: row = self._db.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone() return Media(**dict(row)) if row else None def media_since(self, since: int = 0, state: str | None = "ready") -> list[Media]: query = "SELECT * FROM media WHERE updated_at > ?" params: list = [since] if state: query += " AND state = ?" params.append(state) query += " ORDER BY updated_at ASC" return [Media(**dict(r)) for r in self._db.execute(query, params)] def pending_downloads(self) -> list[Media]: return [ Media(**dict(r)) for r in self._db.execute( "SELECT * FROM media WHERE state IN ('pending','failed') AND remote_url IS NOT NULL " "ORDER BY created_at ASC LIMIT 20" ) ] def delete_media(self, media_id: str) -> Media | None: item = self.media(media_id) if item: self._db.execute("DELETE FROM media WHERE id = ?", (media_id,)) self._db.commit() return item # --- Guets ---------------------------------------------------------- def add_watch(self, query: str) -> str: watch_id = uuid.uuid4().hex self._db.execute( "INSERT INTO watches (id,query,created_at) VALUES (?,?,?)", (watch_id, query.strip(), now()), ) self._db.commit() return watch_id def watches(self) -> list[dict]: return [dict(r) for r in self._db.execute("SELECT * FROM watches ORDER BY created_at DESC")] def pending_watches(self) -> list[dict]: return [dict(r) for r in self._db.execute("SELECT * FROM watches WHERE found_at IS NULL")] def mark_watch_found(self, watch_id: str, station: str, title: str) -> None: self._db.execute( "UPDATE watches SET found_at=?, found_on=?, found_title=? WHERE id=?", (now(), station, title, watch_id), ) self._db.commit() def mark_watches_scanned(self, ids: list[str]) -> None: if not ids: return stamp = now() self._db.executemany( "UPDATE watches SET scans = scans + 1, last_scan = ? WHERE id = ?", [(stamp, i) for i in ids], ) self._db.commit() def delete_watch(self, watch_id: str) -> None: self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,)) self._db.commit()