3 Commits
Author SHA1 Message Date
tuxgyverandClaude Sonnet 5 6677f418ca Gestionnaire de fichiers dans la bibliothèque, boutons blancs sur la carte de lecture (2.6.0)
La bibliothèque du mobile n'offrait aucun moyen de supprimer un fichier : les
éléments reçus du serveur, rangés dans Music/Noxely, ne se retiraient pas, et
l'écran Hors ligne ne proposait la suppression que pour les enregistrements.

Un bouton dossier dans l'en-tête ouvre un écran Fichiers : provenance et taille
de chaque élément, filtre par origine, tri par date, taille ou titre, sélection
avec poids cumulé, suppression. Elle ne traite pas tout pareil : les fichiers
que l'application a créés sont effacés, les liens et podcasts retirés avec leur
copie hors ligne, et ceux de l'appareil — qui appartiennent à d'autres
applications — passent toujours par la confirmation d'Android, depuis Android 11.
Retirer aussi du serveur est une case décochée par défaut, car c'est
irréversible. Le morceau en cours de lecture n'est pas sélectionnable, et un
fichier déjà disparu est retiré de la liste au lieu d'y rester.

Les fichiers de l'appareil affichent leur taille, lue de MediaStore.

Sur la carte de lecture, réduire, playlist et stop passent en blanc, posés sur un
disque sombre translucide pour rester lisibles dans le thème clair.

Compilé et passé au lint de release ; l'écran n'a pas encore été essayé sur un
appareil.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
2026-09-20 23:20:42 +02:00
tuxgyverandClaude Sonnet 5 5b5e90247f Onglet Radio sur le serveur, bouton de synchronisation sur le mobile (2.5.0)
Le serveur gagne un onglet Radio : les stations de l'annuaire, écoutables en
direct dans le navigateur ou enregistrables — jusqu'à l'arrêt ou pour une durée
choisie —, puis réécoutables depuis l'interface. Quatre enregistrements en
parallèle au plus ; si le flux tombe, il est rouvert et le fichier continue.
Les enregistrements rejoignent la bibliothèque : le mobile les reçoit.

Un redémarrage du service clôt proprement les captures en cours, et celles
restées « en cours » après un plantage sont récupérées au démarrage suivant.
La copie de flux sans blocs ICY, propre au guet, est extraite pour être
partagée.

Côté mobile, la synchronisation manuelle n'était qu'un petit lien dans l'onglet
Connexion : elle a maintenant un vrai bouton dans l'onglet Synchronisation.
Deux passages ne se chevauchent plus, et une erreur inattendue s'affiche au lieu
de fermer l'application.

Le code Kotlin n'a pas été compilé sur cette machine (pas de JDK) : il a été
relu, mais reste à construire.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6pmSE168yjyf8vgV8mZx6
2026-09-20 22:39:59 +02:00
tuxgyverandClaude Sonnet 5 7810bef8a3 Le serveur garde la trace de ce qu'il entend, et repère la rotation (2.4.0)
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>
2026-09-20 17:59:32 +02:00
25 changed files with 1570 additions and 72 deletions

No files matched your search

