Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
53f5fc1148 | ||
|
|
28cb545685 |
No files matched your search
@@ -5,6 +5,58 @@ Toutes les évolutions notables de Noxely sont documentées ici.
|
||||
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/)
|
||||
et le versionnage [SemVer](https://semver.org/lang/fr/).
|
||||
|
||||
## [2.3.0] — 2026-09-13
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Page « À propos » du serveur.** Un onglet `ℹ️ À propos` avec en-tête d'identité et cinq
|
||||
sections : ce que le serveur fait, ce qu'il garantit, avec quoi il est construit, ce qui a
|
||||
changé, et qui en répond. Le contenu vit dans un module de constantes séparé du rendu.
|
||||
- Le journal est **lu** et non recopié : une route `/api/changelog` sert le `CHANGELOG.md` du
|
||||
dépôt, que la page interprète pour ce qu'il emploie réellement — titres, puces et gras.
|
||||
|
||||
### Modifié
|
||||
|
||||
- **Le champ « jeton » quitte l'en-tête du serveur**, où il n'avait rien à faire, pour l'onglet
|
||||
`⚙️ Réglages`. Une carte y donne aussi le nom du serveur, sa version et le nombre d'appareils
|
||||
appairés. Sans jeton connu, la pastille d'état indique où le saisir.
|
||||
- **Le serveur annonce la version du dépôt.** Il lisait une constante figée à `1.0.0`, restée en
|
||||
arrière de six publications ; il lit maintenant le fichier `VERSION`, comme le client.
|
||||
|
||||
### Corrigé
|
||||
|
||||
- `saveToken()` relançait l'amorçage du jeton au lieu de rafraîchir la page : un bloc y avait été
|
||||
déplacé par erreur lors d'une édition antérieure.
|
||||
- Une garantie annoncée à tort. La page affirmait que toute route hors `/api/health` exigeait un
|
||||
jeton ; la page elle-même, ses ressources et le journal répondent sans. Les sept garanties ont
|
||||
été confrontées au code une à une, celle-ci reformulée.
|
||||
|
||||
## [2.2.0] — 2026-09-13
|
||||
|
||||
### Ajouté
|
||||
|
||||
- **Un jeton par appareil.** Chaque appairage émet désormais sa propre valeur, au lieu de
|
||||
distribuer le jeton maître. Révoquer un téléphone perdu n'atteint plus les autres : vérifié en
|
||||
appairant deux appareils, en révoquant le premier, et en constatant que le second continue de
|
||||
se synchroniser (401 pour l'un, 200 pour l'autre, jeton maître intact).
|
||||
- **Onglet « Appareils »** dans l'interface du serveur : nom du modèle, date d'appairage,
|
||||
dernière synchronisation — ou « jamais synchronisé » —, et révocation d'un clic.
|
||||
- Le téléphone s'annonce par son modèle lors de l'appairage : un appareil qu'on ne sait pas
|
||||
nommer ne se révoque pas.
|
||||
|
||||
### Sécurité
|
||||
|
||||
- Les jetons d'appareil ne sont **jamais renvoyés** par la route qui les liste : les afficher
|
||||
reviendrait à les distribuer.
|
||||
- La date de dernière utilisation est enregistrée à chaque appel authentifié, ce qui permet de
|
||||
repérer un appareil oublié.
|
||||
|
||||
### Compatibilité
|
||||
|
||||
- La table s'ajoute sans toucher aux données existantes, et le jeton maître reste accepté : un
|
||||
appareil appairé avant cette version garde l'accès. Le réappairer lui donnera un jeton propre,
|
||||
et donc une révocation individuelle.
|
||||
|
||||
## [2.1.0] — 2026-09-13
|
||||
|
||||
### Ajouté
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
[](https://kotlinlang.org)
|
||||
[](https://developer.android.com/jetpack/compose)
|
||||
[](https://developer.android.com/media/media3)
|
||||
[](VERSION)
|
||||
[](VERSION)
|
||||
[](LICENSE)
|
||||
[](#-comment-ça-marche)
|
||||
|
||||
@@ -60,6 +60,7 @@ synchronisations : c'est là qu'est l'économie.
|
||||
|---|---|
|
||||
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante |
|
||||
| 📱 **Appairage** | Un bouton côté serveur, un bouton côté téléphone : aucun jeton à recopier |
|
||||
| 🔑 **Un jeton par appareil** | Chacun le sien, révocable seul depuis l'onglet Appareils |
|
||||
| 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop |
|
||||
| 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil |
|
||||
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
package fr.tuxgyver.noxely.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import fr.tuxgyver.noxely.library.ImportedStore
|
||||
import fr.tuxgyver.noxely.model.MediaEntry
|
||||
import fr.tuxgyver.noxely.model.MediaKind
|
||||
@@ -69,7 +70,10 @@ class SyncClient(
|
||||
val connection = openConnection("$base/api/pair", readTimeoutMs = 20_000).apply {
|
||||
requestMethod = "POST"
|
||||
doOutput = true
|
||||
outputStream.close()
|
||||
setRequestProperty("Content-Type", "application/json")
|
||||
// Le nom du modèle rend l'appareil reconnaissable dans la liste du serveur.
|
||||
val name = (Build.MANUFACTURER + " " + Build.MODEL).trim()
|
||||
outputStream.use { it.write(("{" + "\"name\":\"" + name + "\"}").toByteArray()) }
|
||||
}
|
||||
val body = try {
|
||||
when (connection.responseCode) {
|
||||
|
||||
@@ -18,19 +18,23 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import secrets
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Request
|
||||
from fastapi.responses import FileResponse, HTMLResponse
|
||||
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
|
||||
|
||||
from . import config as config_module
|
||||
from .sources import catalogs, radio
|
||||
from .storage import Storage
|
||||
from .workers import Downloader, Watcher, safe_name
|
||||
|
||||
VERSION = "1.0.0"
|
||||
# Client et serveur sortent du même dépôt, à la même version : on lit le fichier
|
||||
# que la chaîne de publication incrémente, plutôt qu'un numéro à tenir à jour ici.
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
VERSION = (REPO_ROOT / "VERSION").read_text().strip() if (REPO_ROOT / "VERSION").exists() else "0.0.0"
|
||||
PAIRING_WINDOW_SECONDS = 120
|
||||
WEB_DIR = Path(__file__).parent / "web"
|
||||
|
||||
@@ -41,9 +45,18 @@ watcher = Watcher(config, storage)
|
||||
|
||||
|
||||
def authorised(x_noxely_token: str = Header(default="")) -> None:
|
||||
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers."""
|
||||
if x_noxely_token != config.token:
|
||||
"""Sans jeton valide, le serveur ne répond rien : il expose des fichiers.
|
||||
|
||||
Deux formes valent : le jeton maître, qui pilote le serveur depuis le navigateur, et celui
|
||||
d'un appareil appairé. Les distinguer est ce qui permet de révoquer un téléphone perdu sans
|
||||
déranger les autres.
|
||||
"""
|
||||
if x_noxely_token and x_noxely_token == config.token:
|
||||
return
|
||||
device = storage.device_by_token(x_noxely_token) if x_noxely_token else None
|
||||
if not device:
|
||||
raise HTTPException(status_code=401, detail="Jeton invalide")
|
||||
storage.touch_device(device["id"])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -221,8 +234,19 @@ def pairing_state() -> dict:
|
||||
return {"open": remaining > 0, "seconds": max(0, remaining)}
|
||||
|
||||
|
||||
@app.get("/api/devices", dependencies=[Depends(authorised)])
|
||||
def devices() -> dict:
|
||||
return {"devices": storage.devices()}
|
||||
|
||||
|
||||
@app.delete("/api/devices/{device_id}", dependencies=[Depends(authorised)])
|
||||
def revoke_device(device_id: str) -> dict:
|
||||
"""Révoque un appareil : son jeton cesse immédiatement d'être accepté."""
|
||||
return {"revoked": storage.delete_device(device_id)}
|
||||
|
||||
|
||||
@app.post("/api/pair")
|
||||
def pair(request: Request) -> dict:
|
||||
def pair(request: Request, payload: dict = Body(default={})) -> dict:
|
||||
"""Livre le jeton à un appareil du réseau local, pendant la fenêtre d'appairage.
|
||||
|
||||
La fenêtre se referme dès qu'un appareil s'est servi : une ouverture, un appairage.
|
||||
@@ -237,7 +261,11 @@ def pair(request: Request) -> dict:
|
||||
if not _is_private(client):
|
||||
raise HTTPException(status_code=403, detail="Appairage réservé au réseau local")
|
||||
_pairing_until = None
|
||||
return {"token": config.token, "name": "Noxely", "version": VERSION}
|
||||
# Chaque appairage reçoit son propre jeton : révoquer l'un n'atteint pas les autres.
|
||||
token = secrets.token_urlsafe(24)
|
||||
name = str(payload.get("name") or "Appareil").strip()[:60]
|
||||
storage.add_device(name, token)
|
||||
return {"token": token, "name": "Noxely", "version": VERSION, "device": name}
|
||||
|
||||
|
||||
@app.get("/api/bootstrap")
|
||||
@@ -253,6 +281,21 @@ def bootstrap(request: Request) -> dict:
|
||||
return {"token": config.token}
|
||||
|
||||
|
||||
@app.get("/api/changelog", response_class=PlainTextResponse)
|
||||
def changelog() -> str:
|
||||
"""Le journal du dépôt, lu à la demande plutôt que recopié dans la page."""
|
||||
path = REPO_ROOT / "CHANGELOG.md"
|
||||
return path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
|
||||
|
||||
@app.get("/about.js", response_class=PlainTextResponse)
|
||||
def about_script() -> Response:
|
||||
return Response(
|
||||
(WEB_DIR / "about.js").read_text(encoding="utf-8"),
|
||||
media_type="application/javascript; charset=utf-8",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return (WEB_DIR / "index.html").read_text(encoding="utf-8")
|
||||
@@ -42,6 +42,15 @@ CREATE TABLE IF NOT EXISTS media (
|
||||
);
|
||||
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,
|
||||
@@ -150,6 +159,38 @@ class Storage:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// 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.
|
||||
|
||||
// Contenu de la page « À propos », séparé de son rendu.
|
||||
// Tout ce qui est écrit ici se vérifie dans le dépôt : une fonction décrite existe, une garantie
|
||||
// annoncée est tenue par du code. La version vient de /api/health, jamais recopiée.
|
||||
const ABOUT = {
|
||||
name: 'Noxely — serveur',
|
||||
tagline: 'Il cherche, il guette, il enregistre. Le téléphone n\'a plus qu\'à écouter.',
|
||||
repository: 'https://git.tuxtech.fr/tuxgyver/Noxely',
|
||||
author: 'Johnny',
|
||||
authorRole: 'Conception, direction et supervision',
|
||||
authorship:
|
||||
'Application développée avec l\'assistance de l\'IA, sous la direction et la supervision de Johnny.',
|
||||
license: 'GPL v3',
|
||||
copyright: 'Copyright © 2026 Johnny (tuxgyver)',
|
||||
|
||||
// Ce que fait le serveur, du point de vue de qui s'en sert.
|
||||
features: [
|
||||
['Chercher partout à la fois',
|
||||
'Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et Wikimedia ' +
|
||||
'Commons sont interrogés en parallèle, les résultats fondus et les plus légers mis en tête.'],
|
||||
['Choisir avant de télécharger',
|
||||
'Un album ou un livre affiche ses pistes avec leur taille : on met en file ce qu\'on veut, ' +
|
||||
'pas l\'ensemble.'],
|
||||
['Guetter un morceau sur les radios',
|
||||
'On nomme un titre sans désigner de station : les plus écoutées sont balayées en continu, ' +
|
||||
'et l\'enregistrement part dès que l\'une le diffuse.'],
|
||||
['Enregistrer un direct',
|
||||
'La capture prend le nom du morceau annoncé par la station, et s\'arrête quand il cède la ' +
|
||||
'place au suivant.'],
|
||||
['Tenir la file',
|
||||
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
|
||||
['Servir le mobile',
|
||||
'L\'application demande ce qui a changé depuis son dernier passage et récupère des fichiers ' +
|
||||
'déjà prêts — c\'est ce qui lui épargne sa batterie.'],
|
||||
],
|
||||
|
||||
guaranteesIntro:
|
||||
'Noxely est un outil personnel : rien n\'y transite par un service tiers, et le serveur ne ' +
|
||||
'répond qu\'à ce qui présente un jeton valide.',
|
||||
|
||||
// Chaque promesse est tenue par du code.
|
||||
guarantees: [
|
||||
'Aucun compte, aucune clé d\'API : les quatre catalogues et l\'annuaire radio sont publics.',
|
||||
'Toute route de données exige un jeton — celui du serveur, ou celui d\'un appareil appairé ; ' +
|
||||
'seuls l\'état de santé, la page et ses ressources répondent sans.',
|
||||
'Chaque appareil reçoit son propre jeton : en révoquer un ne coupe pas les autres.',
|
||||
'Les jetons d\'appareil ne sont jamais renvoyés par la route qui les liste.',
|
||||
'L\'appairage n\'est possible que pendant une fenêtre ouverte depuis cette interface, deux ' +
|
||||
'minutes durant, une seule fois, et depuis le réseau local uniquement.',
|
||||
'Les catalogues ne proposent que des œuvres libres ou du domaine public ; les éléments en ' +
|
||||
'accès restreint sont écartés avant même d\'être affichés.',
|
||||
'Une synchronisation partielle ne fait pas avancer le curseur du client : rien n\'est perdu.',
|
||||
],
|
||||
|
||||
// Couche → outils.
|
||||
stack: [
|
||||
['Service', 'Python, FastAPI, uvicorn, unité systemd'],
|
||||
['Données', 'SQLite pour l\'index, fichiers sur disque pour les médias'],
|
||||
['Collecte', 'httpx asynchrone, flux ICY pour les titres de radio'],
|
||||
['Catalogues', 'Internet Archive, netlabels, LibriVox, Wikimedia Commons'],
|
||||
['Interface', 'HTML, CSS et JavaScript sans dépendance'],
|
||||
['Synchronisation', 'HTTP avec jeton, curseur incrémental côté client'],
|
||||
],
|
||||
};
|
||||
@@ -48,6 +48,25 @@
|
||||
.pill.ok { background:rgba(17,168,148,.16); color:var(--mint); }
|
||||
.pill.warn { background:rgba(224,98,155,.16); color:#e0629b; }
|
||||
.empty { color:var(--muted); text-align:center; padding:40px 10px; }
|
||||
/* Page « À propos » : en-tête d'identité figé, corps en sous-onglets. */
|
||||
.about-head { display:flex; gap:16px; align-items:center; margin-bottom:16px; }
|
||||
.about-logo { width:80px; height:80px; border-radius:22px; background:var(--accent);
|
||||
display:grid; place-items:center; font-size:40px; flex:none; }
|
||||
.about-head h2 { margin:0 0 2px; font-size:20px; }
|
||||
.about-head p { margin:0 0 8px; color:var(--muted); font-size:14px; }
|
||||
.pill.accent { background:var(--accent); color:#fff; }
|
||||
.subtabs { display:flex; gap:6px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.subtabs button { background:none; border:1px solid var(--line); border-radius:999px;
|
||||
padding:5px 12px; color:var(--muted); cursor:pointer; font-size:13px; }
|
||||
.subtabs button[aria-selected="true"] { border-color:var(--accent); color:var(--accent); }
|
||||
.about-body { max-height:none; }
|
||||
.about-body .card { display:block; }
|
||||
.about-body ul { margin:0; padding-left:20px; }
|
||||
.about-body li { margin:5px 0; }
|
||||
.tech { width:100%; border-collapse:collapse; }
|
||||
.tech td { padding:9px 4px; border-bottom:1px solid var(--line); vertical-align:top; }
|
||||
.tech td:first-child { color:var(--muted); width:38%; }
|
||||
.log h4 { margin:0 0 4px; font-size:15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -55,8 +74,6 @@
|
||||
<h1>🎧 Noxely — serveur</h1>
|
||||
<span id="status" class="pill">…</span>
|
||||
<span class="grow" style="flex:1"></span>
|
||||
<input type="password" id="token" placeholder="Jeton" style="width:190px">
|
||||
<button class="ghost" onclick="saveToken()">Enregistrer</button>
|
||||
<button class="go" onclick="openPairing()">📱 Appairer</button>
|
||||
</header>
|
||||
<main>
|
||||
@@ -65,6 +82,9 @@
|
||||
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
|
||||
<button onclick="show('queue')" aria-selected="false" id="t-queue">⏳ File</button>
|
||||
<button onclick="show('library')" aria-selected="false" id="t-library">📚 Bibliothèque</button>
|
||||
<button onclick="show('devices')" aria-selected="false" id="t-devices">📱 Appareils</button>
|
||||
<button onclick="show('settings')" aria-selected="false" id="t-settings">⚙️ Réglages</button>
|
||||
<button onclick="show('about')" aria-selected="false" id="t-about">ℹ️ À propos</button>
|
||||
</div>
|
||||
|
||||
<section id="p-search">
|
||||
@@ -88,8 +108,39 @@
|
||||
|
||||
<section id="p-queue" hidden><div id="queue"></div></section>
|
||||
<section id="p-library" hidden><div id="library"></div></section>
|
||||
<section id="p-settings" hidden>
|
||||
<div class="card" style="display:block">
|
||||
<b>Jeton d'accès</b>
|
||||
<small>Rempli tout seul quand cette page est ouverte depuis la machine du serveur.
|
||||
Depuis un autre poste, collez celui que donne <code>make server-token</code>.</small>
|
||||
<div class="row" style="margin-top:12px">
|
||||
<input type="password" id="token" placeholder="Jeton" style="flex:1">
|
||||
<button class="go" onclick="saveToken()">Enregistrer</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="serverInfo"></div>
|
||||
</section>
|
||||
|
||||
<section id="p-about" hidden>
|
||||
<div class="about-head">
|
||||
<div class="about-logo">🎧</div>
|
||||
<div>
|
||||
<h2 id="aboutName">Noxely</h2>
|
||||
<p id="aboutTagline"></p>
|
||||
<div class="row" style="margin:0" id="aboutBadges"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="subtabs" id="aboutTabs"></div>
|
||||
<div class="about-body" id="aboutBody"></div>
|
||||
</section>
|
||||
|
||||
<section id="p-devices" hidden>
|
||||
<div class="row"><button class="go" onclick="openPairing()">Appairer un appareil</button></div>
|
||||
<div id="devices"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/about.js"></script>
|
||||
<script>
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let token = localStorage.getItem('noxely-token') || '';
|
||||
@@ -98,16 +149,7 @@ $('token').value = token;
|
||||
function saveToken() {
|
||||
token = $('token').value.trim();
|
||||
localStorage.setItem('noxely-token', token);
|
||||
// Consultée depuis la machine du serveur, la page se remplit seule.
|
||||
(async () => {
|
||||
if (!token) {
|
||||
try {
|
||||
const { token: found } = await fetch('/api/bootstrap').then((r) => r.ok ? r.json() : {});
|
||||
if (found) { token = found; $('token').value = found; localStorage.setItem('noxely-token', found); }
|
||||
} catch {}
|
||||
}
|
||||
refreshAll();
|
||||
})();
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
@@ -120,13 +162,16 @@ async function api(path, options = {}) {
|
||||
}
|
||||
|
||||
function show(name) {
|
||||
for (const tab of ['search', 'watch', 'queue', 'library']) {
|
||||
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
|
||||
$('p-' + tab).hidden = tab !== name;
|
||||
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
|
||||
}
|
||||
if (name === 'queue') loadQueue();
|
||||
if (name === 'library') loadLibrary();
|
||||
if (name === 'watch') loadWatches();
|
||||
if (name === 'devices') loadDevices();
|
||||
if (name === 'settings') loadServerInfo();
|
||||
if (name === 'about') loadAbout();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -135,6 +180,10 @@ async function refreshAll() {
|
||||
$('status').textContent = health.name + ' ' + health.version;
|
||||
$('status').className = 'pill ok';
|
||||
} catch { $('status').textContent = 'injoignable'; $('status').className = 'pill warn'; }
|
||||
if (!token) {
|
||||
$('status').textContent = 'jeton requis → Réglages';
|
||||
$('status').className = 'pill warn';
|
||||
}
|
||||
try {
|
||||
const { catalogs } = await api('/catalogs');
|
||||
$('catalog').innerHTML = Object.entries(catalogs)
|
||||
@@ -257,6 +306,39 @@ async function sweep() {
|
||||
loadWatches();
|
||||
}
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const health = await fetch('/api/health').then((r) => r.json());
|
||||
const { devices } = await api('/devices');
|
||||
$('serverInfo').innerHTML = `
|
||||
<div class="card"><div class="grow"><b>${esc(health.name)} ${esc(health.version)}</b>
|
||||
<small>${devices.length} appareil(s) appairé(s)</small></div>
|
||||
<span class="pill ok">en service</span></div>`;
|
||||
} catch (e) {
|
||||
$('serverInfo').innerHTML = `<div class="card"><div class="grow"><b>Jeton requis</b>
|
||||
<small>${esc(String(e))}</small></div><span class="pill warn">non authentifié</span></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
try {
|
||||
const { devices } = await api('/devices');
|
||||
$('devices').innerHTML = devices.length ? devices.map((d) => `
|
||||
<div class="card"><div class="grow"><b>${esc(d.name)}</b><small>appairé le ${
|
||||
new Date(d.created_at).toLocaleString('fr-FR')}${
|
||||
d.last_seen ? ' · vu le ' + new Date(d.last_seen).toLocaleString('fr-FR')
|
||||
: ' · jamais synchronisé'}</small></div>
|
||||
<button class="ghost" onclick="revokeDevice('${d.id}','${esc(d.name)}')">Révoquer</button></div>`).join('')
|
||||
: '<div class="empty">Aucun appareil appairé</div>';
|
||||
} catch (e) { $('devices').innerHTML = `<div class="empty">${esc(String(e))}</div>`; }
|
||||
}
|
||||
|
||||
async function revokeDevice(id, name) {
|
||||
if (!confirm(`Révoquer « ${name} » ? Il ne pourra plus se synchroniser tant qu'il n'est pas réappairé.`)) return;
|
||||
await api('/devices/' + id, { method: 'DELETE' });
|
||||
loadDevices();
|
||||
}
|
||||
|
||||
async function openPairing() {
|
||||
try {
|
||||
const { seconds } = await api('/pair/open', { method: 'POST' });
|
||||
@@ -266,6 +348,78 @@ async function openPairing() {
|
||||
} catch (e) { alert('Appairage impossible : ' + e); }
|
||||
}
|
||||
|
||||
/* ── À propos ──────────────────────────────────────────────────────────────
|
||||
Le contenu vient d'about.js, la version de /api/health et le journal du
|
||||
CHANGELOG.md du dépôt : rien n'est recopié ici, donc rien ne peut diverger. */
|
||||
let aboutTab = 'features';
|
||||
const ABOUT_TABS = [
|
||||
['features', '⭐ Fonctions'], ['guarantees', '🛡️ Garanties'],
|
||||
['stack', '🧱 Technique'], ['log', '🕓 Journal'], ['author', '👤 Auteur'],
|
||||
];
|
||||
|
||||
async function loadAbout() {
|
||||
$('aboutName').textContent = ABOUT.name;
|
||||
$('aboutTagline').textContent = ABOUT.tagline;
|
||||
let version = '—', live = false;
|
||||
try {
|
||||
const health = await fetch('/api/health').then((r) => r.json());
|
||||
version = health.version; live = true;
|
||||
} catch {}
|
||||
$('aboutBadges').innerHTML =
|
||||
`<span class="pill accent">v${esc(version)}</span>` +
|
||||
`<span class="pill">${esc(ABOUT.license)}</span>` +
|
||||
`<span class="pill ${live ? 'ok' : 'warn'}">${live ? 'en service' : 'injoignable'}</span>` +
|
||||
`<span class="pill ok">4 catalogues libres</span>`;
|
||||
$('aboutTabs').innerHTML = ABOUT_TABS.map(([id, label]) =>
|
||||
`<button onclick="showAbout('${id}')" aria-selected="${id === aboutTab}">${label}</button>`).join('');
|
||||
renderAbout();
|
||||
}
|
||||
|
||||
function showAbout(id) { aboutTab = id; loadAbout(); }
|
||||
|
||||
async function renderAbout() {
|
||||
const body = $('aboutBody');
|
||||
if (aboutTab === 'features') {
|
||||
body.innerHTML = ABOUT.features.map(([title, text]) =>
|
||||
`<div class="card"><b>${esc(title)}</b><small>${esc(text)}</small></div>`).join('');
|
||||
} else if (aboutTab === 'guarantees') {
|
||||
body.innerHTML = `<div class="card"><small>${esc(ABOUT.guaranteesIntro)}</small>
|
||||
<ul style="margin-top:10px">${ABOUT.guarantees.map((g) => `<li>${esc(g)}</li>`).join('')}</ul></div>`;
|
||||
} else if (aboutTab === 'stack') {
|
||||
body.innerHTML = `<div class="card"><table class="tech">${ABOUT.stack.map(([layer, tools]) =>
|
||||
`<tr><td>${esc(layer)}</td><td>${esc(tools)}</td></tr>`).join('')}</table></div>`;
|
||||
} else if (aboutTab === 'author') {
|
||||
body.innerHTML = `<div class="card" style="display:flex;align-items:center">
|
||||
<div class="about-logo" style="width:52px;height:52px;border-radius:16px;font-size:24px">${
|
||||
esc(ABOUT.author[0])}</div>
|
||||
<div class="grow" style="margin-left:14px">
|
||||
<b>${esc(ABOUT.author)}</b><small>${esc(ABOUT.authorRole)}</small>
|
||||
<small style="user-select:text;display:block;margin-top:6px">${esc(ABOUT.repository)}</small>
|
||||
</div></div>
|
||||
<div class="card"><small>${esc(ABOUT.authorship)}</small>
|
||||
<small style="display:block;margin-top:6px">${esc(ABOUT.copyright)} — ${esc(ABOUT.license)}</small></div>`;
|
||||
} else {
|
||||
body.innerHTML = '<div class="empty">Lecture du journal…</div>';
|
||||
try {
|
||||
const text = await fetch('/api/changelog').then((r) => r.text());
|
||||
body.innerHTML = renderChangelog(text) || '<div class="empty">Journal vide</div>';
|
||||
} catch { body.innerHTML = '<div class="empty">Journal indisponible</div>'; }
|
||||
}
|
||||
}
|
||||
|
||||
/* Le CHANGELOG n'emploie que des titres « ## », des puces « - » et du gras :
|
||||
on n'interprète que cela, plutôt que d'embarquer un moteur Markdown. */
|
||||
function renderChangelog(text) {
|
||||
const bold = (s) => esc(s).replace(/\*\*(.+?)\*\*/g, '<b style="display:inline">$1</b>');
|
||||
const entries = [];
|
||||
for (const line of text.split('\n')) {
|
||||
if (line.startsWith('## ')) entries.push({ title: line.slice(3).trim(), items: [] });
|
||||
else if (line.startsWith('- ') && entries.length) entries[entries.length - 1].items.push(line.slice(2).trim());
|
||||
}
|
||||
return entries.map((e) => `<div class="card log"><h4>${bold(e.title)}</h4>
|
||||
<ul>${e.items.map((i) => `<li>${bold(i)}</li>`).join('')}</ul></div>`).join('');
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
|
||||
|
||||
Reference in new issue
Block a user