Le balayage ne servait qu'à trouver un morceau guetté, puis oubliait tout. Il note désormais chaque titre annoncé par les stations, même sans guet en cours : un onglet Écoutes montre l'historique, et la rotation — passages et stations distinctes, par titre et par artiste — sur les 100 dernières remontées. Pour que l'historique soit fiable, un tour va maintenant jusqu'au bout après une trouvaille (l'enregistrement part aussitôt, le début du morceau n'est pas perdu), et la liste des stations est mise en cache une demi-heure au lieu d'interroger l'annuaire à chaque tour. Essayé sur de vraies radios : une station alternait le vrai titre et un titre de repli, ce qui doublait les passages. Un titre déjà relevé sur la même station dans les dix minutes est donc ignoré. Première moitié de la reconnaissance des morceaux ; l'écoute continue et l'empreinte sonore suivront. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
296 lines
11 KiB
Python
296 lines
11 KiB
Python
# Noxely — collecte côté serveur, écoute hors connexion côté mobile.
|
|
# Copyright © 2026 Johnny (tuxgyver)
|
|
#
|
|
# Ce programme est un logiciel libre : vous pouvez le redistribuer et le modifier
|
|
# selon les termes de la GNU General Public License, version 3 ou ultérieure,
|
|
# telle que publiée par la Free Software Foundation.
|
|
#
|
|
# Il est distribué dans l'espoir d'être utile, mais SANS AUCUNE GARANTIE.
|
|
# Voir le fichier LICENSE à la racine du dépôt pour le texte complet.
|
|
|
|
"""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 devices (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
token TEXT NOT NULL UNIQUE,
|
|
created_at INTEGER NOT NULL,
|
|
last_seen INTEGER
|
|
);
|
|
CREATE INDEX IF NOT EXISTS devices_token ON devices(token);
|
|
|
|
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
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS heard (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
heard_at INTEGER NOT NULL,
|
|
station_id TEXT NOT NULL,
|
|
station TEXT NOT NULL,
|
|
raw TEXT NOT NULL,
|
|
artist TEXT NOT NULL DEFAULT '',
|
|
title TEXT NOT NULL,
|
|
method TEXT NOT NULL DEFAULT 'icy'
|
|
);
|
|
CREATE INDEX IF NOT EXISTS heard_station ON heard(station_id, id);
|
|
CREATE INDEX IF NOT EXISTS heard_at ON heard(heard_at);
|
|
"""
|
|
|
|
|
|
#: Délai avant qu'une même station puisse rejouer un même titre, pour l'historique.
|
|
REPEAT_WINDOW_MS = 10 * 60 * 1000
|
|
|
|
|
|
@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
|
|
|
|
# --- Appareils ------------------------------------------------------
|
|
|
|
def add_device(self, name: str, token: str) -> str:
|
|
device_id = uuid.uuid4().hex
|
|
self._db.execute(
|
|
"INSERT INTO devices (id,name,token,created_at) VALUES (?,?,?,?)",
|
|
(device_id, name.strip() or "Appareil", token, now()),
|
|
)
|
|
self._db.commit()
|
|
return device_id
|
|
|
|
def device_by_token(self, token: str) -> dict | None:
|
|
row = self._db.execute("SELECT * FROM devices WHERE token = ?", (token,)).fetchone()
|
|
return dict(row) if row else None
|
|
|
|
def touch_device(self, device_id: str) -> None:
|
|
"""Dernière utilisation : c'est ce qui permet de reconnaître un appareil oublié."""
|
|
self._db.execute("UPDATE devices SET last_seen = ? WHERE id = ?", (now(), device_id))
|
|
self._db.commit()
|
|
|
|
def devices(self) -> list[dict]:
|
|
return [
|
|
# Le jeton n'est jamais renvoyé : le lister reviendrait à le distribuer.
|
|
{k: v for k, v in dict(row).items() if k != "token"}
|
|
for row in self._db.execute("SELECT * FROM devices ORDER BY created_at DESC")
|
|
]
|
|
|
|
def delete_device(self, device_id: str) -> bool:
|
|
cursor = self._db.execute("DELETE FROM devices WHERE id = ?", (device_id,))
|
|
self._db.commit()
|
|
return cursor.rowcount > 0
|
|
|
|
# --- 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()
|
|
|
|
# --- Historique des écoutes ------------------------------------------
|
|
|
|
def add_heard(self, *, station_id: str, station: str, raw: str, artist: str, title: str,
|
|
method: str = "icy") -> bool:
|
|
"""Note un titre entendu, sauf si cette station vient déjà de l'annoncer.
|
|
|
|
Un balayage revient sur la même station toutes les quelques dizaines de secondes, pour
|
|
un morceau de plusieurs minutes : sans garde, chaque passage ajouterait une ligne et la
|
|
rotation compterait dix fois ce qui n'a été diffusé qu'une. Certaines stations alternent
|
|
en plus le vrai titre et un titre de repli ; on ignore donc tout titre déjà relevé sur la
|
|
même station dans les `REPEAT_WINDOW_MS` — une station ne rejoue pas un morceau si tôt.
|
|
"""
|
|
recent = self._db.execute(
|
|
"SELECT raw, heard_at FROM heard WHERE station_id = ? ORDER BY id DESC LIMIT 20",
|
|
(station_id,),
|
|
).fetchall()
|
|
if recent and recent[0]["raw"] == raw:
|
|
return False
|
|
horizon = now() - REPEAT_WINDOW_MS
|
|
if any(r["raw"] == raw and r["heard_at"] > horizon for r in recent):
|
|
return False
|
|
self._db.execute(
|
|
"INSERT INTO heard (heard_at,station_id,station,raw,artist,title,method) "
|
|
"VALUES (?,?,?,?,?,?,?)",
|
|
(now(), station_id, station, raw, artist, title, method),
|
|
)
|
|
self._db.commit()
|
|
return True
|
|
|
|
def heard(self, limit: int = 100, query: str | None = None) -> list[dict]:
|
|
"""Les dernières remontées, la plus récente d'abord."""
|
|
sql = "SELECT * FROM heard"
|
|
params: list = []
|
|
if query:
|
|
sql += " WHERE instr(lower(raw), ?) > 0"
|
|
params.append(query.lower())
|
|
sql += " ORDER BY id DESC LIMIT ?"
|
|
params.append(limit)
|
|
return [dict(r) for r in self._db.execute(sql, params)]
|
|
|
|
def heard_count(self) -> int:
|
|
return self._db.execute("SELECT COUNT(*) FROM heard").fetchone()[0]
|
|
|
|
def prune_heard(self, older_than_ms: int) -> int:
|
|
cursor = self._db.execute("DELETE FROM heard WHERE heard_at < ?", (older_than_ms,))
|
|
self._db.commit()
|
|
return cursor.rowcount
|