+96
View File
@@ -5,6 +5,102 @@ 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.6.0] — 2026-09-20
### Ajouté
- **Gestionnaire de fichiers.** Un bouton dossier dans l'en-tête de la bibliothèque ouvre l'écran
`Fichiers` : tout ce qui occupe l'appareil, avec sa provenance et sa taille, filtrable par
origine (serveur, enregistrements, appareil, liens, podcasts) et triable par date, taille ou
titre. On sélectionne, on voit le poids cumulé, on supprime.
- **Suppression selon ce qu'est l'élément**, et sans jamais confondre les cas :
- *reçus du serveur* et *enregistrements*, que l'application a créés : effacés du téléphone ;
- *liens* et *podcasts* : retirés de la bibliothèque, avec leur copie hors ligne ;
- *fichiers de l'appareil*, qui appartiennent à d'autres applications : Android demande
toujours confirmation, et la suppression n'est proposée que depuis Android 11.
- **« Supprimer aussi du serveur »**, case à cocher décochée par défaut, pour les éléments reçus
du serveur : la suppression y est irréversible, elle ne se fait donc que sur demande.
- Les fichiers de l'appareil affichent désormais leur taille, lue de MediaStore.
### Modifié
- **Boutons de la carte de lecture en blanc** — réduire, ajouter à une playlist, arrêter —, posés
sur un disque sombre translucide pour rester lisibles sur le fond clair du thème clair.
### Notes
- Un élément en cours de lecture ne peut pas être sélectionné : le supprimer sous le lecteur le
ferait échouer.
- Un fichier déjà disparu du disque est retiré de la liste au lieu d'y rester comme une ligne
impossible à supprimer.
## [2.5.0] — 2026-09-20
### Ajouté
- **Onglet `📻 Radio`.** Les stations de l'annuaire, filtrables par genre et par nom. Chacune
s'écoute en direct depuis le navigateur ou s'enregistre, jusqu'à l'arrêt ou pour 15 min, 30 min,
1 h ou 2 h. Jusqu'à quatre stations en parallèle ; une station déjà en cours est refusée.
- **Réécoute.** Les enregistrements sont listés avec leur taille et leur durée : on les lit depuis
le navigateur, on les supprime, et le mobile les reçoit à sa prochaine synchronisation comme
n'importe quel média. Les captures faites par un guet y figurent aussi.
- **Reprise sur coupure.** Quand le flux tombe, le serveur le rouvre et continue dans le même
fichier ; il n'abandonne qu'après cinq tentatives infructueuses d'affilée, en gardant ce qui a
été capté.
- Routes `POST /api/radio/record`, `POST /api/radio/record/{id}/stop` et
`GET /api/radio/recordings`. Réglage `NOXELY_RECORD_MAX_MINUTES` (180) : plafond de durée, pour
qu'un enregistrement oublié ne remplisse pas le disque.
- **Bouton « Synchroniser maintenant » dans l'application.** Il était jusque-là réduit à un
petit lien dans Réglages ▸ Connexion, visible seulement une fois connecté. Il figure
désormais en pleine largeur dans l'onglet **Synchronisation**, avec un indicateur pendant le
transfert, et grisé — avec la raison — tant que le serveur n'est pas renseigné.
### Modifié
- **Deux synchronisations ne se chevauchent plus.** Le passage planifié et le bouton pouvaient
télécharger les mêmes fichiers en double ; le second est maintenant refusé avec le message
« Synchronisation déjà en cours ». Une erreur inattendue pendant la synchronisation manuelle
s'affiche au lieu de fermer l'application.
- **Un redémarrage du service ne laisse plus d'enregistrement « en cours » à jamais.** Le serveur
clôt proprement les captures à l'arrêt ; si le processus meurt brutalement, les fichiers
qui contiennent de l'audio sont déclarés prêts au démarrage suivant, les vides écartés.
- La copie d'un flux en retirant les blocs ICY, jusque-là propre au guet, est désormais partagée
avec l'enregistrement à la demande.
## [2.4.0] — 2026-09-20
### Ajouté
- **Historique des écoutes.** Le serveur note chaque titre que les stations annoncent (ICY),
même quand aucun morceau n'est guetté : station, heure, artiste et titre. Un nouvel onglet
`📈 Écoutes` le montre, avec une recherche dans le texte. La route `GET /api/heard` le sert.
- **Rotation.** `GET /api/rotation` compte, sur les *n* dernières remontées (100 par défaut,
jusqu'à 500), les passages de chaque titre et de chaque artiste, et le nombre de stations
distinctes qui les diffusent. Les accents, la casse et la ponctuation ne séparent plus un
même morceau en deux.
- Trois réglages : `NOXELY_HISTORY` (0 pour couper), `NOXELY_LISTEN_INTERVAL` (pause entre deux
tours quand rien n'est guetté, 60 s) et `NOXELY_HISTORY_DAYS` (purge au-delà, 90 jours).
### Modifié
- **Le balayage ne s'arrête plus quand rien n'est guetté.** Il tourne à cadence lente pour tenir
l'historique, et reste serré (`NOXELY_WATCH_INTERVAL`) dès qu'un guet est en attente.
- **Un tour va jusqu'au bout.** Trouver un morceau guetté n'interrompt plus le tour : l'enregistrement
part aussitôt, mais le reste des stations est lu, pour que l'historique soit complet.
- **La liste des stations est mise en cache une demi-heure.** Chaque tour interrogeait
l'annuaire communautaire ; avec un guet, c'était toutes les quelques secondes. En cas de
panne de l'annuaire, la liste précédente sert au lieu de faire échouer le tour.
### Notes
- Une station qui n'a pas changé de morceau n'ajoute rien, et un titre déjà relevé sur la même
station dans les dix minutes est ignoré : certaines stations alternent le vrai titre et un
titre de repli, ce qui doublait les passages. Les stations qui n'annoncent qu'elles-mêmes sont
écartées.
- La séparation « Artiste - Titre » se fait sur le premier tiret entouré d'espaces. Une station
qui annonce « Titre - Artiste », ou du parlé, est comptée telle quelle.
## [2.3.0] — 2026-09-13
### Ajouté
+5 -2
View File
@@ -7,7 +7,7 @@
[![Kotlin](https://img.shields.io/badge/Kotlin-2.1.0-7F52FF?logo=kotlin&logoColor=white)](https://kotlinlang.org)
[![Compose](https://img.shields.io/badge/Jetpack%20Compose-Material%203-4285F4?logo=jetpackcompose&logoColor=white)](https://developer.android.com/jetpack/compose)
[![Media3](https://img.shields.io/badge/Media3-1.11.1-FF6F00?logo=googleplay&logoColor=white)](https://developer.android.com/media/media3)
[![Version](https://img.shields.io/badge/Version-2.2.0-6B4EFF)](VERSION)
[![Version](https://img.shields.io/badge/Version-2.6.0-6B4EFF)](VERSION)
[![Licence](https://img.shields.io/badge/Licence-GPL%20v3-3FD8C0)](LICENSE)
[![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche)
@@ -51,6 +51,8 @@ synchronisations : c'est là qu'est l'économie.
| 🧾 **Choix piste par piste** | Un album affiche ses pistes avec leur taille avant d'être mis en file |
| 📡 **Guet multi-stations** | Nommez un morceau : les stations les plus écoutées sont balayées jusqu'à le trouver |
| 📻 **Enregistrement** | Capture du direct, nommée d'après le titre annoncé par la station |
| 🎙️ **Onglet Radio** | Écoute des stations en direct, enregistrement à la demande (jusqu'à l'arrêt ou pour une durée), réécoute depuis le navigateur ; le mobile récupère les enregistrements |
| 📈 **Historique et rotation** | Tout ce que les stations annoncent est noté ; on voit ce qui tourne sur les 100 dernières remontées |
| ⏳ **File de téléchargement** | Reprise après échec, état visible |
| 🖥️ **Interface web** | Tout se pilote depuis un navigateur, sans toucher au téléphone |
@@ -58,11 +60,12 @@ synchronisations : c'est là qu'est l'économie.
| | |
|---|---|
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante |
| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante — ou à la demande, d'un bouton |
| 📱 **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 |
| 🗂️ **Gestionnaire de fichiers** | Provenance et taille de chaque élément, tri par poids, suppression de la sélection — du téléphone, et du serveur si on le coche |
| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre |
| 🖼️ **Pochettes** | Cherchées automatiquement pour les titres qui n'en ont pas |
| ℹ️ **À propos et mises à jour** | Version, garanties, journal, et suivi des releases du dépôt |
+1 -1
View File
@@ -1 +1 @@
2.3.0
2.6.0
@@ -15,6 +15,7 @@ package fr.tuxgyver.noxely
import android.app.Application
import android.content.Context
import fr.tuxgyver.noxely.library.ArtworkFinder
import fr.tuxgyver.noxely.library.FileCleaner
import fr.tuxgyver.noxely.library.ImportedStore
import fr.tuxgyver.noxely.library.LibraryRepository
import fr.tuxgyver.noxely.library.LocalMediaScanner
@@ -51,6 +52,7 @@ class AppContainer(context: Context) {
val serverSettings = ServerSettings(context, scope)
val sync = SyncClient(context, serverSettings, imported)
val cleaner = FileCleaner(context, library, downloads, sync)
@@ -0,0 +1,148 @@
/*
* 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.
*/
package fr.tuxgyver.noxely.library
import android.content.Context
import android.content.IntentSender
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.offline.DownloadTracker
import fr.tuxgyver.noxely.sync.SyncClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/** Ce qu'une suppression a réellement fait, pour le dire à l'utilisateur sans rien enjoliver. */
data class CleanResult(
val removed: Int,
val freedBytes: Long,
/** Éléments qu'il a été impossible de supprimer. */
val failed: Int,
/** Éléments supprimés aussi du serveur. */
val onServer: Int,
/** Confirmation du système à obtenir pour le reste, ou null s'il n'y en a pas. */
val consent: Consent?,
)
/** Suppression en attente de l'accord du système : Android l'exige pour les fichiers d'autrui. */
class Consent(val sender: IntentSender, val entries: List<MediaEntry>)
/**
* Supprime des éléments de la bibliothèque, selon ce que chacun est réellement.
*
* Les quatre sortes ne se traitent pas de la même façon, et le confondre serait dangereux :
* - reçus du serveur et enregistrements : des fichiers que l'application a créés, qu'elle efface
* sans confirmation ;
* - liens et podcasts : de simples références, retirées avec leur copie hors ligne ;
* - contenu de l'appareil : des fichiers qui appartiennent à d'autres applications. Android n'en
* laisse supprimer aucun sans la confirmation de l'utilisateur, et nous ne la contournons pas.
*/
class FileCleaner(
private val context: Context,
private val library: LibraryRepository,
private val downloads: DownloadTracker,
private val sync: SyncClient,
) {
private enum class Outcome { GONE, DENIED }
/** Le contenu de l'appareil ne se supprime que depuis Android 11, par la boîte de dialogue système. */
val canDeleteDeviceFiles: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
suspend fun delete(entries: List<MediaEntry>, alsoOnServer: Boolean): CleanResult {
var removed = 0
var freed = 0L
val denied = mutableListOf<MediaEntry>()
val serverIds = mutableListOf<String>()
for (entry in entries) {
when (entry.source) {
SourceKind.URL, SourceKind.PODCAST -> {
downloads.remove(entry.id)
library.forget(entry.id)
removed++
}
SourceKind.SERVER, SourceKind.RECORDING -> when (removeOwnFile(entry)) {
Outcome.GONE -> {
library.forget(entry.id)
removed++
freed += entry.sizeBytes ?: 0L
if (entry.source == SourceKind.SERVER) serverIds += entry.id
}
Outcome.DENIED -> denied += entry
}
SourceKind.LOCAL -> denied += entry
}
}
val onServer = if (alsoOnServer && serverIds.isNotEmpty()) sync.deleteOnServer(serverIds) else 0
val consent = consentFor(denied)
// Sans confirmation possible (Android 10 et avant, ou fichier sans adresse système),
// ces éléments restent : on les compte en échec plutôt que de les faire disparaître.
val failed = if (consent == null) denied.size else denied.count { !it.hasSystemUri() }
return CleanResult(removed, freed, failed, onServer, consent)
}
/**
* À appeler quand le système a accepté la suppression : les fichiers ont disparu, il reste à
* les retirer de la bibliothèque et, si demandé, du serveur.
*/
suspend fun finish(consent: Consent, alsoOnServer: Boolean): Int {
val owned = consent.entries.filter { it.source != SourceKind.LOCAL }
owned.forEach { library.forget(it.id) }
if (alsoOnServer) {
sync.deleteOnServer(owned.filter { it.source == SourceKind.SERVER }.map { it.id })
}
if (consent.entries.any { it.source == SourceKind.LOCAL }) library.refreshLocal()
return consent.entries.count { it.hasSystemUri() }
}
/**
* Efface un fichier que l'application a créé. « Introuvable » vaut « supprimé » : un fichier
* déjà parti ne doit pas rester dans la bibliothèque comme une ligne qu'on ne peut plus retirer.
*/
private suspend fun removeOwnFile(entry: MediaEntry): Outcome = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(entry.uri)
when (uri.scheme) {
"content" -> {
context.contentResolver.delete(uri, null, null)
Outcome.GONE
}
"file" -> {
val file = File(requireNotNull(uri.path))
if (!file.exists() || file.delete()) Outcome.GONE else Outcome.DENIED
}
else -> Outcome.DENIED
}
} catch (_: Exception) {
// Dont SecurityException : après une réinstallation, MediaStore ne nous reconnaît
// plus comme propriétaire de nos propres fichiers.
Outcome.DENIED
}
}
private fun consentFor(entries: List<MediaEntry>): Consent? {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
val deletable = entries.filter { it.hasSystemUri() }
if (deletable.isEmpty()) return null
val request = MediaStore.createDeleteRequest(
context.contentResolver, deletable.map { Uri.parse(it.uri) },
)
return Consent(request.intentSender, deletable)
}
private fun MediaEntry.hasSystemUri(): Boolean = Uri.parse(uri).scheme == "content"
}
@@ -62,6 +62,7 @@ class LocalMediaScanner(private val context: Context) {
MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.ALBUM_ID,
MediaStore.Audio.Media.DATE_ADDED,
MediaStore.Audio.Media.SIZE,
)
val entries = mutableListOf<MediaEntry>()
context.contentResolver.query(
@@ -77,6 +78,7 @@ class LocalMediaScanner(private val context: Context) {
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
val albumCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID)
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DATE_ADDED)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val artist = cursor.getString(artistCol).orEmpty()
@@ -90,6 +92,7 @@ class LocalMediaScanner(private val context: Context) {
artworkUri = albumArtUri(cursor.getLong(albumCol)).toString(),
durationMs = cursor.getLong(durationCol),
addedAt = cursor.getLong(addedCol) * 1000L,
sizeBytes = cursor.getLong(sizeCol).takeIf { it > 0 },
)
}
}
@@ -108,6 +111,7 @@ class LocalMediaScanner(private val context: Context) {
MediaStore.Video.Media.BUCKET_DISPLAY_NAME,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.SIZE,
)
val entries = mutableListOf<MediaEntry>()
context.contentResolver.query(collection, projection, null, null, null)?.use { cursor ->
@@ -116,6 +120,7 @@ class LocalMediaScanner(private val context: Context) {
val bucketCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.BUCKET_DISPLAY_NAME)
val durationCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val addedCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
val sizeCol = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
while (cursor.moveToNext()) {
val id = cursor.getLong(idCol)
val uri = ContentUris.withAppendedId(collection, id)
@@ -129,6 +134,7 @@ class LocalMediaScanner(private val context: Context) {
artworkUri = uri.toString(),
durationMs = cursor.getLong(durationCol),
addedAt = cursor.getLong(addedCol) * 1000L,
sizeBytes = cursor.getLong(sizeCol).takeIf { it > 0 },
)
}
}
@@ -21,6 +21,7 @@ import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.radio.RecordingSink
import fr.tuxgyver.noxely.sources.openConnection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
@@ -95,7 +96,21 @@ class SyncClient(
}
}
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
/** Un seul passage à la fois : la synchronisation planifiée et le bouton ne se chevauchent pas. */
private val running = Mutex()
/**
* Récupère ce qui est prêt sur le serveur.
*
* Deux passages simultanés téléchargeraient les mêmes fichiers deux fois : le second est donc
* refusé, avec un message clair plutôt qu'un doublon dans la bibliothèque.
*/
suspend fun sync(): SyncResult {
if (!running.tryLock()) return SyncResult(0, 0, "Synchronisation déjà en cours")
return try { runSync() } finally { running.unlock() }
}
private suspend fun runSync(): SyncResult = withContext(Dispatchers.IO) {
val config = settings.config.value
if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré")
@@ -173,6 +188,26 @@ class SyncClient(
SyncResult(added, failed, settings.config.value.lastResult)
}
/**
* Supprime des médias du serveur, et renvoie le nombre réellement supprimé.
*
* Les identifiants sont ceux de la bibliothèque du téléphone (`srv:<id>`) : tout ce qui n'en
* porte pas le préfixe est ignoré. Un échec sur l'un n'empêche pas les autres.
*/
suspend fun deleteOnServer(entryIds: List<String>): Int = withContext(Dispatchers.IO) {
val config = settings.config.value
if (!config.isConfigured) return@withContext 0
val base = config.baseUrl.trimEnd('/')
entryIds.filter { it.startsWith("srv:") }.count { entryId ->
runCatching {
val connection = openConnection(
"$base/api/media/${entryId.removePrefix("srv:")}", method = "DELETE",
).apply { setRequestProperty("X-Noxely-Token", config.token) }
try { connection.responseCode in 200..299 } finally { connection.disconnect() }
}.getOrDefault(false)
}
}
private fun request(url: String, token: String?): String {
val connection = openConnection(url, readTimeoutMs = 30_000).apply {
token?.let { setRequestProperty("X-Noxely-Token", it) }
@@ -51,6 +51,7 @@ import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.ui.about.AboutScreen
import fr.tuxgyver.noxely.ui.files.FilesScreen
import fr.tuxgyver.noxely.ui.library.LibraryScreen
import fr.tuxgyver.noxely.ui.offline.DownloadsScreen
import fr.tuxgyver.noxely.ui.player.MiniPlayer
@@ -70,6 +71,7 @@ private enum class Tab(val route: String, val label: String, val icon: ImageVect
private const val PLAYLIST_ROUTE = "playlist/{playlist}"
private const val ABOUT_ROUTE = "about"
private const val FILES_ROUTE = "files"
@Composable
fun NoxelyRoot(container: AppContainer) {
@@ -115,7 +117,9 @@ fun NoxelyRoot(container: AppContainer) {
NavigationBar {
Tab.entries.forEach { tab ->
NavigationBarItem(
selected = currentRoute == tab.route,
// Le gestionnaire de fichiers s'ouvre depuis la bibliothèque : son onglet reste allumé.
selected = currentRoute == tab.route ||
(tab == Tab.LIBRARY && currentRoute == FILES_ROUTE),
onClick = { navController.switchTab(tab.route) },
icon = { Icon(tab.icon, contentDescription = null) },
label = { Text(tab.label) },
@@ -132,6 +136,17 @@ fun NoxelyRoot(container: AppContainer) {
currentMediaId = playerState.mediaId,
onPlay = play,
onAddToPlaylist = { playlistTarget = it },
onManageFiles = { navController.navigate(FILES_ROUTE) },
contentPadding = padding,
)
}
composable(FILES_ROUTE) {
FilesScreen(
container = container,
currentMediaId = playerState.mediaId,
onBack = navController::popBackStack,
onMessage = notify,
contentPadding = padding,
)
}
@@ -43,12 +43,16 @@ object AppInfo {
"ou forfait épuisé. Audio et vidéo, commandes au casque et sur l'écran verrouillé.",
"Se synchroniser sans y penser" to
"Toutes les six heures, en Wi-Fi non facturé et batterie suffisante, l'application " +
"demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort.",
"demande au serveur ce qui est neuf et le récupère. Le reste du temps, elle dort. " +
"Un bouton lance aussi la synchronisation à la demande.",
"Laisser le travail au serveur" to
"Recherche dans les catalogues, guet des morceaux sur les radios, enregistrement des " +
"directs : tout cela se pilote depuis l'interface web du serveur, pas depuis le mobile.",
"Organiser en playlists" to
"Des listes nommées, alimentées d'un appui long, lues à la suite.",
"Faire de la place" to
"Le gestionnaire de fichiers, ouvert depuis la bibliothèque, liste tout ce qui occupe " +
"l'appareil avec sa provenance et sa taille, trie par poids et supprime la sélection.",
"Retrouver les pochettes" to
"Les titres sans image en reçoivent une, cherchée sur leur nom.",
"Rester à jour" to
@@ -70,6 +74,9 @@ object AppInfo {
"plus aucune radio, ne cherche plus et n'enregistre plus.",
"Le cache ne contient que ce que vous avez demandé à garder : rien n'y est écrit pendant " +
"une écoute en ligne, et rien n'en est effacé sans votre geste.",
"Un fichier qui appartient à une autre application ne se supprime jamais sans la " +
"confirmation du système ; les fichiers reçus du serveur ne sont retirés du serveur " +
"que si vous le cochez.",
"Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " +
"d'appareil à appareil.",
"Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " +
@@ -61,6 +61,8 @@ fun MediaRow(
selected: Boolean? = null,
/** Appui long : ajout à une playlist. Absent là où le geste n'a pas de sens. */
onLongClick: (() -> Unit)? = null,
/** Taille à afficher après la durée, là où elle sert à choisir (gestionnaire de fichiers). */
sizeLabel: String? = null,
) {
val background by animateColorAsState(
targetValue = when {
@@ -110,7 +112,8 @@ fun MediaRow(
modifier = Modifier.weight(1f, fill = false),
)
Text(
text = " · ${formatDuration(entry.durationMs)}",
text = " · ${formatDuration(entry.durationMs)}" +
(sizeLabel?.let { " · $it" } ?: ""),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -0,0 +1,343 @@
/*
* 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.
*/
package fr.tuxgyver.noxely.ui.files
import android.app.Activity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.IntentSenderRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.FolderOpen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.library.Consent
import fr.tuxgyver.noxely.model.MediaEntry
import fr.tuxgyver.noxely.model.SourceKind
import fr.tuxgyver.noxely.offline.OfflineState
import fr.tuxgyver.noxely.ui.components.MediaRow
import fr.tuxgyver.noxely.ui.components.formatBytes
import kotlinx.coroutines.launch
private enum class SortKey(val label: String) {
RECENT("Récents"),
SIZE("Taille"),
TITLE("Titre"),
}
/**
* Gestionnaire de fichiers : voir ce qui occupe la place, et le supprimer.
*
* Chaque ligne annonce sa provenance et sa taille, car c'est ce qui permet de choisir. La
* suppression, elle, dépend de ce qu'est l'élément — voir [fr.tuxgyver.noxely.library.FileCleaner].
*/
@Composable
fun FilesScreen(
container: AppContainer,
currentMediaId: String?,
onBack: () -> Unit,
onMessage: (String) -> Unit,
contentPadding: PaddingValues,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
val offlineStates by container.downloads.states.collectAsStateWithLifecycle()
val serverSettings by container.serverSettings.config.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
var sort by rememberSaveable { mutableStateOf(SortKey.RECENT) }
var source by rememberSaveable { mutableStateOf<SourceKind?>(null) }
var selected by remember { mutableStateOf(setOf<String>()) }
var confirming by remember { mutableStateOf(false) }
var alsoOnServer by remember { mutableStateOf(false) }
var busy by remember { mutableStateOf(false) }
var awaitingConsent by remember { mutableStateOf<Pair<Consent, Boolean>?>(null) }
val consentLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
val pending = awaitingConsent
awaitingConsent = null
if (pending == null) return@rememberLauncherForActivityResult
if (result.resultCode == Activity.RESULT_OK) {
scope.launch {
val count = container.cleaner.finish(pending.first, pending.second)
val freed = pending.first.entries.sumOf { it.sizeBytes ?: 0L }
onMessage("$count fichier(s) supprimé(s) · ${formatBytes(freed)} libérés")
}
} else {
onMessage("Suppression annulée : ces fichiers appartiennent à d'autres applications")
}
}
fun sizeOf(entry: MediaEntry, offline: OfflineState?): Long =
if (entry.isRemote) offline?.takeIf { it.isComplete }?.bytesDownloaded ?: 0L
else entry.sizeBytes ?: 0L
val sources = remember(entries) { entries.map { it.source }.distinct() }
val visible = remember(entries, offlineStates, sort, source) {
entries
.filter { source == null || it.source == source }
.let { list ->
when (sort) {
SortKey.RECENT -> list.sortedByDescending { it.addedAt }
SortKey.SIZE -> list.sortedByDescending { sizeOf(it, offlineStates[it.id]) }
SortKey.TITLE -> list.sortedBy { it.title.lowercase() }
}
}
}
val chosen = remember(entries, selected) { entries.filter { it.id in selected } }
val chosenBytes = chosen.sumOf { sizeOf(it, offlineStates[it.id]) }
val totalBytes = entries.sumOf { sizeOf(it, offlineStates[it.id]) }
Column(
Modifier
.fillMaxSize()
.padding(top = contentPadding.calculateTopPadding())
.padding(horizontal = 16.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 8.dp)) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour")
}
Column(Modifier.weight(1f)) {
Text("Fichiers", style = MaterialTheme.typography.headlineSmall)
Text(
text = "${entries.size} éléments · ${formatBytes(totalBytes)} sur l'appareil",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 8.dp),
) {
FilterChip(selected = source == null, onClick = { source = null }, label = { Text("Tout") })
sources.forEach { kind ->
FilterChip(
selected = source == kind,
onClick = { source = kind },
label = { Text(kind.label) },
)
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
Text("Trier :", style = MaterialTheme.typography.labelMedium)
SortKey.entries.forEach { key ->
FilterChip(selected = sort == key, onClick = { sort = key }, label = { Text(key.label) })
}
}
Spacer(Modifier.size(8.dp))
if (selected.isNotEmpty()) {
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 8.dp, top = 6.dp, bottom = 6.dp),
) {
Column(Modifier.weight(1f)) {
Text("${chosen.size} sélectionné(s)", style = MaterialTheme.typography.titleSmall)
Text(formatBytes(chosenBytes), style = MaterialTheme.typography.bodySmall)
}
TextButton(onClick = {
selected = visible.filter { it.id != currentMediaId }.map { it.id }.toSet()
}) { Text("Tout") }
TextButton(onClick = { selected = emptySet() }) { Text("Annuler") }
if (busy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else {
Button(
onClick = { alsoOnServer = false; confirming = true },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError,
),
) { Text("Supprimer") }
}
}
}
} else {
Text(
text = "Touchez des éléments pour les sélectionner, puis supprimez-les.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 4.dp),
)
}
Spacer(Modifier.size(8.dp))
LazyColumn(
contentPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.weight(1f),
) {
if (visible.isEmpty()) {
item { EmptyFiles() }
}
items(visible, key = { it.id }) { entry ->
val offline = offlineStates[entry.id]
val bytes = sizeOf(entry, offline)
MediaRow(
entry = entry,
offline = offline,
isCurrent = entry.id == currentMediaId,
onClick = {
if (entry.id == currentMediaId) {
onMessage("En cours de lecture : arrêtez-la avant de la supprimer")
} else {
selected = if (entry.id in selected) selected - entry.id else selected + entry.id
}
},
onDownload = {},
onRemoveOffline = {},
selected = entry.id in selected,
sizeLabel = entry.source.label + if (bytes > 0) " · ${formatBytes(bytes)}" else "",
)
}
}
}
if (confirming) {
val server = chosen.count { it.source == SourceKind.SERVER }
val recordings = chosen.count { it.source == SourceKind.RECORDING }
val links = chosen.count { it.isRemote }
val device = chosen.count { it.source == SourceKind.LOCAL }
AlertDialog(
onDismissRequest = { confirming = false },
title = { Text("Supprimer ${chosen.size} élément(s) ?") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text("${formatBytes(chosenBytes)} seront libérés. Cette action est définitive.")
if (server > 0) Text("• $server reçu(s) du serveur : effacés du téléphone.")
if (recordings > 0) Text("• $recordings enregistrement(s) : effacés, sans retour possible.")
if (links > 0) Text("• $links lien(s) ou podcast(s) : retirés de la bibliothèque et de la mémoire hors ligne.")
if (device > 0) {
Text(
if (container.cleaner.canDeleteDeviceFiles)
"• $device fichier(s) de l'appareil : Android vous demandera de confirmer."
else
"• $device fichier(s) de l'appareil : impossibles à supprimer d'ici avant Android 11."
)
}
if (server > 0 && serverSettings.isConfigured) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Checkbox(checked = alsoOnServer, onCheckedChange = { alsoOnServer = it })
Text("Supprimer aussi du serveur (irréversible)")
}
}
}
},
confirmButton = {
TextButton(onClick = {
confirming = false
val targets = chosen
val withServer = alsoOnServer
busy = true
scope.launch {
val result = runCatching { container.cleaner.delete(targets, withServer) }
busy = false
selected = emptySet()
result.onFailure { onMessage(it.message ?: "Suppression impossible") }
result.onSuccess { done ->
onMessage(
buildString {
append("${done.removed} supprimé(s) · ${formatBytes(done.freedBytes)} libérés")
if (done.onServer > 0) append(" · ${done.onServer} sur le serveur")
if (done.failed > 0) append(" · ${done.failed} impossible(s)")
}
)
done.consent?.let { consent ->
awaitingConsent = consent to withServer
consentLauncher.launch(IntentSenderRequest.Builder(consent.sender).build())
}
}
}
}) { Text("Supprimer", color = MaterialTheme.colorScheme.error) }
},
dismissButton = { TextButton(onClick = { confirming = false }) { Text("Annuler") } },
)
}
}
@Composable
private fun EmptyFiles() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 56.dp),
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Rounded.FolderOpen,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(48.dp),
)
Spacer(Modifier.size(12.dp))
Text("Aucun fichier", style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center)
}
}
}
@@ -29,12 +29,14 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.horizontalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.FolderOpen
import androidx.compose.material.icons.rounded.LibraryMusic
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -72,6 +74,7 @@ fun LibraryScreen(
currentMediaId: String?,
onPlay: (List<MediaEntry>, Int) -> Unit,
onAddToPlaylist: (MediaEntry) -> Unit,
onManageFiles: () -> Unit,
contentPadding: PaddingValues,
) {
val entries by container.library.entries.collectAsStateWithLifecycle()
@@ -108,11 +111,19 @@ fun LibraryScreen(
) {
item {
Column {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp),
) {
Text(
text = "Bibliothèque",
style = MaterialTheme.typography.displaySmall,
modifier = Modifier.padding(top = 12.dp),
modifier = Modifier.weight(1f),
)
IconButton(onClick = onManageFiles) {
Icon(Icons.Rounded.FolderOpen, contentDescription = "Gérer les fichiers")
}
}
Text(
text = "${entries.size} éléments · ${offlineStates.values.count { it.isComplete }} mis de côté",
style = MaterialTheme.typography.bodyMedium,
@@ -27,6 +27,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -120,11 +121,17 @@ fun NowPlayingScreen(
.navigationBarsPadding()
.padding(horizontal = 24.dp),
) {
// Icônes blanches sur un disque sombre translucide : lisibles sur le fond violet clair du
// thème clair comme sur le fond sombre du thème sombre.
val onCard = IconButtonDefaults.iconButtonColors(
containerColor = Color.Black.copy(alpha = 0.35f),
contentColor = Color.White,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
IconButton(onClick = onCollapse) {
IconButton(onClick = onCollapse, colors = onCard) {
Icon(Icons.Rounded.KeyboardArrowDown, contentDescription = "Réduire")
}
Text(
@@ -134,10 +141,11 @@ fun NowPlayingScreen(
textAlign = TextAlign.Center,
modifier = Modifier.weight(1f),
)
IconButton(onClick = onAddToPlaylist) {
IconButton(onClick = onAddToPlaylist, colors = onCard) {
Icon(Icons.Rounded.PlaylistAdd, contentDescription = "Ajouter à une playlist")
}
IconButton(onClick = onStop) {
Spacer(Modifier.width(8.dp))
IconButton(onClick = onStop, colors = onCard) {
Icon(Icons.Rounded.Stop, contentDescription = "Arrêter la lecture")
}
OfflineToggle(entry, offline, onDownload, onRemoveOffline)
@@ -53,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import fr.tuxgyver.noxely.AppContainer
import fr.tuxgyver.noxely.BuildConfig
import fr.tuxgyver.noxely.sync.SyncResult
import fr.tuxgyver.noxely.ui.sources.UpdateCard
import kotlinx.coroutines.launch
import java.text.SimpleDateFormat
@@ -84,6 +85,19 @@ fun ServerScreen(
var busy by remember { mutableStateOf(false) }
var probe by remember { mutableStateOf<String?>(null) }
// Le bouton de l'onglet Synchronisation et celui de Connexion font exactement la même chose.
val runSync: () -> Unit = {
if (!busy) {
busy = true
scope.launch {
val result = runCatching { container.sync.sync() }
.getOrElse { SyncResult(0, 0, it.message ?: "Synchronisation impossible") }
busy = false
onMessage(result.message)
}
}
}
LaunchedEffect(config.baseUrl, config.token) {
if (url.isBlank()) url = config.baseUrl
if (token.isBlank()) token = config.token
@@ -191,14 +205,7 @@ fun ServerScreen(
if (busy) {
CircularProgressIndicator(strokeWidth = 2.dp, modifier = Modifier.size(22.dp))
} else if (config.isConfigured) {
TextButton(onClick = {
busy = true
scope.launch {
val result = container.sync.sync()
busy = false
onMessage(result.message)
}
}) { Text("Synchroniser") }
TextButton(onClick = runSync) { Text("Synchroniser") }
}
}
probe?.let {
@@ -249,6 +256,38 @@ fun ServerScreen(
}
}
item {
Column {
Button(
onClick = runSync,
enabled = config.isConfigured && !busy,
modifier = Modifier.fillMaxWidth(),
) {
if (busy) {
CircularProgressIndicator(
strokeWidth = 2.dp,
modifier = Modifier.size(18.dp),
color = MaterialTheme.colorScheme.onPrimary,
)
Spacer(Modifier.width(10.dp))
Text("Synchronisation en cours…")
} else {
Icon(Icons.Rounded.CloudSync, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.width(10.dp))
Text("Synchroniser maintenant")
}
}
if (!config.isConfigured) {
Text(
text = "Renseignez d'abord le serveur dans l'onglet Connexion.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
else -> {
+13
View File
@@ -17,3 +17,16 @@ NOXELY_PORT=8710
NOXELY_WATCH_STATIONS=40
NOXELY_WATCH_INTERVAL=5
NOXELY_WATCH_CONCURRENCY=8
# Historique des écoutes : le serveur note ce que les stations annoncent, même sans guet en
# cours, et en tire ce qui tourne. Mettre 0 pour le couper (le balayage s'arrête alors quand
# plus rien n'est guetté).
NOXELY_HISTORY=1
# Pause entre deux tours quand rien n'est guetté, en secondes. Plus haut = moins de trafic.
NOXELY_LISTEN_INTERVAL=60
# Ancienneté maximale de l'historique, en jours.
NOXELY_HISTORY_DAYS=90
# Durée maximale d'un enregistrement de station, en minutes. Garde-fou contre un enregistrement
# oublié : compter environ 57 Mo par heure pour une station à 128 kb/s.
NOXELY_RECORD_MAX_MINUTES=180
+29 -1
View File
@@ -32,9 +32,37 @@ make server-logs # journal suivi
| `GET /api/media/{id}/file` | **le mobile** — le fichier |
| `GET /api/search`, `/api/tracks`, `POST /api/queue` | l'interface web |
| `GET/POST/DELETE /api/watches`, `POST /api/watches/sweep` | l'interface web |
| `GET /api/radio/stations`, `POST /api/radio/record`, `POST /api/radio/record/{id}/stop`, `GET /api/radio/recordings` | l'interface web — stations, enregistrement à la demande |
| `GET /api/heard?limit=&q=` | l'interface web — l'historique de ce qui a été entendu |
| `GET /api/rotation?last=100&top=20` | l'interface web — ce qui tourne sur les *n* dernières remontées |
Toutes les routes sauf `/api/health` exigent l'en-tête `X-Noxely-Token`.
## Radio : écouter et enregistrer
L'onglet **📻 Radio** liste les stations (annuaire Radio Browser, filtrables par genre et par nom).
Chacune peut s'écouter en direct dans le navigateur, ou s'enregistrer jusqu'à l'arrêt — ou pour
une durée choisie (15 min à 2 h). Jusqu'à quatre stations en parallèle.
Un enregistrement rejoint la bibliothèque comme n'importe quel média : on le réécoute depuis
l'interface web, et le mobile le récupère à sa prochaine synchronisation. Si le flux tombe, le
serveur le rouvre (jusqu'à cinq essais d'affilée) et continue dans le même fichier. Un
redémarrage du service clôt proprement les enregistrements en cours. `NOXELY_RECORD_MAX_MINUTES`
(180) plafonne la durée, pour qu'un enregistrement oublié ne remplisse pas le disque.
## Historique et rotation
Le serveur note chaque titre annoncé par les stations qu'il balaie (ICY), même quand aucun
morceau n'est guetté. Une station qui n'a pas changé de morceau depuis le dernier tour n'ajoute
rien, et celles qui n'annoncent qu'elles-mêmes sont écartées.
La **rotation** compte, sur les *n* dernières remontées (100 par défaut), les passages de chaque
titre et de chaque artiste, avec le nombre de stations distinctes qui les diffusent.
Sans guet en cours, un tour a lieu toutes les `NOXELY_LISTEN_INTERVAL` secondes (60 par défaut)
— quelques dizaines de kilo-octets par station. `NOXELY_HISTORY=0` coupe l'historique ; les
lignes plus vieilles que `NOXELY_HISTORY_DAYS` (90) sont purgées.
## Licence
GPL v3, comme le reste du projet — voir [`../LICENSE`](../LICENSE).
@@ -43,6 +71,6 @@ GPL v3, comme le reste du projet — voir [`../LICENSE`](../LICENSE).
```
$NOXELY_DATA/
├── noxely.sqlite3 index des médias et des guets
├── noxely.sqlite3 index des médias, des guets et de l'historique des écoutes
└── media/ les fichiers eux-mêmes
```
+64 -1
View File
@@ -27,6 +27,8 @@ from fastapi import Body, Depends, FastAPI, Header, HTTPException, Query, Reques
from fastapi.responses import FileResponse, HTMLResponse, PlainTextResponse, Response
from . import config as config_module
from . import rotation
from .recorder import AlreadyRecording, Recorder, TooMany
from .sources import catalogs, radio
from .storage import Storage
from .workers import Downloader, Watcher, safe_name
@@ -42,6 +44,7 @@ config = config_module.load()
storage = Storage(config.db_path)
downloader = Downloader(config, storage)
watcher = Watcher(config, storage)
recorder = Recorder(config, storage)
def authorised(x_noxely_token: str = Header(default="")) -> None:
@@ -61,11 +64,15 @@ def authorised(x_noxely_token: str = Header(default="")) -> None:
@asynccontextmanager
async def lifespan(app: FastAPI):
recorder.recover()
tasks = [
asyncio.create_task(downloader.run_forever()),
asyncio.create_task(watcher.run_forever()),
]
yield
# Les enregistrements en cours sont clos avant de couper : sans cela, redémarrer le
# service laisserait chaque fichier « en cours » à jamais.
await recorder.stop_all()
for task in tasks:
task.cancel()
@@ -102,7 +109,8 @@ def media_file(media_id: str):
@app.delete("/api/media/{media_id}", dependencies=[Depends(authorised)])
def delete_media(media_id: str) -> dict:
async def delete_media(media_id: str) -> dict:
recorder.stop(media_id) # supprimer un enregistrement en cours l'arrête d'abord
item = storage.delete_media(media_id)
if item:
(config.media_dir / item.filename).unlink(missing_ok=True)
@@ -167,6 +175,44 @@ async def radio_stations(genre: str | None = None, name: str | None = None) -> d
return {"stations": await radio.stations(genre, name)}
@app.post("/api/radio/record", dependencies=[Depends(authorised)])
async def radio_record(payload: dict = Body(...)) -> dict:
"""Enregistre une station jusqu'à l'arrêt, ou jusqu'à `minutes` si elles sont données."""
url = str(payload.get("url") or "").strip()
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="Adresse de flux invalide")
try:
minutes = int(payload["minutes"]) if payload.get("minutes") else None
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="Durée invalide")
station = {
"name": str(payload.get("name") or "Station").strip()[:80] or "Station",
"url": url,
"codec": str(payload.get("codec") or ""),
}
try:
return {"media_id": recorder.start(station, minutes)}
except AlreadyRecording:
raise HTTPException(status_code=409, detail="Cette station est déjà en cours d'enregistrement")
except TooMany as error:
raise HTTPException(status_code=409, detail=f"{error} enregistrements simultanés au plus")
@app.post("/api/radio/record/{media_id}/stop", dependencies=[Depends(authorised)])
async def radio_stop(media_id: str) -> dict:
return {"stopped": recorder.stop(media_id)}
@app.get("/api/radio/recordings", dependencies=[Depends(authorised)])
async def radio_recordings() -> dict:
"""Ce qui s'enregistre en ce moment, et ce qui l'a été — guets compris."""
return {
"active": recorder.active(),
"items": [m.public() for m in storage.radio_media()],
"max_minutes": config.record_max_minutes,
}
@app.get("/api/watches", dependencies=[Depends(authorised)])
def watches() -> dict:
return {
@@ -200,6 +246,23 @@ async def sweep() -> dict:
raise HTTPException(status_code=502, detail=f"Annuaire indisponible : {str(error)[:120]}")
@app.get("/api/heard", dependencies=[Depends(authorised)])
def heard(limit: int = Query(default=100, ge=1, le=500), q: str | None = None) -> dict:
"""Ce que les stations ont annoncé, le plus récent d'abord ; `q` filtre sur le texte."""
return {
"items": storage.heard(limit, q.strip() if q else None),
"total": storage.heard_count(),
"history": config.history,
}
@app.get("/api/rotation", dependencies=[Depends(authorised)])
def rotation_view(last: int = Query(default=100, ge=1, le=500),
top: int = Query(default=20, ge=1, le=100)) -> dict:
"""Ce qui tourne sur les `last` dernières remontées, tous titres et artistes confondus."""
return rotation.rotation(storage.heard(last), top)
@app.get("/api/queue", dependencies=[Depends(authorised)])
def queue_state() -> dict:
pending = storage.media_since(0, state=None)
+14
View File
@@ -53,6 +53,16 @@ class Config:
watch_interval: int
#: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref.
watch_concurrency: int
#: Garder la trace de ce que les stations annoncent, même sans guet en cours.
history: bool
#: Pause entre deux tours quand rien n'est guetté : l'historique n'a pas besoin de la
#: cadence d'un guet, et cela ménage la bande passante.
listen_interval: int
#: Ancienneté maximale de l'historique, en jours. Au-delà, les lignes sont purgées.
history_days: int
#: Durée maximale d'un enregistrement de station, en minutes : garde-fou contre un
#: enregistrement oublié, qui remplirait le disque à raison d'environ 57 Mo par heure.
record_max_minutes: int
@property
def media_dir(self) -> Path:
@@ -74,4 +84,8 @@ def load() -> Config:
watch_stations=int(os.environ.get("NOXELY_WATCH_STATIONS", "40")),
watch_interval=int(os.environ.get("NOXELY_WATCH_INTERVAL", "5")),
watch_concurrency=int(os.environ.get("NOXELY_WATCH_CONCURRENCY", "8")),
history=os.environ.get("NOXELY_HISTORY", "1").strip().lower() not in ("0", "false", "non", "no"),
listen_interval=int(os.environ.get("NOXELY_LISTEN_INTERVAL", "60")),
history_days=int(os.environ.get("NOXELY_HISTORY_DAYS", "90")),
record_max_minutes=int(os.environ.get("NOXELY_RECORD_MAX_MINUTES", "180")),
)
+194
View File
@@ -0,0 +1,194 @@
# 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.
"""Enregistrement d'une station à la demande, jusqu'à l'arrêt ou l'expiration de la durée.
À la différence du guet, qui s'arrête quand la station annonce un autre morceau, on capte ici
le flux tel quel. Le fichier rejoint la bibliothèque comme n'importe quel média : le mobile le
récupère à sa prochaine synchronisation, et l'interface web peut le réécouter.
"""
from __future__ import annotations
import asyncio
import time
import uuid
from dataclasses import dataclass, field
from datetime import datetime
import httpx
from .config import Config
from .sources import radio
from .storage import Storage
from .workers import UA, safe_name
#: Enregistrements simultanés au plus : chacun tient un flux ouvert en permanence.
MAX_PARALLEL = 4
#: Nouvelles tentatives d'affilée avant d'abandonner un flux qui ne revient pas.
MAX_RECONNECTS = 5
#: En deçà, un enregistrement interrompu ne vaut pas d'être conservé.
MIN_KEPT_BYTES = 64 * 1024
class AlreadyRecording(Exception):
"""La station est déjà en cours d'enregistrement."""
class TooMany(Exception):
"""Trop d'enregistrements simultanés."""
@dataclass
class Active:
media_id: str
station: str
url: str
started_at: int
limit_minutes: int
written: int = 0
stop: asyncio.Event = field(default_factory=asyncio.Event)
task: asyncio.Task | None = None
def public(self) -> dict:
return {
"media_id": self.media_id, "station": self.station, "started_at": self.started_at,
"written": self.written, "limit_minutes": self.limit_minutes,
}
class Recorder:
def __init__(self, config: Config, storage: Storage) -> None:
self.config = config
self.storage = storage
self._active: dict[str, Active] = {}
# --- Pilotage -------------------------------------------------------
def start(self, station: dict, minutes: int | None = None) -> str:
"""Lance l'enregistrement d'une station ; à appeler depuis la boucle d'événements."""
if any(a.url == station["url"] for a in self._active.values()):
raise AlreadyRecording(station["name"])
if len(self._active) >= MAX_PARALLEL:
raise TooMany(str(MAX_PARALLEL))
cap = self.config.record_max_minutes
limit = min(minutes, cap) if minutes and minutes > 0 else cap
codec = (station.get("codec") or "").upper()
extension, mime = (
("aac", "audio/aac") if codec.startswith("AAC")
else ("ogg", "audio/ogg") if codec.startswith("OGG")
else ("mp3", "audio/mpeg")
)
stamp = datetime.now()
title = f"{station['name']} — {stamp:%d/%m/%Y %H:%M}"
# Le suffixe aléatoire évite qu'un second enregistrement de la même station, lancé dans
# la même minute, écrase le fichier du premier.
filename = safe_name(f"{station['name']}_{stamp:%Y-%m-%d_%H%M}_{uuid.uuid4().hex[:6]}", extension)
media_id = self.storage.add_media(
title=title, artist=station["name"], source="radio", origin=station["name"],
filename=filename, remote_url=None, mime=mime, state="downloading",
)
entry = Active(media_id, station["name"], station["url"], _now(), limit)
entry.task = asyncio.create_task(self._run(entry, filename))
self._active[media_id] = entry
return media_id
def stop(self, media_id: str) -> bool:
entry = self._active.get(media_id)
if not entry:
return False
entry.stop.set()
return True
async def stop_all(self, timeout: float = 8.0) -> None:
"""À l'arrêt du serveur : clôt proprement les fichiers plutôt que de les laisser en cours."""
tasks = [a.task for a in self._active.values() if a.task]
for media_id in list(self._active):
self.stop(media_id)
if tasks:
await asyncio.wait(tasks, timeout=timeout)
def active(self) -> list[dict]:
return [a.public() for a in self._active.values()]
def recover(self) -> None:
"""Au démarrage : ce qui était « en cours » ne l'est plus, l'ancien processus est mort.
Un fichier qui contient de l'audio reste lisible : on le déclare prêt plutôt que de
perdre une écoute entière sur un simple redémarrage du service.
"""
for item in self.storage.stuck_recordings():
path = self.config.media_dir / item.filename
size = path.stat().st_size if path.exists() else 0
if size >= MIN_KEPT_BYTES:
self.storage.update_media(item.id, state="ready", size=size,
error="interrompu par un arrêt du serveur")
else:
path.unlink(missing_ok=True)
self.storage.update_media(item.id, state="failed", error="interrompu, rien de capté")
# --- Capture --------------------------------------------------------
async def _run(self, entry: Active, filename: str) -> None:
target = self.config.media_dir / filename
began = time.monotonic()
deadline = began + entry.limit_minutes * 60
failures = 0
problem = ""
def keep_going(total: int, _title: str | None) -> bool:
entry.written = base + total
return not entry.stop.is_set() and time.monotonic() < deadline
try:
while not entry.stop.is_set() and time.monotonic() < deadline:
base = entry.written
try:
async with httpx.AsyncClient(
headers={**UA, "Icy-MetaData": "1"}, follow_redirects=True,
timeout=httpx.Timeout(30.0, read=60.0),
) as client:
async with client.stream("GET", entry.url) as response:
response.raise_for_status()
interval = int(response.headers.get("icy-metaint") or 0)
with target.open("ab") as handle:
await radio.copy_stream(response, handle, interval, keep_going)
except Exception as error:
problem = str(error)[:150] or type(error).__name__
# Une connexion qui a livré de l'audio remet le compteur à zéro : seules les
# tentatives infructueuses d'affilée comptent, pas une coupure après une heure.
failures = 0 if entry.written > base else failures + 1
if entry.stop.is_set() or time.monotonic() >= deadline:
break
if failures > MAX_RECONNECTS:
break
await asyncio.sleep(3)
finally:
self._finish(entry, target, time.monotonic() - began, problem)
self._active.pop(entry.media_id, None)
def _finish(self, entry: Active, target, elapsed: float, problem: str) -> None:
size = target.stat().st_size if target.exists() else 0
completed = entry.stop.is_set() or elapsed >= entry.limit_minutes * 60
if size >= MIN_KEPT_BYTES or (size > 0 and completed):
self.storage.update_media(
entry.media_id, state="ready", size=size, duration_ms=int(elapsed * 1000),
error=None if completed else (f"interrompu : {problem}" if problem else "interrompu"),
)
else:
target.unlink(missing_ok=True)
self.storage.update_media(
entry.media_id, state="failed",
error=problem or "aucune donnée reçue de la station",
)
def _now() -> int:
return int(time.time() * 1000)
+101
View File
@@ -0,0 +1,101 @@
# 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.
"""Lecture de ce que les stations annoncent : découpage des titres et calcul de la rotation.
Rien ici ne touche au réseau ni à la base : ce sont des fonctions pures, faciles à vérifier.
"""
from __future__ import annotations
import re
import unicodedata
#: « Artiste - Titre » : le séparateur porte des espaces, pour ne pas couper « Jay-Z » ni
#: « Ain't That a Kick in the Head ». Le tiret cadratin et le demi-cadratin comptent aussi.
_SEPARATOR = re.compile(r"\s+[-–—]\s+")
#: Ce que certaines stations annoncent quand elles n'ont rien à dire.
_PLACEHOLDERS = {"", "unknown", "n a", "none", "null", "untitled"}
def normalise(text: str) -> str:
"""Clé de comparaison : sans accents, sans casse, sans ponctuation.
« Beyoncé — Halo » et « BEYONCE - HALO » doivent tomber dans la même case, sans quoi la
rotation compterait deux fois le même morceau.
"""
decomposed = unicodedata.normalize("NFKD", text)
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
return re.sub(r"[^a-z0-9]+", " ", stripped.casefold()).strip()
def split_title(raw: str) -> tuple[str, str]:
"""Sépare « Artiste - Titre ». Sans séparateur, tout est titre : on ne devine pas l'artiste."""
parts = _SEPARATOR.split(raw.strip(), maxsplit=1)
if len(parts) == 2 and parts[0].strip() and parts[1].strip():
return parts[0].strip(), parts[1].strip()
return "", raw.strip()
def is_track(raw: str, station: str) -> bool:
"""Écarte ce qui n'est pas un morceau : une station qui s'annonce elle-même, ou rien."""
key = normalise(raw)
return key not in _PLACEHOLDERS and key != normalise(station)
def rotation(rows: list[dict], top: int = 20) -> dict:
"""Ce qui tourne, d'après les dernières remontées.
`rows` est le résultat brut de la base (artist, title, station, heard_at). Un morceau se
compte par passage ; le nombre de stations distinctes dit s'il tourne partout ou sur une
seule antenne, ce qui n'a pas le même sens.
"""
tracks: dict[str, dict] = {}
artists: dict[str, dict] = {}
for row in rows:
key = normalise(row["artist"]) + "|" + normalise(row["title"])
entry = tracks.setdefault(key, {
"artist": row["artist"], "title": row["title"], "plays": 0,
"stations": set(), "last_heard": 0,
})
entry["plays"] += 1
entry["stations"].add(row["station"])
entry["last_heard"] = max(entry["last_heard"], row["heard_at"])
if row["artist"]:
who = artists.setdefault(normalise(row["artist"]), {
"artist": row["artist"], "plays": 0, "tracks": set(),
"stations": set(), "last_heard": 0,
})
who["plays"] += 1
who["tracks"].add(key)
who["stations"].add(row["station"])
who["last_heard"] = max(who["last_heard"], row["heard_at"])
def ranked(groups: dict[str, dict]) -> list[dict]:
ordered = sorted(
groups.values(),
key=lambda g: (-g["plays"], -len(g["stations"]), -g["last_heard"]),
)
out = []
for group in ordered[:top]:
shaped = {**group, "stations": sorted(group["stations"])}
if "tracks" in shaped:
shaped["tracks"] = len(shaped["tracks"])
out.append(shaped)
return out
return {
"window": len(rows),
"since": min((r["heard_at"] for r in rows), default=None),
"tracks": ranked(tracks),
"artists": ranked(artists),
}
+38
View File
@@ -100,3 +100,41 @@ async def _read_title(response, interval: int) -> str | None:
found = STREAM_TITLE.search(meta)
return found.group(1).strip() or None if found else None
return None
async def copy_stream(response, handle, interval: int, keep_going) -> int:
"""Copie l'audio d'un flux dans `handle`, sans les blocs de métadonnées ICY.
Laissés dans le fichier, ces blocs le rendraient illisible. `keep_going(total, title)` est
interrogée après chaque bloc écrit : `total` est le nombre d'octets audio copiés, `title` le
titre qui vient d'être annoncé (ou None). Elle décide seule quand s'arrêter — fin d'un
morceau pour le guet, demande de l'utilisateur ou durée écoulée pour un enregistrement.
"""
if interval <= 0:
total = 0
async for chunk in response.aiter_bytes(32768):
handle.write(chunk)
total += len(chunk)
if not keep_going(total, None):
break
return total
buffer = bytearray()
total = 0
async for chunk in response.aiter_bytes(32768):
buffer.extend(chunk)
while len(buffer) >= interval + 1:
length = buffer[interval] * 16
if len(buffer) < interval + 1 + length:
break
handle.write(bytes(buffer[:interval]))
total += interval
meta = bytes(buffer[interval + 1:interval + 1 + length]).decode("utf-8", "replace")
del buffer[:interval + 1 + length]
announced = STREAM_TITLE.search(meta)
title = announced.group(1).strip() if announced else None
if not keep_going(total, title or None):
return total
if not keep_going(total, None):
return total
return total
+84
View File
@@ -61,9 +61,26 @@ CREATE TABLE IF NOT EXISTS watches (
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
@@ -152,6 +169,25 @@ class Storage:
)
]
def radio_media(self, limit: int = 100) -> list[Media]:
"""Les enregistrements de radio, les plus récents d'abord — guets compris."""
return [
Media(**dict(r))
for r in self._db.execute(
"SELECT * FROM media WHERE source = 'radio' ORDER BY created_at DESC LIMIT ?",
(limit,),
)
]
def stuck_recordings(self) -> list[Media]:
"""Enregistrements restés « en cours » : le serveur s'est arrêté avant de les clore."""
return [
Media(**dict(r))
for r in self._db.execute(
"SELECT * FROM media WHERE source = 'radio' AND state = 'downloading'"
)
]
def delete_media(self, media_id: str) -> Media | None:
item = self.media(media_id)
if item:
@@ -228,3 +264,51 @@ class Storage:
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
+7 -2
View File
@@ -34,8 +34,13 @@ const ABOUT = {
'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.'],
'Le guet capte le morceau annoncé et s\'arrête quand il cède la place au suivant.'],
['Écouter et enregistrer une station',
'L\'onglet Radio liste les stations : on les écoute en direct, ou on les enregistre — jusqu\'à ' +
'l\'arrêt ou pour une durée choisie — puis on réécoute depuis le navigateur ou le mobile.'],
['Garder l\'historique et repérer la rotation',
'Chaque titre annoncé par les stations est noté ; l\'onglet Écoutes montre ce qui a été ' +
'entendu et ce qui tourne le plus sur les dernières remontées, titres comme artistes.'],
['Tenir la file',
'Les téléchargements s\'enchaînent seuls ; un échec est repris, son état reste visible.'],
['Servir le mobile',
+223 -3
View File
@@ -66,6 +66,10 @@
.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%; }
[hidden] { display:none !important; }
.card button { flex:none; }
#audio { flex:2; min-width:220px; max-width:100%; }
h3.section { font-size:15px; margin:20px 0 10px; }
.log h4 { margin:0 0 4px; font-size:15px; }
</style>
</head>
@@ -80,6 +84,8 @@
<div class="tabs">
<button onclick="show('search')" aria-selected="true" id="t-search">🔎 Rechercher</button>
<button onclick="show('watch')" aria-selected="false" id="t-watch">📡 Guets</button>
<button onclick="show('radio')" aria-selected="false" id="t-radio">📻 Radio</button>
<button onclick="show('heard')" aria-selected="false" id="t-heard">📈 Écoutes</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>
@@ -106,6 +112,51 @@
<div id="watches"></div>
</section>
<section id="p-radio" hidden>
<div class="row">
<select id="radioGenre" onchange="loadStations()"><option value="">Tous les genres</option></select>
<input type="text" id="radioName" placeholder="Nom de la station"
onkeydown="if(event.key==='Enter')loadStations()">
<button class="go" onclick="loadStations()">Chercher</button>
<select id="radioMinutes" title="Durée d'un enregistrement">
<option value="">Enregistrer jusqu'à l'arrêt</option>
<option value="15">Enregistrer 15 min</option>
<option value="30">Enregistrer 30 min</option>
<option value="60">Enregistrer 1 h</option>
<option value="120">Enregistrer 2 h</option>
</select>
</div>
<div class="card" id="radioPlayer" hidden>
<div class="grow"><b id="playerTitle"></b><small id="playerNote"></small></div>
<audio id="audio" controls></audio>
<button class="ghost" onclick="stopPlayer()" title="Fermer le lecteur">×</button>
</div>
<h3 class="section">⏺ En cours d'enregistrement</h3>
<div id="radioActive"></div>
<h3 class="section">🎙️ Enregistrements</h3>
<div id="radioRecordings"></div>
<h3 class="section">📻 Stations</h3>
<div id="radioStations"></div>
</section>
<section id="p-heard" hidden>
<div class="row">
<select id="rotationWindow" onchange="loadHeard()">
<option value="50">50 dernières remontées</option>
<option value="100" selected>100 dernières remontées</option>
<option value="200">200 dernières remontées</option>
<option value="500">500 dernières remontées</option>
</select>
<input type="text" id="heardQuery" placeholder="Chercher dans l'historique"
onkeydown="if(event.key==='Enter')loadHeard()">
<button class="ghost" onclick="loadHeard()">Actualiser</button>
</div>
<h3 class="section">🔥 En rotation</h3>
<div id="rotation"></div>
<h3 class="section">🕘 Historique</h3>
<div id="heard"></div>
</section>
<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>
@@ -162,13 +213,15 @@ async function api(path, options = {}) {
}
function show(name) {
for (const tab of ['search', 'watch', 'queue', 'library', 'devices', 'settings', 'about']) {
for (const tab of ['search', 'watch', 'radio', 'heard', '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 === 'radio') loadRadio();
if (name === 'heard') loadHeard();
if (name === 'devices') loadDevices();
if (name === 'settings') loadServerInfo();
if (name === 'about') loadAbout();
@@ -185,9 +238,11 @@ async function refreshAll() {
$('status').className = 'pill warn';
}
try {
const { catalogs } = await api('/catalogs');
const { catalogs, genres } = await api('/catalogs');
$('catalog').innerHTML = Object.entries(catalogs)
.map(([id, label]) => `<option value="${id}">${label}</option>`).join('');
$('radioGenre').innerHTML = '<option value="">Tous les genres</option>' +
genres.map((g) => `<option value="${esc(g)}">${esc(g)}</option>`).join('');
} catch {}
}
@@ -271,7 +326,8 @@ async function loadWatches() {
try {
const { watches, recording, sweeps, last_sweep: last } = await api('/watches');
const scan = last && last.stations
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus`
? `${sweeps} tour(s) · dernier : ${last.stations} stations, ${last.titles} titres lus, ` +
`${last.heard || 0} nouveau(x) dans l'historique`
: 'en attente d\'un guet';
$('watches').innerHTML =
`<div class="card"><div class="grow"><b>Balayage continu</b><small>${esc(scan)}</small></div>
@@ -306,6 +362,170 @@ async function sweep() {
loadWatches();
}
const clock = (ms) => new Date(ms).toLocaleString('fr-FR',
{ day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
async function loadHeard() {
const last = $('rotationWindow').value;
const query = $('heardQuery').value.trim();
try {
const [rot, list] = await Promise.all([
api('/rotation?last=' + last + '&top=15'),
api('/heard?limit=100' + (query ? '&q=' + encodeURIComponent(query) : '')),
]);
const label = (t) => t.artist ? `${esc(t.artist)} — ${esc(t.title)}` : esc(t.title);
const stations = (n) => n.length > 2 ? `${n.length} stations` : esc(n.join(', '));
$('rotation').innerHTML = rot.tracks.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${rot.window} remontée(s) analysée(s)</div>` +
rot.tracks.map((t) => `
<div class="card"><div class="grow"><b>${label(t)}</b>
<small>${stations(t.stations)} · dernier passage ${clock(t.last_heard)}</small></div>
<span class="pill ${t.plays > 1 ? 'ok' : ''}">×${t.plays}</span></div>`).join('') +
(rot.artists.length ? `<h3 class="section">🎤 Artistes</h3>` + rot.artists.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.artist)}</b>
<small>${a.tracks} titre(s) · ${a.stations.length} station(s)</small></div>
<span class="pill ${a.plays > 1 ? 'ok' : ''}">×${a.plays}</span></div>`).join('') : '')
: `<div class="empty">${list.history
? 'Rien d\'entendu pour l\'instant — le premier tour de balayage peut prendre une minute.'
: 'L\'historique est désactivé (NOXELY_HISTORY=0).'}</div>`;
$('heard').innerHTML = list.items.length
? `<div class="empty" style="padding:0 0 10px;text-align:left">${list.total} ligne(s) au total</div>` +
list.items.map((h) => `
<div class="card"><div class="grow"><b>${h.artist ? esc(h.artist) + ' — ' : ''}${esc(h.title)}</b>
<small>${esc(h.station)} · ${clock(h.heard_at)}</small></div></div>`).join('')
: '<div class="empty">Aucun résultat</div>';
} catch (e) {
$('rotation').innerHTML = `<div class="empty">${esc(String(e))}</div>`;
$('heard').innerHTML = '';
}
}
// --- Radio : stations, écoute, enregistrement ---------------------------
let radioStations = [];
let radioItems = [];
let radioTimer = null;
let playerUrl = null;
const fmtSize = (n) => n >= 1e6 ? (n / 1e6).toFixed(1) + ' Mo' : Math.round(n / 1e3) + ' Ko';
const fmtDuration = (ms) => {
if (ms < 60000) return `${Math.max(1, Math.round(ms / 1000))} s`;
const min = Math.round(ms / 60000);
return min >= 60 ? `${Math.floor(min / 60)} h ${String(min % 60).padStart(2, '0')}` : `${min} min`;
};
function loadRadio() {
loadRecordings();
if (!radioStations.length) loadStations();
}
async function loadStations() {
$('radioStations').innerHTML = '<div class="empty">Chargement des stations…</div>';
const params = new URLSearchParams();
if ($('radioGenre').value) params.set('genre', $('radioGenre').value);
if ($('radioName').value.trim()) params.set('name', $('radioName').value.trim());
try {
radioStations = (await api('/radio/stations?' + params)).stations;
$('radioStations').innerHTML = radioStations.length ? radioStations.map((s, i) => `
<div class="card"><div class="grow"><b>${esc(s.name)}</b>
<small>${esc([s.country, s.codec, s.bitrate ? s.bitrate + ' kb/s' : ''].filter(Boolean).join(' · '))}</small></div>
<button class="ghost" onclick="listenLive(${i})">▶ Écouter</button>
<button class="go" onclick="recordStation(${i})">⏺ Enregistrer</button></div>`).join('')
: '<div class="empty">Aucune station</div>';
} catch (e) {
$('radioStations').innerHTML = `<div class="empty">Annuaire indisponible : ${esc(e.message || e)}</div>`;
}
}
function play(title, note, src, isBlob) {
if (playerUrl) URL.revokeObjectURL(playerUrl);
playerUrl = isBlob ? src : null;
$('radioPlayer').hidden = false;
$('playerTitle').textContent = title;
$('playerNote').textContent = note;
const audio = $('audio');
audio.onerror = () => { $('playerNote').textContent = 'Lecture impossible dans ce navigateur'; };
audio.src = src;
audio.play().catch(() => {});
}
function stopPlayer() {
const audio = $('audio');
audio.pause();
audio.removeAttribute('src');
audio.load();
if (playerUrl) URL.revokeObjectURL(playerUrl);
playerUrl = null;
$('radioPlayer').hidden = true;
}
const listenLive = (i) => play(radioStations[i].name, 'En direct', radioStations[i].url, false);
async function playRecording(i) {
const item = radioItems[i];
$('radioPlayer').hidden = false;
$('playerTitle').textContent = item.title;
$('playerNote').textContent = 'Chargement du fichier…';
try {
const response = await fetch('/api/media/' + item.id + '/file', { headers: { 'X-Noxely-Token': token } });
if (!response.ok) throw new Error(response.status);
play(item.title, 'Enregistrement', URL.createObjectURL(await response.blob()), true);
} catch (e) { $('playerNote').textContent = 'Lecture impossible : ' + (e.message || e); }
}
async function recordStation(i) {
const s = radioStations[i];
const minutes = $('radioMinutes').value;
try {
await api('/radio/record', { method: 'POST', body: JSON.stringify({
name: s.name, url: s.url, codec: s.codec, minutes: minutes ? Number(minutes) : null }) });
loadRecordings();
} catch (e) { alert(e.message || e); }
}
async function stopRecording(id) {
await api('/radio/record/' + id + '/stop', { method: 'POST' });
setTimeout(loadRecordings, 1500);
}
async function deleteRecording(i) {
if (!confirm('Supprimer définitivement « ' + radioItems[i].title + ' » du serveur ?')) return;
await api('/media/' + radioItems[i].id, { method: 'DELETE' });
loadRecordings();
}
async function loadRecordings() {
clearTimeout(radioTimer);
try {
const { active, items, max_minutes: max } = await api('/radio/recordings');
radioItems = items;
const running = new Set(active.map((a) => a.media_id));
$('radioActive').innerHTML = active.length ? active.map((a) => `
<div class="card"><div class="grow"><b>${esc(a.station)}</b>
<small>depuis ${clock(a.started_at)} · ${fmtSize(a.written)} · s'arrête seul après ${a.limit_minutes} min (max ${max})</small></div>
<span class="pill warn">● en cours</span>
<button class="go" onclick="stopRecording('${a.media_id}')">■ Arrêter</button></div>`).join('')
: '<div class="empty" style="padding:10px">Aucun enregistrement en cours</div>';
const shown = items.map((m, i) => [m, i]).filter(([m]) => !running.has(m.id));
$('radioRecordings').innerHTML = shown.length ? shown.map(([m, i]) => `
<div class="card"><div class="grow"><b>${esc(m.title)}</b>
<small>${clock(m.created_at)}${m.size ? ' · ' + fmtSize(m.size) : ''}${
m.duration_ms ? ' · ' + fmtDuration(m.duration_ms) : ''}${m.error ? ' · ' + esc(m.error) : ''}</small></div>
${m.state === 'ready' ? `<button class="ghost" onclick="playRecording(${i})">▶ Écouter</button>`
: `<span class="pill ${m.state === 'failed' ? 'warn' : ''}">${m.state === 'failed' ? 'échec' : 'en cours'}</span>`}
<button class="ghost" onclick="deleteRecording(${i})" title="Supprimer">×</button></div>`).join('')
: '<div class="empty" style="padding:10px">Aucun enregistrement — choisis une station ci-dessous</div>';
// Tant que quelque chose s'enregistre, la taille et l'état se rafraîchissent d'eux-mêmes.
if (!$('p-radio').hidden && (active.length || items.some((m) => m.state === 'downloading'))) {
radioTimer = setTimeout(loadRecordings, 4000);
}
} catch (e) {
$('radioActive').innerHTML = `<div class="empty">${esc(e.message || e)}</div>`;
}
}
async function loadServerInfo() {
try {
const health = await fetch('/api/health').then((r) => r.json());
+68 -46
View File
@@ -22,6 +22,7 @@ from pathlib import Path
import httpx
from . import rotation
from .config import Config
from .sources import radio
from .storage import Storage
@@ -83,54 +84,98 @@ class Watcher:
#: Dernier tour de balayage, pour que l'interface montre que ça tourne.
self.last_sweep: dict = {}
self.sweeps = 0
self._stations: list[dict] = []
self._stations_at = 0.0
self._pruned_at = 0.0
async def run_forever(self) -> None:
"""Balaie en continu : un tour enchaîne sur le suivant sans attendre.
"""Balaie en continu : un tour enchaîne sur le suivant.
Une sonde ne lit que le premier bloc de métadonnées puis referme — quelques dizaines de
kilo-octets par station. C'est ce qui rend le balayage permanent tenable, là où garder
quarante flux ouverts consommerait plusieurs mégabits par seconde en continu.
Le rythme dépend de ce qu'on attend de lui : serré quand un morceau est guetté, plus
lâche quand il ne fait que tenir l'historique, arrêté si l'historique est coupé.
"""
while True:
try:
if self.storage.pending_watches():
self.last_sweep = await self.sweep()
self.sweeps += 1
else:
# Rien à guetter : inutile de solliciter l'annuaire.
guetting = bool(self.storage.pending_watches())
if not guetting and not self.config.history:
# Rien à guetter et rien à retenir : inutile de solliciter l'annuaire.
await asyncio.sleep(30)
continue
self.last_sweep = await self.sweep()
self.sweeps += 1
self._prune()
except Exception as error:
print(f"[noxely] guet : {error}")
await asyncio.sleep(30)
continue
await asyncio.sleep(self.config.watch_interval)
await asyncio.sleep(self.config.watch_interval if guetting
else self.config.listen_interval)
async def _known_stations(self) -> list[dict]:
"""Les stations à sonder, relues de l'annuaire une fois par demi-heure.
Sans ce cache, chaque tour — toutes les quelques secondes en cas de guet — interrogerait
l'annuaire, un service communautaire. Si celui-ci est injoignable, on garde la liste
précédente plutôt que de renoncer au tour.
"""
fresh = time.monotonic() - self._stations_at < 1800
if self._stations and fresh:
return self._stations
try:
self._stations = await radio.stations(limit=self.config.watch_stations)
self._stations_at = time.monotonic()
except Exception:
if not self._stations:
raise
return self._stations
def _prune(self) -> None:
"""Purge l'historique périmé, au plus une fois par heure."""
if time.monotonic() - self._pruned_at < 3600:
return
self._pruned_at = time.monotonic()
limit = _now() - self.config.history_days * 86_400_000
self.storage.prune_heard(limit)
async def sweep(self) -> dict:
pending = self.storage.pending_watches()
if not pending:
return {"stations": 0, "titles": 0, "at": _now(), "matched": None}
found = await radio.stations(limit=self.config.watch_stations)
found = await self._known_stations()
titles = 0
noted = 0
matched: dict | None = None
for group in _chunks(found, self.config.watch_concurrency):
heard = await asyncio.gather(*(radio.now_playing(s["url"]) for s in group))
for station, title in zip(group, heard):
if not title:
continue
titles += 1
if self.config.history and rotation.is_track(title, station["name"]):
artist, name = rotation.split_title(title)
noted += self.storage.add_heard(
station_id=station["id"], station=station["name"], raw=title,
artist=artist, title=name,
)
if matched:
continue
hit = next((w for w in pending if w["query"].lower() in title.lower()), None)
if not hit:
continue
# L'enregistrement part tout de suite : attendre la fin du tour ferait manquer
# le début du morceau. Le tour, lui, continue, pour que l'historique soit complet.
if self.recording is None:
asyncio.create_task(self.record(station, title, hit["query"]))
self.storage.mark_watch_found(hit["id"], station["name"], title)
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(),
"matched": title, "station": station["name"]}
matched = {"matched": title, "station": station["name"]}
self.storage.mark_watches_scanned([w["id"] for w in pending])
return {"stations": len(found), "titles": titles, "at": _now(), "matched": None}
return {"stations": len(found), "titles": titles, "heard": noted, "at": _now(),
"matched": matched["matched"] if matched else None,
**({"station": matched["station"]} if matched else {})}
async def record(self, station: dict, title: str, trigger: str) -> None:
"""Enregistre jusqu'à ce que la station annonce un autre morceau."""
@@ -150,44 +195,21 @@ class Watcher:
async with client.stream("GET", station["url"]) as response:
response.raise_for_status()
interval = int(response.headers.get("icy-metaint") or 0)
# Fin du morceau : la station en annonce un autre. Les gardes-fous bornent
# le fichier quand aucune annonce ne vient (40 Mo sans ICY, 60 Mo avec).
limit = 40_000_000 if interval <= 0 else 60_000_000
def keep_going(total: int, announced: str | None) -> bool:
return not (announced and announced != title) and total <= limit
with target.open("wb") as handle:
written = await self._copy(response, handle, interval, title)
written = await radio.copy_stream(response, handle, interval, keep_going)
self.storage.update_media(media_id, state="ready", size=written)
except Exception as error:
self.storage.update_media(media_id, state="failed", error=str(error)[:200])
finally:
self.recording = None
async def _copy(self, response, handle, interval: int, wanted: str) -> int:
"""Retire les blocs ICY du flux : laissés dedans, ils rendraient le fichier illisible."""
if interval <= 0:
total = 0
async for chunk in response.aiter_bytes(32768):
handle.write(chunk)
total += len(chunk)
if total > 40_000_000: # garde-fou : 40 Mo sans annonce de fin
break
return total
buffer = bytearray()
total = 0
async for chunk in response.aiter_bytes(32768):
buffer.extend(chunk)
while len(buffer) >= interval + 1:
length = buffer[interval] * 16
if len(buffer) < interval + 1 + length:
break
handle.write(bytes(buffer[:interval]))
total += interval
meta = bytes(buffer[interval + 1:interval + 1 + length]).decode("utf-8", "replace")
del buffer[:interval + 1 + length]
announced = radio.STREAM_TITLE.search(meta)
if announced and announced.group(1).strip() and announced.group(1).strip() != wanted:
return total # le morceau est terminé
if total > 60_000_000:
return total
return total
def _now() -> int:
return int(time.time() * 1000)