1 Commits
Author SHA1 Message Date
tuxgyver 53f5fc1148 Le serveur se présente, et range son jeton (2.3.0)
L'interface du serveur affichait un champ de jeton dans son en-tête, visible
en permanence alors qu'il ne sert qu'une fois : il descend dans l'onglet
Réglages, avec le nom du serveur, sa version et le nombre d'appareils
appairés.

Elle gagne surtout une page À propos sur le modèle des autres projets : cinq
sections, contenu séparé du rendu, version lue de /api/health et journal servi
depuis le CHANGELOG du dépôt — rien de recopié, donc rien qui puisse diverger.

Au passage, le serveur annonçait 1.0.0 depuis six publications ; il lit
maintenant le fichier VERSION comme le client.
2026-09-13 22:10:44 +02:00
5 changed files with 261 additions and 15 deletions

No files matched your search

+26
View File
@@ -5,6 +5,32 @@ 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é
+1 -1
View File
@@ -1 +1 @@
2.2.0
2.3.0
+20 -2
View File
@@ -24,14 +24,17 @@ 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"
@@ -278,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")
+73
View File
@@ -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'],
],
};
+141 -12
View File
@@ -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>
@@ -66,6 +83,8 @@
<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">
@@ -89,12 +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') || '';
@@ -103,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 = {}) {
@@ -125,7 +162,7 @@ async function api(path, options = {}) {
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library', 'devices']) {
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
$('p-' + tab).hidden = tab !== name;
$('t-' + tab).setAttribute('aria-selected', String(tab === name));
}
@@ -133,6 +170,8 @@ function show(name) {
if (name === 'library') loadLibrary();
if (name === 'watch') loadWatches();
if (name === 'devices') loadDevices();
if (name === 'settings') loadServerInfo();
if (name === 'about') loadAbout();
}
async function refreshAll() {
@@ -141,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)
@@ -263,6 +306,20 @@ 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');
@@ -291,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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));