Tous les appareils partageaient le jeton du serveur : perdre un téléphone obligeait à changer ce jeton, donc à réappairer tout le monde. Chaque appairage émet désormais le sien. Révoquer devient une opération locale, vérifiée en appairant deux appareils puis en coupant le premier : 401 pour lui, 200 pour l'autre, jeton maître intact. L'interface du serveur les liste avec leur modèle, leur date d'appairage et leur dernière synchronisation — sans jamais afficher les jetons eux-mêmes, ce qui reviendrait à les distribuer. La table s'ajoute sans toucher aux données existantes et le jeton maître reste accepté : rien ne casse pour un appareil appairé avant cette version.
231 lines
7.9 KiB
Python
231 lines
7.9 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
|
|
);
|
|
"""
|
|
|
|
|
|
@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()
|