Files
Noxely/server/noxely_server/storage.py
T
tuxgyver ed1d40826b Noxely se sépare en client et serveur (2.0.0)
Le téléphone balayait quarante radios toutes les trois minutes, cherchait dans
quatre catalogues et enregistrait des directs. Tout cela le tenait éveillé pour
un travail qui n'a aucune raison de se faire sur batterie.

Un serveur reprend la collecte : recherche, guet, enregistrement, file de
téléchargement, piloté depuis un navigateur. Le mobile demande ce qui a changé
depuis son curseur, télécharge et se rendort — toutes les six heures, en Wi-Fi
non facturé. Vingt fichiers quittent l'application au passage.

Le balayage devient continu plutôt qu'espacé de trois minutes : une sonde ne lit
que le premier bloc de métadonnées avant de refermer, ce qui rend la permanence
tenable quand garder les flux ouverts coûterait plusieurs mégabits par seconde.
Un morceau guetté a été capté en moins de vingt-cinq secondes. La recherche
interroge les quatre catalogues à la fois et fond leurs résultats, les plus
légers d'abord.

Quatre défauts corrigés en chemin, tous trouvés en essayant plutôt qu'en
relisant : un jeton régénéré à chaque démarrage, qui aurait obligé à
reconfigurer le mobile ; un 403 de Wikimedia dû à un agent trop générique, que
la fusion des sources masquait ; un dossier de données par défaut inaccessible
au compte qui lance le serveur ; et une lecture de métadonnées qui n'itérait
rien, faute d'être asynchrone.
2026-09-13 17:39:43 +02:00

180 lines
5.8 KiB
Python

"""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()