diff --git a/.gitignore b/.gitignore index 609afc2..8142a20 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ keystore.properties *.apk update-token.properties app/src/main/assets/CHANGELOG.md +server/venv/ +server/data/ +server/.env +server/**/__pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f71962..eefed52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,59 @@ 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.0.0] — 2026-09-13 + +Noxely se sépare en deux : un **serveur** qui collecte, un **mobile** qui écoute. Tout ce qui +tenait le téléphone éveillé a migré ailleurs — c'est la raison d'être de cette version. + +### Ajouté + +- **Serveur** (`server/`, Python + FastAPI + SQLite). Il porte désormais la recherche dans les + catalogues, le guet des morceaux sur les radios, l'enregistrement des directs et la file de + téléchargement. Une interface web permet de le piloter depuis un navigateur : recherche, guets, + file et bibliothèque. +- **Recherche simultanée sur toutes les sources.** Les quatre catalogues sont interrogés en + parallèle et les résultats fusionnés, chacun étiqueté de son origine, les plus légers en tête. + Un catalogue en panne n'empêche pas les autres de répondre. +- **Balayage radio continu.** Un tour enchaîne sur le suivant au lieu d'attendre trois minutes : + un morceau guetté a été capté en moins de vingt-cinq secondes à la mesure. Une sonde ne lit que + le premier bloc de métadonnées puis referme, ce qui rend la permanence tenable là où garder + quarante flux ouverts coûterait plusieurs mégabits par seconde. +- **Synchronisation client/serveur.** Le mobile demande ce qui a changé depuis son curseur, + télécharge les fichiers prêts et se rendort. Toutes les six heures, en Wi-Fi non facturé et + batterie suffisante. +- **Réglages en sous-onglets** sur le mobile : Connexion, Synchronisation, Application. +- Cibles `make server-*` : environnement, lancement, service systemd, jeton, journal. + +### Changé + +- **L'application Android devient un client de synchronisation.** Recherche, catalogues, radio, + guets et enregistrement quittent le téléphone : vingt fichiers supprimés. Restent la + bibliothèque, la lecture, les playlists, le hors-ligne et les réglages. +- L'onglet Sources devient Réglages. + +### Corrigé + +- Le jeton du serveur était régénéré à chaque démarrage : il aurait fallu reconfigurer le mobile + après le moindre redémarrage. Il est désormais conservé dans `data/token`, en lecture seule pour + son propriétaire. +- Wikimedia Commons répondait 403 : sa politique refuse les agents génériques. L'agent annonce + maintenant le projet et son dépôt — 28 résultats là où il n'y en avait aucun. L'erreur était + masquée par la fusion des sources, et n'est apparue qu'en interrogeant la source seule. +- `NOXELY_DATA` pointait par défaut sur `/var/lib/noxely`, inaccessible au compte qui lance + `make server-run` : le défaut devient `./data`, et l'unité systemd crée elle-même son dossier + d'état. Un dossier inutilisable donne désormais une phrase, plus une trace Python. +- La lecture des métadonnées ICY n'itérait rien côté serveur : `iter_bytes` au lieu de + `aiter_bytes` sur un client asynchrone. Trente-six stations écoutées, zéro titre lu. +- Une coupure de l'annuaire Radio Browser faisait échouer tout le balayage : une seconde tentative + est faite, et l'erreur renvoyée est lisible. + +### Migration + +Le mobile seul ne sert plus à rien : il faut installer le serveur (`make server-venv`, puis +`make server-install`), relever son jeton (`make server-token`) et le saisir dans Réglages ▸ +Connexion. Consultée depuis la machine du serveur, l'interface web se remplit seule. + ## [1.7.0] — 2026-09-13 ### Ajouté diff --git a/Makefile b/Makefile index b519d88..3b10221 100644 --- a/Makefile +++ b/Makefile @@ -86,6 +86,64 @@ logcat: ## Suit les journaux de l'application (Ctrl+C pour arrêter) crash: ## Affiche la dernière trace de plantage éventuelle @$(ADB) logcat -d -v brief | grep -A 30 -E "FATAL EXCEPTION" | tail -40 || echo "Aucun plantage enregistré" +##@ 🖥️ Serveur + +SERVER := server +SERVER_PY := $(SERVER)/venv/bin/python +SERVICE := noxely-server +UNIT_FILE := $(SERVER)/deploy/$(SERVICE).service +UNIT_DEST := /etc/systemd/system/$(SERVICE).service +PROJECT_DIR := $(abspath .) +SERVICE_USER ?= $(shell whoami) + +.PHONY: server-venv +server-venv: ## Crée l'environnement Python du serveur et installe ses dépendances + python3 -m venv $(SERVER)/venv + $(SERVER)/venv/bin/pip install -q -r $(SERVER)/requirements.txt + @echo "→ $(SERVER_PY)" + +.PHONY: server-token +server-token: ## Affiche le jeton du serveur (à recopier dans l'application) + @sed -n 's/^NOXELY_TOKEN=//p' $(SERVER)/.env 2>/dev/null | grep . \ + || cat $(SERVER)/data/token 2>/dev/null \ + || echo "Aucun jeton : lancez le serveur une fois, il en génère un." + +.PHONY: server-run +server-run: ## Lance le serveur au premier plan (Ctrl+C pour arrêter) + @test -f $(SERVER)/.env || { echo "❌ $(SERVER)/.env absent — copiez $(SERVER)/.env.example"; exit 1; } + cd $(SERVER) && set -a && . ./.env && set +a && ./venv/bin/python -m noxely_server + +.PHONY: server-install +server-install: ## Installe et active le service systemd (sudo requis) + @test -f $(SERVER)/.env || { echo "❌ $(SERVER)/.env absent — copiez $(SERVER)/.env.example"; exit 1; } + sed -e 's|__SERVICE_USER__|$(SERVICE_USER)|g' -e 's|@USER@|$(SERVICE_USER)|g' \ + -e 's|@PROJECT@|$(PROJECT_DIR)|g' $(UNIT_FILE) | sudo tee $(UNIT_DEST) > /dev/null + sudo systemctl daemon-reload + sudo systemctl enable --now $(SERVICE) + @echo "✅ $(SERVICE) installé et démarré" + +.PHONY: server-start +server-start: ## Démarre le service + sudo systemctl start $(SERVICE) + +.PHONY: server-stop +server-stop: ## Arrête le service + sudo systemctl stop $(SERVICE) + +.PHONY: server-status +server-status: ## État du service + systemctl status $(SERVICE) --no-pager + +.PHONY: server-logs +server-logs: ## Suit le journal du service + journalctl -u $(SERVICE) -f + +.PHONY: server-uninstall +server-uninstall: ## Désactive et retire le service (les données sont conservées) + sudo systemctl disable --now $(SERVICE) || true + sudo rm -f $(UNIT_DEST) + sudo systemctl daemon-reload + ##@ 📤 Publication .PHONY: publish diff --git a/README.md b/README.md index 5a77fc4..8c30447 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 🎧 Noxely -> Mettez la musique et les vidéos **de côté** pendant que vous avez du réseau. Écoutez-les quand vous n'en avez plus. +> Un serveur cherche, guette les radios et enregistre. Le téléphone ne fait que récupérer les +> fichiers prêts — et garde sa batterie. [![Platform](https://img.shields.io/badge/Plateforme-Android%208.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com) [![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-1.7.0-6B4EFF)](VERSION) +[![Version](https://img.shields.io/badge/Version-2.0.0-6B4EFF)](VERSION) [![Hors ligne](https://img.shields.io/badge/Hors%20ligne-100%25-3FD8C0)](#-comment-ça-marche) Noxely est un lecteur audio et vidéo pensé pour les trajets, les tunnels, les avions et les @@ -23,41 +24,53 @@ sans la moindre connexion. --- -## ✨ Fonctionnalités +## 🏗️ Architecture + +``` + SERVEUR (Python / FastAPI, sur votre machine) + ├── cherche dans les catalogues libres + ├── guette les morceaux sur les radios + ├── enregistre les directs, titrage ICY compris + └── tient les fichiers prêts + │ HTTP + jeton + ▼ + MOBILE (Android / Compose) + └── demande ce qui est neuf, télécharge, lit + Wi-Fi non facturé · batterie suffisante · toutes les 6 h +``` + +Tout ce qui écoutait en continu a migré sur le serveur. Le téléphone dort entre deux +synchronisations : c'est là qu'est l'économie. + +## ✨ Ce que fait le serveur | | | |---|---| -| 🎵 **Audio et vidéo** | Un seul lecteur, une seule file d'attente, la vidéo bascule sur une surface dédiée | -| 📥 **Mise en cache explicite** | Rien n'est téléchargé sans un geste : vous décidez quoi garder | -| 🔍 **Parcours des sources** | On entre dans une source, on coche ce qu'on veut, on télécharge en lot | -| 🎧 **Écoute avant téléchargement** | Chaque piste s'essaie en ligne d'un appui, sans rien mettre en cache | -| 🧾 **Choix piste par piste** | Un album ou un livre affiche ses pistes avec leur taille : on n'emporte que ce qu'on veut | -| 🌍 **Filtrage par langue** | Français, anglais, allemand, espagnol, italien, néerlandais | -| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre, lecture enchaînée | -| 🎼 **Catalogues libres de droits** | Recherche intégrée dans Internet Archive et LibriVox, sans compte ni clé d'API | -| 📻 **Podcasts RSS** | Un flux, ses épisodes, et le choix de ceux qu'on emporte | -| 🔗 **Liens directs** | N'importe quelle URL audio ou vidéo, y compris un serveur média auto-hébergé | -| 📱 **Contenu de l'appareil** | Lecture de la bibliothèque MediaStore, déjà disponible hors connexion par nature | -| 🎛️ **Lecture en arrière-plan** | Notification système, casque, Bluetooth, écran verrouillé | -| ⏯️ **Contrôles complets** | Lecture, pause, arrêt, piste précédente et suivante, ±15 s | -| 🌙 **Thème clair et sombre** | Material 3, palette de marque, contraste garanti | -| 🔁 **Reprise automatique** | Réseau coupé : mise en attente et reprise. Échec serveur : relance seule après 5 s, 15 s, 45 s | -| 📻 **Radio** | Les stations les plus écoutées, en direct — et enregistrables d'un appui | -| 🏷️ **Titrage automatique** | L'enregistrement prend le nom du morceau annoncé par la station | -| ℹ️ **Page « À propos »** | Fonctions, garanties, pile technique, journal et auteur | -| 👀 **Titre en cours** | Ce que diffuse chaque station, lu dans son flux | -| ⏰ **Guet d'un titre** | L'enregistrement se déclenche quand le morceau voulu passe, et s'arrête à sa fin | -| 📡 **Guet sur toutes les stations** | Nommez un morceau : Noxely balaie l'annuaire jusqu'à le trouver | -| 🖼️ **Pochettes automatiques** | Les titres sans image en reçoivent une, cherchée sur leur nom | -| 🆕 **Mise à jour intégrée** | Surveille les releases du dépôt, télécharge l'APK en Wi-Fi et propose l'installation | +| 🔎 **Recherche** | Internet Archive, Underground (netlabels + Free Music Archive), LibriVox, Wikimedia Commons | +| 🧾 **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 | +| ⏳ **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 | + +## 📱 Ce que fait le mobile + +| | | +|---|---| +| 🔄 **Synchronisation sobre** | Toutes les 6 h, en Wi-Fi non facturé et batterie suffisante | +| 🎵 **Lecture** | Audio et vidéo, arrière-plan, casque, écran verrouillé, ±15 s, stop | +| 📚 **Bibliothèque** | Médias reçus du serveur et contenu déjà présent sur l'appareil | +| 📋 **Playlists** | Listes nommées, appui long pour y ajouter un titre | +| 🖼️ **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 | --- -## 📦 Sources disponibles +## 📦 Sources interrogées par le serveur | Source | État | Détail | |---|:---:|---| -| 📱 Appareil | ✅ | Musique et vidéos MediaStore | +| 📱 Appareil | ✅ | Musique et vidéos déjà sur le téléphone (côté mobile) | | 🔗 Lien direct | ✅ | URL `http(s)` vers un fichier audio ou vidéo | | 📻 Podcast RSS | ✅ | Flux standard avec `` | | 🏛️ Internet Archive | ✅ | Domaine public / Creative Commons | diff --git a/VERSION b/VERSION index bd8bf88..227cea2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 +2.0.0 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b3ddcc..ffc8c79 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -59,16 +59,6 @@ - - - - = listOf( - ArchiveOrgCatalog(), - ArchiveOrgCatalog( - id = CatalogId.UNDERGROUND, - collections = listOf("netlabels", "freemusicarchive"), - ), - LibriVoxCatalog(rssImporter), - WikimediaCatalog(), - ).associateBy { it.id } - /** - * Résultat de catalogue en cours de consultation. - * Un identifiant de route ne suffirait pas à le reconstituer sans un appel réseau de plus. - */ - var openedCatalogItem: CatalogItem? = null val player = PlayerController(context, scope) { id -> library.entry(id) } @@ -82,6 +58,6 @@ class NoxelyApp : Application() { super.onCreate() container = AppContainer(this) UpdateCheckWorker.schedule(this) - WatchScanner.schedule(this) + SyncWorker.schedule(this) } } diff --git a/app/src/main/java/fr/tuxgyver/noxely/model/Media.kt b/app/src/main/java/fr/tuxgyver/noxely/model/Media.kt index 7e6515c..8791d97 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/model/Media.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/model/Media.kt @@ -10,6 +10,7 @@ enum class SourceKind(val label: String) { URL("Lien direct"), PODCAST("Podcast"), RECORDING("Enregistrement"), + SERVER("Serveur"), } /** @@ -33,5 +34,5 @@ data class MediaEntry( val sizeBytes: Long? = null, ) { /** Local ou enregistré : le fichier est déjà sur l'appareil, il n'y a rien à télécharger. */ - val isRemote: Boolean get() = source != SourceKind.LOCAL && source != SourceKind.RECORDING + val isRemote: Boolean get() = source == SourceKind.URL || source == SourceKind.PODCAST } diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/RadioDirectory.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/RadioDirectory.kt deleted file mode 100644 index 5c50c8d..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/RadioDirectory.kt +++ /dev/null @@ -1,127 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import fr.tuxgyver.noxely.sources.openConnection -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import java.net.URLEncoder - -/** - * Annuaire des stations, via l'API communautaire Radio Browser (sans clé). - * - * Les flux HLS sont écartés : ils se lisent, mais ne s'enregistrent pas en copiant les octets, - * et proposer un bouton qui échouerait vaudrait moins que ne pas le proposer. - */ -class RadioDirectory { - - private val json = Json { ignoreUnknownKeys = true } - - /** Les stations les plus écoutées, éventuellement restreintes à un genre. */ - suspend fun popular(genre: String? = null): Result> = - query(buildString { - append("?limit=60&hidebroken=true&order=clickcount&reverse=true") - genre?.let { append("&tag=${URLEncoder.encode(it, "UTF-8")}") } - }) - - suspend fun search(name: String): Result> = - query("?limit=60&hidebroken=true&order=clickcount&reverse=true" + - "&name=${URLEncoder.encode(name.trim(), "UTF-8")}") - - /** - * Le morceau diffusé à l'instant par une station. - * - * Aucun annuaire ne l'expose : il faut ouvrir le flux, lire le premier bloc de métadonnées - * ICY, puis refermer. C'est une connexion par station — d'où l'appel par petits paquets et - * la mise en cache par l'appelant plutôt qu'un balayage systématique. - */ - suspend fun nowPlaying(streamUrl: String): String? = withContext(Dispatchers.IO) { - runCatching { - val connection = openConnection(streamUrl, readTimeoutMs = 8_000).apply { - setRequestProperty("Icy-MetaData", "1") - } - try { - if (connection.responseCode !in 200..299) return@runCatching null - val interval = connection.getHeaderFieldInt("icy-metaint", 0) - if (interval <= 0) return@runCatching null - - connection.inputStream.use { input -> - val skip = ByteArray(interval) - var filled = 0 - while (filled < interval) { - val read = input.read(skip, filled, interval - filled) - if (read < 0) return@runCatching null - filled += read - } - val length = input.read().takeIf { it >= 0 }?.times(16) ?: return@runCatching null - if (length == 0) return@runCatching null - val meta = ByteArray(length) - filled = 0 - while (filled < length) { - val read = input.read(meta, filled, length - filled) - if (read < 0) return@runCatching null - filled += read - } - STREAM_TITLE.find(String(meta, Charsets.UTF_8)) - ?.groupValues?.get(1)?.trim()?.takeIf { it.isNotBlank() } - } - } finally { - connection.disconnect() - } - }.getOrNull() - } - - private suspend fun query(suffix: String): Result> = withContext(Dispatchers.IO) { - runCatching { - val connection = openConnection("$BASE$suffix", readTimeoutMs = 30_000) - val body = try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("L'annuaire a répondu ${connection.responseCode}") - } - connection.inputStream.bufferedReader().readText() - } finally { - connection.disconnect() - } - - (json.parseToJsonElement(body) as? JsonArray).orEmpty() - .mapNotNull { it as? JsonObject } - .filter { it.number("hls") == 0L } - .mapNotNull { station -> - val url = station.string("url_resolved")?.takeIf { it.startsWith("http") } - ?: return@mapNotNull null - RadioStation( - id = station.string("stationuuid") ?: url, - name = station.string("name")?.trim() ?: "Station", - streamUrl = url, - codec = station.string("codec").orEmpty(), - bitrate = station.number("bitrate")?.toInt() ?: 0, - country = station.string("country").orEmpty(), - tags = station.string("tags").orEmpty(), - faviconUrl = station.string("favicon")?.takeIf { it.startsWith("http") }, - ) - } - .distinctBy { it.streamUrl } - } - } - - private companion object { - const val BASE = "https://de1.api.radio-browser.info/json/stations/search" - - /** Le titre court jusqu'à la fin du champ ICY : les apostrophes y sont fréquentes. */ - val STREAM_TITLE = Regex("StreamTitle='(.*?)';") - - /** Genres proposés d'emblée, parmi les plus fournis de l'annuaire. */ - fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList()) - - fun JsonObject.string(key: String): String? = - (this[key] as? JsonPrimitive)?.content?.takeIf { it.isNotBlank() && it != "null" } - - fun JsonObject.number(key: String): Long? = - (this[key] as? JsonPrimitive)?.content?.substringBefore('.')?.toLongOrNull() - } -} - -/** Genres offerts en filtre rapide. */ -val RADIO_GENRES = listOf("pop", "rock", "jazz", "classical", "electronic", "news", "chanson") diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/RadioStation.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/RadioStation.kt deleted file mode 100644 index 18d881c..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/RadioStation.kt +++ /dev/null @@ -1,20 +0,0 @@ -package fr.tuxgyver.noxely.radio - -/** Une station de radio et son flux direct. */ -data class RadioStation( - val id: String, - val name: String, - val streamUrl: String, - val codec: String, - val bitrate: Int, - val country: String, - val tags: String, - val faviconUrl: String?, -) { - val subtitle: String - get() = listOfNotNull( - country.takeIf { it.isNotBlank() }, - tags.split(',').firstOrNull()?.trim()?.takeIf { it.isNotBlank() }?.replaceFirstChar(Char::uppercase), - bitrate.takeIf { it > 0 }?.let { "$it kbps" }, - ).joinToString(" · ") -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/Recorder.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/Recorder.kt deleted file mode 100644 index 81caf8d..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/Recorder.kt +++ /dev/null @@ -1,54 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -data class RecordingStatus( - val station: RadioStation? = null, - val startedAt: Long = 0L, - val bytes: Long = 0L, - /** Titre annoncé par le flux, quand la station en diffuse un. */ - val currentTitle: String? = null, - /** Titre attendu avant de commencer à écrire, si l'enregistrement a été programmé. */ - val waitingFor: String? = null, -) { - val isRecording: Boolean get() = station != null - val elapsedMs: Long get() = if (startedAt == 0L) 0L else System.currentTimeMillis() - startedAt -} - -/** - * État de l'enregistrement en cours. - * - * Seul [RecordingService] y écrit ; l'interface ne fait que l'observer. Un singleton se justifie - * ici parce que l'enregistrement est unique par nature : on n'enregistre qu'une station à la fois. - */ -object Recorder { - private val _status = MutableStateFlow(RecordingStatus()) - val status: StateFlow = _status.asStateFlow() - - internal fun started(station: RadioStation, waitingFor: String? = null) { - _status.value = RecordingStatus(station, System.currentTimeMillis(), 0L, waitingFor = waitingFor) - } - - /** Le morceau attendu commence : l'écriture démarre maintenant. */ - internal fun armed(title: String) { - _status.value = _status.value.copy( - waitingFor = null, - currentTitle = title, - startedAt = System.currentTimeMillis(), - ) - } - - internal fun progress(bytes: Long) { - _status.value = _status.value.copy(bytes = bytes) - } - - internal fun title(title: String) { - _status.value = _status.value.copy(currentTitle = title) - } - - internal fun stopped() { - _status.value = RecordingStatus() - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/RecordingService.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/RecordingService.kt deleted file mode 100644 index 48dc1a9..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/RecordingService.kt +++ /dev/null @@ -1,380 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import fr.tuxgyver.noxely.NoxelyApp -import fr.tuxgyver.noxely.MainActivity -import fr.tuxgyver.noxely.R -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import fr.tuxgyver.noxely.sources.openConnection -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.io.File -import java.io.FileOutputStream -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Enregistre un flux radio en copiant ses octets dans un fichier. - * - * Un direct n'a pas de fin : `DownloadManager`, qui attend une taille et une complétion, ne peut - * pas s'en charger. L'enregistrement tourne donc en service de premier plan jusqu'à ce que - * l'utilisateur l'arrête, et le fichier obtenu rejoint la bibliothèque comme média local. - */ -class RecordingService : Service() { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var job: Job? = null - private var sink: RecordingSink? = null - private var sinkTitle: String = "" - - private var station: RadioStation? = null - private var firstTitle: String? = null - private var trigger: String? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - when (intent?.action) { - ACTION_STOP -> { - finish() - return START_NOT_STICKY - } - - ACTION_START -> { - val incoming = intent.toStation() ?: run { - stopSelf() - return START_NOT_STICKY - } - if (job?.isActive == true) return START_STICKY - trigger = intent.getStringExtra(EXTRA_TRIGGER)?.takeIf { it.isNotBlank() } - begin(incoming) - } - } - return START_STICKY - } - - private fun begin(incoming: RadioStation) { - station = incoming - firstTitle = null - Recorder.started(incoming, trigger) - startForegroundCompat(notification(incoming, 0L)) - - val stamp = SimpleDateFormat("yyyy-MM-dd-HHmm", Locale.FRANCE).format(Date()) - val extension = incoming.extension() - sinkTitle = "${incoming.name} — ${SimpleDateFormat("d MMM HH:mm", Locale.FRANCE).format(Date())}" - val output = RecordingSink( - context = this, - fileName = "${incoming.name.sanitized()}-$stamp.$extension", - mimeType = if (extension == "aac") "audio/aac" else "audio/mpeg", - ) - sink = output - - job = scope.launch { - runCatching { - // « Icy-MetaData: 1 » demande à la station d'intercaler le titre en cours dans le - // flux. Ces octets ne sont pas de l'audio : ils doivent être retirés avant écriture, - // sinon le fichier obtenu est corrompu. - val connection = openConnection(incoming.streamUrl, readTimeoutMs = 30_000).apply { - setRequestProperty("Icy-MetaData", "1") - } - try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("Flux indisponible (${connection.responseCode})") - } - val metaInterval = connection.getHeaderFieldInt("icy-metaint", 0) - connection.inputStream.use { input -> - output.open().use { stream -> - if (metaInterval > 0) { - copyWithTitles(input, stream, metaInterval, incoming) - } else if (trigger != null) { - // Sans métadonnées, impossible de savoir quand le morceau passe. - throw IllegalStateException( - "${incoming.name} n'annonce pas ses titres : l'attente est impossible" - ) - } else { - copyPlain(input, stream, incoming) - } - stream.flush() - } - } - } finally { - connection.disconnect() - } - } - // Fin du flux ou coupure réseau : ce qui a été capté reste valable. - finish() - } - } - - /** Flux sans métadonnées : simple recopie. */ - private suspend fun copyPlain( - input: java.io.InputStream, - output: java.io.OutputStream, - station: RadioStation, - ) { - val buffer = ByteArray(32 * 1024) - var total = 0L - var lastNotified = 0L - while (currentCoroutineContext().isActive) { - val read = input.read(buffer) - if (read <= 0) break - output.write(buffer, 0, read) - total += read - if (total - lastNotified > NOTIFY_EVERY_BYTES) { - lastNotified = total - Recorder.progress(total) - notifyProgress(station, total) - } - } - Recorder.progress(total) - } - - /** - * Flux ICY : `interval` octets d'audio, puis un bloc de métadonnées à retirer. - * Le premier titre rencontré nomme l'enregistrement ; les suivants alimentent la notification. - */ - private suspend fun copyWithTitles( - input: java.io.InputStream, - output: java.io.OutputStream, - interval: Int, - station: RadioStation, - ) { - val wanted = trigger - val audio = ByteArray(interval) - var total = 0L - var lastNotified = 0L - // Sans titre attendu, on écrit dès la première seconde ; sinon on patiente. - var writing = wanted == null - var capturedTitle: String? = null - - while (currentCoroutineContext().isActive) { - if (!input.readFully(audio, interval)) break - if (writing) { - output.write(audio) - total += interval - } - - val lengthByte = input.read() - if (lengthByte < 0) break - val metaLength = lengthByte * 16 - if (metaLength > 0) { - val meta = ByteArray(metaLength) - if (!input.readFully(meta, metaLength)) break - val announced = STREAM_TITLE - .find(String(meta, Charsets.UTF_8)) - ?.groupValues?.get(1) - ?.trim() - ?.takeIf { it.isNotBlank() } - - if (announced != null) { - Recorder.title(announced) - when { - wanted == null -> if (firstTitle == null) firstTitle = announced - - !writing && announced.contains(wanted, ignoreCase = true) -> { - // Le morceau attendu commence : on enregistre à partir d'ici. - writing = true - capturedTitle = announced - firstTitle = announced - Recorder.armed(announced) - } - - writing && capturedTitle != null && announced != capturedTitle -> { - // Le morceau est terminé : on s'arrête sans attendre le suivant. - break - } - } - notifyProgress(station, total) - } - } - - if (total - lastNotified > NOTIFY_EVERY_BYTES) { - lastNotified = total - Recorder.progress(total) - notifyProgress(station, total) - } - } - Recorder.progress(total) - } - - /** Lit exactement [count] octets, ou renvoie false si le flux s'interrompt avant. */ - private fun java.io.InputStream.readFully(target: ByteArray, count: Int): Boolean { - var filled = 0 - while (filled < count) { - val read = read(target, filled, count - filled) - if (read < 0) return false - filled += read - } - return true - } - - /** Clôt l'enregistrement et verse le fichier obtenu dans la bibliothèque. */ - private fun finish() { - job?.cancel() - job = null - - val output = sink - val recorded = station - val captured = firstTitle - sink = null - station = null - trigger = null - - if (output != null && recorded != null) { - val elapsed = Recorder.status.value.elapsedMs - // Le titre annoncé par la station prime : c'est lui qu'on cherchait en enregistrant. - val title = captured ?: sinkTitle - val published = output.finish(title, elapsed) - if (published != null) { - val (uri, size) = published - val container = (application as NoxelyApp).container - val entry = MediaEntry( - id = "rec:${uri.hashCode()}", - source = SourceKind.RECORDING, - kind = MediaKind.AUDIO, - title = title, - subtitle = if (captured != null) recorded.name else "Enregistrement radio", - uri = uri, - artworkUri = recorded.faviconUrl, - durationMs = elapsed, - addedAt = System.currentTimeMillis(), - sizeBytes = size, - ) - CoroutineScope(Dispatchers.IO).launch { container.imported.add(listOf(entry)) } - } - } - - firstTitle = null - Recorder.stopped() - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onDestroy() { - scope.cancel() - super.onDestroy() - } - - private fun startForegroundCompat(notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun notifyProgress(station: RadioStation, bytes: Long) { - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, notification(station, bytes)) - } - - private fun notification(station: RadioStation, bytes: Long): Notification { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - getSystemService(NotificationManager::class.java).createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Enregistrement radio", NotificationManager.IMPORTANCE_LOW) - ) - } - val open = PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val stop = PendingIntent.getService( - this, - 1, - Intent(this, RecordingService::class.java).setAction(ACTION_STOP), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_offline) - .setContentTitle( - if (Recorder.status.value.waitingFor != null) "En attente sur ${station.name}" - else "Enregistrement de ${station.name}" - ) - .setContentText( - Recorder.status.value.currentTitle - ?: if (bytes > 0) "%.1f Mo captés".format(bytes / 1_000_000.0) else "Démarrage…" - ) - .setContentIntent(open) - .addAction(0, "Arrêter", stop) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - private fun Intent.toStation(): RadioStation? { - val url = getStringExtra(EXTRA_URL) ?: return null - return RadioStation( - id = getStringExtra(EXTRA_ID).orEmpty(), - name = getStringExtra(EXTRA_NAME) ?: "Radio", - streamUrl = url, - codec = getStringExtra(EXTRA_CODEC).orEmpty(), - bitrate = getIntExtra(EXTRA_BITRATE, 0), - country = "", - tags = "", - faviconUrl = getStringExtra(EXTRA_FAVICON), - ) - } - - private fun RadioStation.extension(): String = - if (codec.equals("AAC", ignoreCase = true) || codec.startsWith("AAC", true)) "aac" else "mp3" - - private fun String.sanitized(): String = - replace(Regex("[^\\p{L}\\p{N} _-]"), "").trim().replace(' ', '_').take(40).ifBlank { "radio" } - - companion object { - private const val ACTION_START = "fr.tuxgyver.noxely.RECORD_START" - private const val ACTION_STOP = "fr.tuxgyver.noxely.RECORD_STOP" - private const val EXTRA_ID = "id" - private const val EXTRA_NAME = "name" - private const val EXTRA_URL = "url" - private const val EXTRA_CODEC = "codec" - private const val EXTRA_BITRATE = "bitrate" - private const val EXTRA_FAVICON = "favicon" - private const val EXTRA_TRIGGER = "trigger" - private const val NOTIFICATION_ID = 4401 - private const val CHANNEL_ID = "cachette_recording" - private const val NOTIFY_EVERY_BYTES = 256 * 1024L - private const val MIN_USEFUL_BYTES = 16 * 1024L - private val STREAM_TITLE = Regex("StreamTitle='(.*?)';") - - fun start(context: Context, station: RadioStation, whenTitleContains: String? = null) { - val intent = Intent(context, RecordingService::class.java) - .setAction(ACTION_START) - .putExtra(EXTRA_ID, station.id) - .putExtra(EXTRA_NAME, station.name) - .putExtra(EXTRA_URL, station.streamUrl) - .putExtra(EXTRA_CODEC, station.codec) - .putExtra(EXTRA_BITRATE, station.bitrate) - .putExtra(EXTRA_FAVICON, station.faviconUrl) - .putExtra(EXTRA_TRIGGER, whenTitleContains) - context.startForegroundService(intent) - } - - fun stop(context: Context) { - context.startService( - Intent(context, RecordingService::class.java).setAction(ACTION_STOP) - ) - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt deleted file mode 100644 index 41745e4..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchScanner.kt +++ /dev/null @@ -1,101 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import android.content.Context -import androidx.work.Constraints -import androidx.work.CoroutineWorker -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.NetworkType -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkManager -import androidx.work.WorkerParameters -import fr.tuxgyver.noxely.NoxelyApp -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import java.util.concurrent.TimeUnit - -/** - * Résultat d'un balayage, tel que l'interface le rapporte. - */ -data class ScanOutcome( - val stationsProbed: Int, - val titlesRead: Int, - val matched: String?, - val matchedOn: String?, -) - -/** - * Passe les stations en revue à la recherche des morceaux guettés. - * - * Chaque station doit être ouverte pour livrer son titre : le balayage est donc borné aux - * stations les plus écoutées, par paquets, et non lancé en continu. - * - * ⚠️ Un morceau dure trois à quatre minutes, et WorkManager n'autorise pas de tâche périodique - * plus fréquente qu'un quart d'heure. Le balayage automatique ne peut donc pas tout attraper : - * il repère ce qui passe au moment où il regarde. Le balayage manuel existe pour cela. - */ -class WatchScanner( - private val directory: RadioDirectory, - private val store: WatchStore, -) { - suspend fun scan(context: Context, stationLimit: Int = STATION_LIMIT): ScanOutcome { - val pending = store.pending() - if (pending.isEmpty()) return ScanOutcome(0, 0, null, null) - - val stations = directory.popular().getOrNull()?.take(stationLimit).orEmpty() - var titles = 0 - - stations.chunked(CONCURRENCY).forEach { group -> - val heard = coroutineScope { - group.map { station -> async { station to directory.nowPlaying(station.streamUrl) } } - .awaitAll() - } - for ((station, title) in heard) { - if (title == null) continue - titles++ - val hit = pending.firstOrNull { title.contains(it.query, ignoreCase = true) } - ?: continue - - // Un seul enregistrement à la fois : inutile d'en réclamer un second. - if (!Recorder.status.value.isRecording) { - RecordingService.start(context, station, whenTitleContains = hit.query) - } - store.markFound(hit.id, station.name, title) - store.markScanned(pending.map { it.id }) - return ScanOutcome(stations.size, titles, title, station.name) - } - } - - store.markScanned(pending.map { it.id }) - return ScanOutcome(stations.size, titles, null, null) - } - - companion object { - private const val STATION_LIMIT = 40 - private const val CONCURRENCY = 4 - private const val WORK_NAME = "noxely-watch-scan" - - /** Un quart d'heure est le minimum autorisé pour une tâche périodique. */ - fun schedule(context: Context) { - val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) - .setConstraints( - Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build() - ) - .build() - WorkManager.getInstance(context).enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.KEEP, - request, - ) - } - } -} - -class WatchWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - override suspend fun doWork(): Result { - val container = (applicationContext as NoxelyApp).container - if (container.watches.pending().isEmpty()) return Result.success() - runCatching { container.watchScanner.scan(applicationContext) } - return Result.success() - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt deleted file mode 100644 index 926fd54..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchService.kt +++ /dev/null @@ -1,144 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import android.app.Notification -import android.app.NotificationChannel -import android.app.NotificationManager -import android.app.PendingIntent -import android.app.Service -import android.content.Context -import android.content.Intent -import android.content.pm.ServiceInfo -import android.os.Build -import android.os.IBinder -import androidx.core.app.NotificationCompat -import fr.tuxgyver.noxely.MainActivity -import fr.tuxgyver.noxely.NoxelyApp -import fr.tuxgyver.noxely.R -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch - -/** - * Veille active : balaie les stations en continu tant qu'un morceau est guetté. - * - * La tâche périodique du système ne descend pas sous le quart d'heure, quand un morceau dure - * trois à quatre minutes : elle ne peut donc qu'en manquer. Cette veille tourne en service de - * premier plan, visible et interruptible, et s'arrête d'elle-même quand il n'y a plus rien à - * guetter — il n'y a pas de raison de consommer pour rien. - */ -class WatchService : Service() { - - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - private var job: Job? = null - - override fun onBind(intent: Intent?): IBinder? = null - - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - if (intent?.action == ACTION_STOP) { - finish() - return START_NOT_STICKY - } - if (job?.isActive == true) return START_STICKY - - val container = (application as NoxelyApp).container - running.value = true - startForegroundCompat(notification(container.watches.pending().size, null)) - - job = scope.launch { - while (isActive) { - val pending = container.watches.pending() - if (pending.isEmpty()) break - - val outcome = runCatching { container.watchScanner.scan(applicationContext) }.getOrNull() - notify(container.watches.pending().size, outcome?.matched) - - if (container.watches.pending().isEmpty()) break - delay(SWEEP_INTERVAL_MS) - } - finish() - } - return START_STICKY - } - - private fun finish() { - job?.cancel() - job = null - running.value = false - stopForeground(STOP_FOREGROUND_REMOVE) - stopSelf() - } - - override fun onDestroy() { - running.value = false - scope.cancel() - super.onDestroy() - } - - private fun startForegroundCompat(notification: Notification) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) - } else { - startForeground(NOTIFICATION_ID, notification) - } - } - - private fun notify(pending: Int, lastMatch: String?) { - getSystemService(NotificationManager::class.java) - .notify(NOTIFICATION_ID, notification(pending, lastMatch)) - } - - private fun notification(pending: Int, lastMatch: String?): Notification { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - getSystemService(NotificationManager::class.java).createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Veille radio", NotificationManager.IMPORTANCE_LOW) - ) - } - val open = PendingIntent.getActivity( - this, 0, - Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - val stop = PendingIntent.getService( - this, 1, - Intent(this, WatchService::class.java).setAction(ACTION_STOP), - PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, - ) - return NotificationCompat.Builder(this, CHANNEL_ID) - .setSmallIcon(R.drawable.ic_offline) - .setContentTitle("Veille radio · $pending morceau(x) guetté(s)") - .setContentText(lastMatch?.let { "Dernier capté : $it" } ?: "Balayage toutes les 3 minutes") - .setContentIntent(open) - .addAction(0, "Arrêter", stop) - .setOngoing(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .build() - } - - companion object { - private const val ACTION_STOP = "fr.tuxgyver.noxely.WATCH_STOP" - private const val NOTIFICATION_ID = 4501 - private const val CHANNEL_ID = "noxely_watch" - - /** Trois minutes : assez serré pour croiser un morceau, assez large pour la batterie. */ - private const val SWEEP_INTERVAL_MS = 180_000L - - private val running = MutableStateFlow(false) - val isRunning: StateFlow = running.asStateFlow() - - fun start(context: Context) { - context.startForegroundService(Intent(context, WatchService::class.java)) - } - - fun stop(context: Context) { - context.startService(Intent(context, WatchService::class.java).setAction(ACTION_STOP)) - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt b/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt deleted file mode 100644 index 34dd4a8..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/radio/WatchStore.kt +++ /dev/null @@ -1,90 +0,0 @@ -package fr.tuxgyver.noxely.radio - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext -import kotlinx.serialization.Serializable -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import java.io.File -import java.util.UUID - -/** Un morceau guetté sur l'ensemble des stations. */ -@Serializable -data class Watch( - val id: String, - val query: String, - val createdAt: Long, - /** Renseignés une fois le morceau capté. */ - val foundAt: Long? = null, - val foundOn: String? = null, - val foundTitle: String? = null, - val lastScanAt: Long? = null, - val scans: Int = 0, -) { - val isFound: Boolean get() = foundAt != null -} - -/** Persistance des guets, sur le même principe que les playlists. */ -class WatchStore(context: Context, scope: CoroutineScope) { - - private val file = File(context.filesDir, "watches.json") - private val json = Json { ignoreUnknownKeys = true } - private val mutex = Mutex() - - private val _watches = MutableStateFlow>(emptyList()) - val watches: StateFlow> = _watches.asStateFlow() - - init { - scope.launch { _watches.value = read() } - } - - suspend fun add(query: String) = mutate { current -> - val cleaned = query.trim() - if (cleaned.isBlank() || current.any { it.query.equals(cleaned, true) && !it.isFound }) current - else current + Watch(UUID.randomUUID().toString(), cleaned, System.currentTimeMillis()) - } - - suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } } - - suspend fun markFound(id: String, station: String, title: String) = mutate { current -> - current.map { - if (it.id == id) it.copy(foundAt = System.currentTimeMillis(), foundOn = station, foundTitle = title) - else it - } - } - - suspend fun markScanned(ids: Collection) = mutate { current -> - val now = System.currentTimeMillis() - current.map { if (it.id in ids) it.copy(lastScanAt = now, scans = it.scans + 1) else it } - } - - /** Les guets encore en attente, seuls concernés par un balayage. */ - fun pending(): List = _watches.value.filterNot { it.isFound } - - private suspend fun mutate(transform: (List) -> List) = mutex.withLock { - val updated = transform(_watches.value) - if (updated != _watches.value) { - _watches.value = updated - write(updated) - } - } - - private suspend fun read(): List = withContext(Dispatchers.IO) { - if (!file.exists()) return@withContext emptyList() - runCatching { json.decodeFromString>(file.readText()) }.getOrElse { emptyList() } - } - - private suspend fun write(watches: List) = withContext(Dispatchers.IO) { - val tmp = File(file.parentFile, "${file.name}.tmp") - tmp.writeText(json.encodeToString(watches)) - tmp.renameTo(file) - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/ArchiveOrgCatalog.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/ArchiveOrgCatalog.kt deleted file mode 100644 index c3f3671..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/ArchiveOrgCatalog.kt +++ /dev/null @@ -1,158 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.jsonObject -import java.net.URLEncoder - -/** - * Recherche dans archive.org. - * - * La recherche ne renvoie que des identifiants ; le fichier réellement lisible n'est déterminé - * qu'au moment où l'utilisateur choisit un résultat, pour ne pas interroger les métadonnées de - * trente éléments dont vingt-neuf seront ignorés. - */ -class ArchiveOrgCatalog( - override val id: CatalogId = CatalogId.ARCHIVE, - /** - * Collections auxquelles se restreindre. Vide : tout archive.org. - * `netlabels` et `freemusicarchive` ne contiennent que des publications sous licence libre, - * ce qui donne un fonds indépendant sans les dépôts de titres commerciaux qu'on croise - * ailleurs sur le site. - */ - private val collections: List = emptyList(), -) : Catalog { - - override val languages = COMMON_LANGUAGES - - private val json = Json { ignoreUnknownKeys = true } - - override suspend fun search(query: String, language: String?): Result> = - withContext(Dispatchers.IO) { - runCatching { - // Les éléments « access-restricted » répondent 401 au téléchargement : on les écarte - // dès la recherche plutôt que de les proposer puis d'échouer. - val languageClause = language?.let { " AND language:($it)" }.orEmpty() - val collectionClause = - collections.takeIf { it.isNotEmpty() } - ?.joinToString(" OR ") - ?.let { " AND collection:($it)" } - .orEmpty() - val q = URLEncoder.encode( - "($query) AND mediatype:(audio OR movies)$languageClause$collectionClause" + - " AND NOT access-restricted-item:true", - "UTF-8", - ) - val url = "https://archive.org/advancedsearch.php?q=$q" + - "&fl%5B%5D=identifier&fl%5B%5D=title&fl%5B%5D=creator&fl%5B%5D=mediatype&fl%5B%5D=item_size" + - "&rows=30&page=1&output=json" - - val body = fetch(url) - val docs = json.parseToJsonElement(body).jsonObject["response"] - ?.jsonObject?.get("docs") as? JsonArray - ?: throw IllegalStateException("Réponse inattendue d'archive.org") - - docs.mapNotNull { element -> - val doc = element as? JsonObject ?: return@mapNotNull null - val identifier = doc.text("identifier") ?: return@mapNotNull null - CatalogItem( - id = identifier, - title = doc.text("title") ?: identifier, - subtitle = doc.text("creator") ?: "Internet Archive", - artworkUri = "https://archive.org/services/img/$identifier", - hint = if (doc.text("mediatype") == "movies") "Vidéo" else "Audio", - sizeBytes = doc.text("item_size")?.toLongOrNull(), - ) - } - } - } - - override suspend fun tracks(item: CatalogItem): Result> = withContext(Dispatchers.IO) { - runCatching { - val metadata = json.parseToJsonElement(fetch("https://archive.org/metadata/${item.id}")).jsonObject - - if (metadata["metadata"]?.jsonObject?.text("access-restricted-item") == "true") { - throw IllegalStateException("« ${item.title} » est en accès restreint sur archive.org") - } - - val wantsVideo = item.hint == "Vidéo" - val extensions = if (wantsVideo) VIDEO_EXTENSIONS else AUDIO_EXTENSIONS - val files = (metadata["files"] as? JsonArray).orEmpty().mapNotNull { it as? JsonObject } - - val playable = files.filter { file -> - val name = file.text("name").orEmpty().lowercase() - extensions.any { name.endsWith(it) } - } - if (playable.isEmpty()) { - throw IllegalStateException( - "Aucun fichier ${if (wantsVideo) "vidéo" else "audio"} lisible dans « ${item.title} »" - ) - } - - // Un élément expose l'original et ses dérivés du même morceau. On regroupe par nom de - // base et on garde le plus léger de chaque groupe : autant de pistes que d'œuvres, - // pas autant que de formats. - playable.groupBy { it.text("name")!!.substringBeforeLast('.').lowercase() } - .values - .mapNotNull { variants -> - variants.minByOrNull { it.text("size")?.toLongOrNull() ?: Long.MAX_VALUE } - } - .sortedBy { it.text("name") } - .map { file -> - val name = file.text("name")!! - val seconds = file.text("length")?.substringBefore('.')?.toLongOrNull() ?: 0L - MediaEntry( - id = stableId("ia:", "${item.id}/$name"), - source = SourceKind.URL, - kind = if (wantsVideo) MediaKind.VIDEO else MediaKind.AUDIO, - title = file.text("title") ?: name.substringBeforeLast('.'), - subtitle = item.title, - uri = "https://archive.org/download/${item.id}/${encodePath(name)}", - artworkUri = item.artworkUri, - durationMs = seconds * 1000, - addedAt = System.currentTimeMillis(), - sizeBytes = file.text("size")?.toLongOrNull(), - ) - } - } - } - - /** Encode le nom de fichier sans toucher aux séparateurs de chemin. */ - private fun encodePath(name: String): String = - name.split('/').joinToString("/") { - URLEncoder.encode(it, "UTF-8").replace("+", "%20") - } - - private fun fetch(url: String): String { - val connection = openConnection(url) - return try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("archive.org a répondu ${connection.responseCode}") - } - connection.inputStream.bufferedReader().readText() - } finally { - connection.disconnect() - } - } - - private companion object { - val AUDIO_EXTENSIONS = listOf(".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav") - val VIDEO_EXTENSIONS = listOf(".mp4", ".m4v", ".webm", ".ogv") - - fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList()) - } -} - -/** archive.org renvoie tantôt une chaîne, tantôt un tableau : on prend la première valeur utile. */ -internal fun JsonObject.text(key: String): String? = when (val value = this[key]) { - is JsonPrimitive -> value.content.takeIf { it.isNotBlank() } - is JsonArray -> value.firstOrNull()?.let { (it as? JsonPrimitive)?.content }?.takeIf { it.isNotBlank() } - else -> null -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/Catalog.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/Catalog.kt deleted file mode 100644 index fbd970c..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/Catalog.kt +++ /dev/null @@ -1,77 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import fr.tuxgyver.noxely.model.MediaEntry - -/** Un résultat de recherche : un album, un livre ou un morceau, pas encore un fichier. */ -data class CatalogItem( - val id: String, - val title: String, - val subtitle: String, - val artworkUri: String? = null, - val hint: String = "", - /** Taille de l'ensemble, quand le catalogue l'annonce. */ - val sizeBytes: Long? = null, -) - -/** Langue proposée au filtrage. [code] est la valeur attendue par l'API du catalogue. */ -data class CatalogLanguage(val code: String, val label: String) - -/** Catalogue public interrogeable sans compte ni clé d'API. */ -interface Catalog { - val id: CatalogId - - /** Vide si le catalogue ne distingue pas les langues (la musique instrumentale, typiquement). */ - val languages: List get() = emptyList() - - suspend fun search(query: String, language: String? = null): Result> - - /** - * Les pistes d'un résultat, avec leur taille. - * - * Un album ou un livre en compte plusieurs : c'est à l'utilisateur de choisir lesquelles il - * emporte, pas à l'application de décider pour lui. - */ - suspend fun tracks(item: CatalogItem): Result> -} - -enum class CatalogId( - val title: String, - val description: String, - val license: String, - val suggestion: String, -) { - ARCHIVE( - title = "Internet Archive", - description = "Musique, concerts et films versés au domaine public ou sous licence libre.", - license = "Domaine public / Creative Commons", - suggestion = "Grateful Dead", - ), - LIBRIVOX( - title = "LibriVox", - description = "Livres audio lus par des bénévoles, tous dans le domaine public.", - license = "Domaine public", - suggestion = "Jules Verne", - ), - UNDERGROUND( - title = "Underground", - description = "Netlabels et Free Music Archive : des dizaines de milliers de morceaux d'artistes indépendants, publiés sous licence libre.", - license = "Creative Commons / netlabels", - suggestion = "ambient", - ), - WIKIMEDIA( - title = "Wikimedia Commons", - description = "Enregistrements classiques, archives sonores et musique libre hébergés par Wikimedia.", - license = "Domaine public / Creative Commons", - suggestion = "Chopin", - ), -} - -/** Langues les plus fournies, communes à LibriVox et archive.org. */ -val COMMON_LANGUAGES = listOf( - CatalogLanguage("French", "Français"), - CatalogLanguage("English", "Anglais"), - CatalogLanguage("German", "Allemand"), - CatalogLanguage("Spanish", "Espagnol"), - CatalogLanguage("Italian", "Italien"), - CatalogLanguage("Dutch", "Néerlandais"), -) diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/LibriVoxCatalog.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/LibriVoxCatalog.kt deleted file mode 100644 index e74d42c..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/LibriVoxCatalog.kt +++ /dev/null @@ -1,106 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import fr.tuxgyver.noxely.model.MediaEntry -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonObject -import java.net.URLEncoder - -/** - * Recherche dans LibriVox. - * - * L'API a deux pièges, tous deux vérifiés contre le service réel : - * le champ `title` fait une correspondance **exacte** sauf s'il est préfixé par `^` - * (`title=Alice` ne donne rien, `title=^Alice` donne vingt-trois livres), et le champ `author` - * ne matche que le **nom de famille** (`author=Jules Verne` ne donne rien, `author=Verne` si). - * Un « aucun résultat » se présente enfin sous la forme d'un HTTP 404, pas d'une liste vide. - * - * D'où trois interrogations combinées et dédoublonnées, plutôt qu'une seule qui échouerait. - * - * Chaque livre publie un flux RSS : la résolution réutilise l'import de podcast plutôt que de - * redécrire un parcours de chapitres. - */ -class LibriVoxCatalog(private val rss: RssImporter) : Catalog { - - override val id = CatalogId.LIBRIVOX - - override val languages = COMMON_LANGUAGES - - private val json = Json { ignoreUnknownKeys = true } - - override suspend fun search(query: String, language: String?): Result> = - withContext(Dispatchers.IO) { - runCatching { - // Le paramètre `language` de l'API est accepté puis ignoré : vérifié, `language=French` - // seul renvoie des livres anglais. Le tri se fait donc sur le champ `language` de - // chaque livre, et on demande davantage de résultats pour qu'il en reste après filtrage. - val limit = if (language == null) 25 else 100 - val cleaned = query.trim() - val term = URLEncoder.encode(cleaned, "UTF-8") - val words = cleaned.split(Regex("\\s+")).filter { it.isNotBlank() } - - val criteria = buildList { - add("title=^$term") - add("author=$term") - // « Jules Verne » ne matche que sur « Verne » : on retente avec le nom de famille. - if (words.size > 1) add("author=${URLEncoder.encode(words.last(), "UTF-8")}") - } - - // Les trois critères sont indépendants : les lancer en parallèle ramène l'attente - // à celle de la requête la plus lente, au lieu de leur somme. - coroutineScope { - criteria.map { async { books(it, limit, language) } }.awaitAll() - }.flatten().distinctBy { it.id } - } - } - - override suspend fun tracks(item: CatalogItem): Result> = - rss.import(item.id).map { feed -> feed.episodes } - - private fun books(criterion: String, limit: Int, language: String?): List { - val url = "https://librivox.org/api/feed/audiobooks/?format=json&limit=$limit&$criterion" - // Le service met 14 à 16 secondes à répondre, quelle que soit la taille demandée : - // c'est sa latence propre, pas le volume. D'où une patience adaptée. - val connection = openConnection(url, readTimeoutMs = 45_000) - val body = try { - when (connection.responseCode) { - // LibriVox répond 404 quand le critère ne donne rien : ce n'est pas une panne. - 404 -> return emptyList() - in 200..299 -> connection.inputStream.bufferedReader().readText() - else -> throw IllegalStateException("LibriVox a répondu ${connection.responseCode}") - } - } finally { - connection.disconnect() - } - - val books = json.parseToJsonElement(body).jsonObject["books"] as? JsonArray ?: return emptyList() - return books.mapNotNull { element -> - val book = element as? JsonObject ?: return@mapNotNull null - if (language != null && !book.text("language").equals(language, ignoreCase = true)) { - return@mapNotNull null - } - val feed = book.text("url_rss") ?: return@mapNotNull null - CatalogItem( - id = feed, - title = book.text("title") ?: "Livre audio", - subtitle = book.authors(), - hint = book.text("totaltime")?.let { "Livre audio · $it" } ?: "Livre audio", - ) - } - } - - private fun JsonObject.authors(): String { - val authors = this["authors"] as? JsonArray ?: return "LibriVox" - return authors.mapNotNull { it as? JsonObject } - .joinToString(", ") { author -> - listOfNotNull(author.text("first_name"), author.text("last_name")).joinToString(" ") - } - .ifBlank { "LibriVox" } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/RssImporter.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/RssImporter.kt deleted file mode 100644 index 99e9efa..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/RssImporter.kt +++ /dev/null @@ -1,143 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import android.util.Xml -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.xmlpull.v1.XmlPullParser -import java.text.SimpleDateFormat -import java.util.Locale - -data class PodcastFeed( - val title: String, - val artworkUri: String?, - val episodes: List, -) - -/** Importe un flux RSS de podcast : chaque `` devient une entrée téléchargeable. */ -class RssImporter { - - suspend fun import(feedUrl: String): Result = withContext(Dispatchers.IO) { - val url = feedUrl.trim() - if (!url.startsWith("http://") && !url.startsWith("https://")) { - return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://")) - } - runCatching { - val connection = openConnection(url) - try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("Le serveur a répondu ${connection.responseCode}") - } - parse(connection.inputStream.bufferedReader().readText()) - } finally { - connection.disconnect() - } - } - } - - private fun parse(xml: String): PodcastFeed { - val parser = Xml.newPullParser().apply { - setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false) - setInput(xml.reader()) - } - - var feedTitle = "" - var feedImage: String? = null - val episodes = mutableListOf() - - var item: Draft? = null - var event = parser.eventType - while (event != XmlPullParser.END_DOCUMENT) { - when (event) { - XmlPullParser.START_TAG -> when (parser.name) { - "item", "entry" -> item = Draft() - "title" -> { - val value = parser.textOrEmpty() - if (item != null) item.title = value else if (feedTitle.isEmpty()) feedTitle = value - } - "enclosure" -> if (item != null) { - item.url = parser.getAttributeValue(null, "url") - item.type = parser.getAttributeValue(null, "type").orEmpty() - } - "itunes:image" -> { - val href = parser.getAttributeValue(null, "href") - if (item != null) item.image = href else if (feedImage == null) feedImage = href - } - "itunes:duration" -> if (item != null) item.duration = parser.textOrEmpty() - "pubDate" -> if (item != null) item.pubDate = parser.textOrEmpty() - "url" -> if (item == null && feedImage == null) feedImage = parser.textOrEmpty() - } - - XmlPullParser.END_TAG -> if (parser.name == "item" || parser.name == "entry") { - item?.toEntry(feedTitle, feedImage)?.let(episodes::add) - item = null - } - } - event = parser.next() - } - - if (episodes.isEmpty()) { - throw IllegalStateException("Aucun épisode téléchargeable trouvé dans ce flux") - } - return PodcastFeed( - title = feedTitle.ifEmpty { "Podcast" }, - artworkUri = feedImage, - episodes = episodes.take(MAX_EPISODES), - ) - } - - private class Draft { - var title: String = "" - var url: String? = null - var type: String = "" - var image: String? = null - var duration: String = "" - var pubDate: String = "" - - fun toEntry(feedTitle: String, feedImage: String?): MediaEntry? { - val mediaUrl = url?.takeIf { it.isNotBlank() } ?: return null - return MediaEntry( - id = stableId("pod:", mediaUrl), - source = SourceKind.PODCAST, - kind = if (type.startsWith("video/")) MediaKind.VIDEO else MediaKind.AUDIO, - title = title.ifBlank { "Épisode" }, - subtitle = feedTitle.ifBlank { "Podcast" }, - uri = mediaUrl, - artworkUri = image ?: feedImage, - durationMs = parseDuration(duration), - addedAt = parseDate(pubDate), - ) - } - } - - private companion object { - const val MAX_EPISODES = 100 - - fun XmlPullParser.textOrEmpty(): String = - runCatching { nextText().trim() }.getOrDefault("") - - /** `1:02:03`, `12:34` ou un nombre de secondes. */ - fun parseDuration(raw: String): Long { - if (raw.isBlank()) return 0L - val parts = raw.split(':').mapNotNull { it.trim().toLongOrNull() } - return when (parts.size) { - 1 -> parts[0] * 1000 - 2 -> (parts[0] * 60 + parts[1]) * 1000 - 3 -> (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000 - else -> 0L - } - } - - fun parseDate(raw: String): Long { - if (raw.isBlank()) return System.currentTimeMillis() - val formats = listOf("EEE, dd MMM yyyy HH:mm:ss Z", "EEE, dd MMM yyyy HH:mm:ss zzz") - for (pattern in formats) { - runCatching { SimpleDateFormat(pattern, Locale.US).parse(raw)?.time } - .getOrNull()?.let { return it } - } - return System.currentTimeMillis() - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/UrlImporter.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/UrlImporter.kt deleted file mode 100644 index af72181..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/UrlImporter.kt +++ /dev/null @@ -1,65 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import android.net.Uri -import androidx.core.net.toUri -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext - -/** Importe un lien direct vers un fichier média (mp3, m4a, mp4, webm…). */ -class UrlImporter { - - suspend fun import(rawUrl: String): Result = withContext(Dispatchers.IO) { - val url = rawUrl.trim() - if (!url.startsWith("http://") && !url.startsWith("https://")) { - return@withContext Result.failure(IllegalArgumentException("Le lien doit commencer par http:// ou https://")) - } - runCatching { - val connection = openConnection(url, "HEAD") - val contentType = try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("Le serveur a répondu ${connection.responseCode}") - } - connection.contentType.orEmpty() - } finally { - connection.disconnect() - } - - val kind = kindOf(contentType, url) - ?: throw IllegalStateException("Ce lien ne pointe pas vers un fichier audio ou vidéo") - - val name = url.toUri().lastPathSegment - ?.substringBeforeLast('.') - ?.replace(Regex("[_-]+"), " ") - ?.trim() - ?.takeIf { it.isNotBlank() } - ?: "Média importé" - - MediaEntry( - id = stableId("url:", url), - source = SourceKind.URL, - kind = kind, - title = name, - subtitle = url.toUri().host ?: "Lien direct", - uri = url, - addedAt = System.currentTimeMillis(), - ) - } - } - - private fun kindOf(contentType: String, url: String): MediaKind? { - val mime = contentType.substringBefore(';').trim().lowercase() - return when { - mime.startsWith("video/") -> MediaKind.VIDEO - mime.startsWith("audio/") -> MediaKind.AUDIO - mime == "application/vnd.apple.mpegurl" || mime == "application/x-mpegurl" -> MediaKind.VIDEO - else -> when (url.substringAfterLast('.', "").lowercase().substringBefore('?')) { - "mp3", "m4a", "aac", "ogg", "opus", "flac", "wav" -> MediaKind.AUDIO - "mp4", "mkv", "webm", "m4v", "mov" -> MediaKind.VIDEO - else -> null - } - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sources/WikimediaCatalog.kt b/app/src/main/java/fr/tuxgyver/noxely/sources/WikimediaCatalog.kt deleted file mode 100644 index 62ca71a..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/sources/WikimediaCatalog.kt +++ /dev/null @@ -1,96 +0,0 @@ -package fr.tuxgyver.noxely.sources - -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import java.net.URLEncoder - -/** - * Recherche de fichiers audio sur Wikimedia Commons. - * - * Retenu après avoir écarté deux candidats vérifiés : Openverse refuse désormais l'accès anonyme - * (401 sur huit requêtes sur huit) et ccMixter exige un en-tête `Referer` que Media3 ne permet pas - * de poser par téléchargement. Commons, lui, sert ses fichiers directement depuis - * upload.wikimedia.org, sans clé ni en-tête particulier, et annonce leur taille. - * - * Chaque résultat est déjà un fichier unique : la liste de pistes n'en contient donc qu'une. - */ -class WikimediaCatalog : Catalog { - - override val id = CatalogId.WIKIMEDIA - - private val json = Json { ignoreUnknownKeys = true } - - override suspend fun search(query: String, language: String?): Result> = - withContext(Dispatchers.IO) { - runCatching { - val search = URLEncoder.encode("filetype:audio ${query.trim()}", "UTF-8") - val url = "https://commons.wikimedia.org/w/api.php?action=query&format=json" + - "&generator=search&gsrsearch=$search&gsrnamespace=6&gsrlimit=30" + - "&prop=imageinfo&iiprop=url%7Csize%7Cmime%7Cextmetadata" - - val connection = openConnection(url, readTimeoutMs = 30_000) - val body = try { - if (connection.responseCode !in 200..299) { - throw IllegalStateException("Commons a répondu ${connection.responseCode}") - } - connection.inputStream.bufferedReader().readText() - } finally { - connection.disconnect() - } - - val pages = json.parseToJsonElement(body).jsonObject["query"] - ?.jsonObject?.get("pages")?.jsonObject - ?: return@runCatching emptyList() - - pages.values.mapNotNull { element -> - val page = element as? JsonObject ?: return@mapNotNull null - val info = page["imageinfo"]?.jsonArray?.firstOrNull()?.jsonObject - ?: return@mapNotNull null - val fileUrl = info.text("url") ?: return@mapNotNull null - if (!PLAYABLE.any { fileUrl.substringBefore('?').lowercase().endsWith(it) }) { - return@mapNotNull null - } - - val licence = info["extmetadata"]?.jsonObject - ?.get("LicenseShortName")?.jsonObject?.text("value") - - CatalogItem( - id = fileUrl, - // Les titres Commons sont préfixés « File: » : inutile à l'écran. - title = page.text("title")?.removePrefix("File:")?.substringBeforeLast('.') - ?: "Sans titre", - subtitle = "Wikimedia Commons", - hint = licence ?: "Domaine public / CC", - sizeBytes = info.text("size")?.toLongOrNull(), - ) - } - } - } - - override suspend fun tracks(item: CatalogItem): Result> = Result.success( - listOf( - MediaEntry( - id = stableId("wm:", item.id), - source = SourceKind.URL, - kind = MediaKind.AUDIO, - title = item.title, - subtitle = item.subtitle, - uri = item.id, - addedAt = System.currentTimeMillis(), - sizeBytes = item.sizeBytes, - ) - ) - ) - - private companion object { - /** Formats que Media3 sait lire parmi ceux que Commons héberge. */ - val PLAYABLE = listOf(".ogg", ".oga", ".opus", ".flac", ".wav", ".mp3", ".m4a") - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/ServerSettings.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/ServerSettings.kt new file mode 100644 index 0000000..f91b038 --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/ServerSettings.kt @@ -0,0 +1,64 @@ +package fr.tuxgyver.noxely.sync + +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +/** Coordonnées du serveur et état de la dernière synchronisation. */ +@Serializable +data class ServerConfig( + val baseUrl: String = "", + val token: String = "", + /** Horodatage du dernier média reçu : le serveur ne renvoie que ce qui a changé après. */ + val cursor: Long = 0L, + val lastSyncAt: Long = 0L, + val lastResult: String = "", +) { + val isConfigured: Boolean get() = baseUrl.isNotBlank() && token.isNotBlank() +} + +/** + * Réglages du serveur, persistés localement. + * + * Le jeton ne quitte jamais l'appareil autrement que dans l'en-tête des requêtes vers le serveur + * désigné : c'est la seule adresse que l'application contacte une fois la bascule faite. + */ +class ServerSettings(context: Context, private val scope: CoroutineScope) { + + private val file = File(context.filesDir, "server.json") + private val json = Json { ignoreUnknownKeys = true } + + private val _config = MutableStateFlow(ServerConfig()) + val config: StateFlow = _config.asStateFlow() + + init { + scope.launch { _config.value = read() } + } + + suspend fun update(transform: (ServerConfig) -> ServerConfig) { + val updated = transform(_config.value) + _config.value = updated + write(updated) + } + + private suspend fun read(): ServerConfig = withContext(Dispatchers.IO) { + if (!file.exists()) return@withContext ServerConfig() + runCatching { json.decodeFromString(file.readText()) } + .getOrElse { ServerConfig() } + } + + private suspend fun write(config: ServerConfig) = withContext(Dispatchers.IO) { + val tmp = File(file.parentFile, "${file.name}.tmp") + tmp.writeText(json.encodeToString(config)) + tmp.renameTo(file) + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt new file mode 100644 index 0000000..0d3746b --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncClient.kt @@ -0,0 +1,144 @@ +package fr.tuxgyver.noxely.sync + +import android.content.Context +import fr.tuxgyver.noxely.library.ImportedStore +import fr.tuxgyver.noxely.model.MediaEntry +import fr.tuxgyver.noxely.model.MediaKind +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.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.net.URLEncoder + +data class SyncResult(val added: Int, val failed: Int, val message: String) + +/** + * Client de synchronisation. + * + * Le téléphone ne cherche plus, ne guette plus et n'enregistre plus : il demande au serveur ce + * qui est apparu depuis son dernier passage, télécharge les fichiers prêts, et se rendort. C'est + * là qu'est l'économie de batterie — tout le travail d'écoute et de collecte a migré ailleurs. + */ +class SyncClient( + private val context: Context, + private val settings: ServerSettings, + private val imported: ImportedStore, +) { + private val json = Json { ignoreUnknownKeys = true } + + /** Vérifie que le serveur répond et que le jeton est accepté. */ + suspend fun probe(baseUrl: String, token: String): Result = withContext(Dispatchers.IO) { + runCatching { + val name = request("${baseUrl.trimEnd('/')}/api/health", token = null) + .let { json.parseToJsonElement(it).jsonObject } + .let { "${it["name"]?.jsonPrimitive?.content} ${it["version"]?.jsonPrimitive?.content}" } + // Une réponse publique ne prouve rien sur le jeton : on interroge aussi un point protégé. + request("${baseUrl.trimEnd('/')}/api/library?since=0", token) + name + } + } + + suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { + val config = settings.config.value + if (!config.isConfigured) return@withContext SyncResult(0, 0, "Serveur non configuré") + + val base = config.baseUrl.trimEnd('/') + val body = runCatching { request("$base/api/library?since=${config.cursor}", config.token) } + .getOrElse { return@withContext SyncResult(0, 0, it.message ?: "Serveur injoignable") } + + val payload = json.parseToJsonElement(body).jsonObject + val items = (payload["items"] as? JsonArray).orEmpty() + val cursor = payload["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: config.cursor + + var added = 0 + var failed = 0 + for (element in items) { + val item = element as? JsonObject ?: continue + val id = item.text("id") ?: continue + val title = item.text("title") ?: "Sans titre" + val mime = item.text("mime") ?: "audio/mpeg" + val extension = if (mime.contains("aac")) "aac" else if (mime.contains("mp4")) "mp4" else "mp3" + + val sink = RecordingSink(context, "${title.take(80)}.$extension", mime) + val outcome = runCatching { + val connection = openConnection("$base/api/media/$id/file", readTimeoutMs = 120_000) + .apply { setRequestProperty("X-Noxely-Token", config.token) } + try { + if (connection.responseCode !in 200..299) { + throw IllegalStateException("HTTP ${connection.responseCode}") + } + connection.inputStream.use { input -> + sink.open().use { output -> input.copyTo(output) } + } + } finally { + connection.disconnect() + } + sink.finish(title, item.text("duration_ms")?.toLongOrNull() ?: 0L) + }.getOrNull() + + if (outcome == null) { + sink.abort() + failed++ + continue + } + val (uri, size) = outcome + imported.add( + listOf( + MediaEntry( + id = "srv:$id", + source = SourceKind.SERVER, + kind = if (mime.startsWith("video")) MediaKind.VIDEO else MediaKind.AUDIO, + title = title, + subtitle = item.text("artist")?.takeIf { it.isNotBlank() } ?: "Serveur Noxely", + uri = uri, + artworkUri = item.text("artwork_url"), + durationMs = item.text("duration_ms")?.toLongOrNull() ?: 0L, + addedAt = System.currentTimeMillis(), + sizeBytes = size, + ) + ) + ) + added++ + } + + // Le curseur n'avance que si tout est passé : un échec sera retenté au prochain tour. + settings.update { + it.copy( + cursor = if (failed == 0) cursor else it.cursor, + lastSyncAt = System.currentTimeMillis(), + lastResult = when { + added == 0 && failed == 0 -> "À jour" + failed == 0 -> "$added titre(s) reçus" + else -> "$added reçus, $failed en échec" + }, + ) + } + SyncResult(added, failed, settings.config.value.lastResult) + } + + private fun request(url: String, token: String?): String { + val connection = openConnection(url, readTimeoutMs = 30_000).apply { + token?.let { setRequestProperty("X-Noxely-Token", it) } + } + return try { + when (connection.responseCode) { + 401 -> throw IllegalStateException("Jeton refusé par le serveur") + in 200..299 -> connection.inputStream.bufferedReader().readText() + else -> throw IllegalStateException("Le serveur a répondu ${connection.responseCode}") + } + } finally { + connection.disconnect() + } + } + + private fun JsonArray?.orEmpty(): JsonArray = this ?: JsonArray(emptyList()) + + private fun JsonObject.text(key: String): String? = + this[key]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() && it != "null" } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/sync/SyncWorker.kt b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncWorker.kt new file mode 100644 index 0000000..f1e770c --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/sync/SyncWorker.kt @@ -0,0 +1,49 @@ +package fr.tuxgyver.noxely.sync + +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import fr.tuxgyver.noxely.NoxelyApp +import java.util.concurrent.TimeUnit + +/** + * Synchronisation planifiée. + * + * Wi-Fi non facturé et batterie non faible : le téléphone ne se réveille que dans les conditions + * où un téléchargement ne coûte rien. C'est le réglage le plus sobre — le serveur, lui, a déjà + * fait tout le travail d'écoute et de collecte. + */ +class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + val container = (applicationContext as NoxelyApp).container + if (!container.serverSettings.config.value.isConfigured) return Result.success() + val outcome = runCatching { container.sync.sync() }.getOrNull() + return if (outcome == null) Result.retry() else Result.success() + } + + companion object { + private const val WORK_NAME = "noxely-sync" + + fun schedule(context: Context) { + val request = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .setConstraints( + Constraints.Builder() + .setRequiredNetworkType(NetworkType.UNMETERED) + .setRequiresBatteryNotLow(true) + .build() + ) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt index 9eca7c0..2664a86 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/NoxelyRoot.kt @@ -38,37 +38,26 @@ import androidx.navigation.compose.rememberNavController import fr.tuxgyver.noxely.AppContainer import fr.tuxgyver.noxely.model.MediaEntry import fr.tuxgyver.noxely.model.SourceKind -import fr.tuxgyver.noxely.sources.CatalogId import fr.tuxgyver.noxely.ui.about.AboutScreen -import fr.tuxgyver.noxely.ui.browse.CatalogBrowseScreen -import fr.tuxgyver.noxely.ui.browse.CatalogItemScreen -import fr.tuxgyver.noxely.ui.browse.SourceBrowseScreen import fr.tuxgyver.noxely.ui.library.LibraryScreen import fr.tuxgyver.noxely.ui.offline.DownloadsScreen import fr.tuxgyver.noxely.ui.player.MiniPlayer -import fr.tuxgyver.noxely.ui.radio.RadioScreen -import fr.tuxgyver.noxely.ui.radio.WatchListScreen +import fr.tuxgyver.noxely.ui.server.ServerScreen import fr.tuxgyver.noxely.ui.playlists.AddToPlaylistSheet import fr.tuxgyver.noxely.ui.playlists.PlaylistDetailScreen import fr.tuxgyver.noxely.ui.playlists.PlaylistsScreen import fr.tuxgyver.noxely.ui.player.NowPlayingScreen -import fr.tuxgyver.noxely.ui.sources.SourcesScreen import kotlinx.coroutines.launch private enum class Tab(val route: String, val label: String, val icon: ImageVector) { LIBRARY("library", "Bibliothèque", Icons.Rounded.LibraryMusic), OFFLINE("offline", "Hors ligne", Icons.Rounded.CloudDownload), PLAYLISTS("playlists", "Playlists", Icons.Rounded.PlaylistPlay), - SOURCES("sources", "Sources", Icons.Rounded.Tune), + SERVER("server", "Réglages", Icons.Rounded.Tune), } -private const val BROWSE_ROUTE = "browse/{source}" -private const val CATALOG_ROUTE = "catalog/{catalog}" -private const val CATALOG_ITEM_ROUTE = "catalog/{catalog}/item" private const val PLAYLIST_ROUTE = "playlist/{playlist}" -private const val RADIO_ROUTE = "radio" private const val ABOUT_ROUTE = "about" -private const val WATCHES_ROUTE = "watches" @Composable fun NoxelyRoot(container: AppContainer) { @@ -157,26 +146,30 @@ fun NoxelyRoot(container: AppContainer) { AboutScreen(onBack = navController::popBackStack, contentPadding = padding) } - composable(RADIO_ROUTE) { - RadioScreen( + composable(PLAYLIST_ROUTE) { entry -> + PlaylistDetailScreen( container = container, + playlistId = entry.arguments?.getString("playlist").orEmpty(), currentMediaId = playerState.mediaId, onBack = navController::popBackStack, - onMessage = notify, - onOpenWatches = { navController.navigate(WATCHES_ROUTE) }, + onPlay = playFrom, contentPadding = padding, ) } - composable(WATCHES_ROUTE) { - WatchListScreen( + composable(Tab.SERVER.route) { + ServerScreen( container = container, - onBack = navController::popBackStack, + onOpenAbout = { navController.navigate(ABOUT_ROUTE) }, onMessage = notify, contentPadding = padding, ) } + composable(ABOUT_ROUTE) { + AboutScreen(onBack = navController::popBackStack, contentPadding = padding) + } + composable(PLAYLIST_ROUTE) { entry -> PlaylistDetailScreen( container = container, @@ -188,55 +181,6 @@ fun NoxelyRoot(container: AppContainer) { ) } - composable(Tab.SOURCES.route) { - SourcesScreen( - container = container, - contentPadding = padding, - onMessage = notify, - onBrowseSource = { navController.navigate("browse/${it.name}") }, - onBrowseCatalog = { navController.navigate("catalog/${it.name}") }, - onBrowseRadio = { navController.navigate(RADIO_ROUTE) }, - onOpenAbout = { navController.navigate(ABOUT_ROUTE) }, - ) - } - - composable(BROWSE_ROUTE) { entry -> - val source = SourceKind.valueOf(entry.arguments?.getString("source") ?: SourceKind.LOCAL.name) - SourceBrowseScreen( - container = container, - source = source, - currentMediaId = playerState.mediaId, - onBack = navController::popBackStack, - onPlay = play, - contentPadding = padding, - ) - } - - composable(CATALOG_ROUTE) { entry -> - val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name) - CatalogBrowseScreen( - container = container, - catalogId = catalog, - onBack = navController::popBackStack, - onOpenItem = { item -> - container.openedCatalogItem = item - navController.navigate("catalog/${catalog.name}/item") - }, - contentPadding = padding, - ) - } - - composable(CATALOG_ITEM_ROUTE) { entry -> - val catalog = CatalogId.valueOf(entry.arguments?.getString("catalog") ?: CatalogId.ARCHIVE.name) - CatalogItemScreen( - container = container, - catalogId = catalog, - currentMediaId = playerState.mediaId, - onBack = navController::popBackStack, - onMessage = notify, - contentPadding = padding, - ) - } } } @@ -288,4 +232,4 @@ private fun NavHostController.switchTab(route: String) { launchSingleTop = true restoreState = true } -} +} \ No newline at end of file diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt index 86ec13e..1f31bb0 100644 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/about/AppInfo.kt @@ -11,7 +11,7 @@ import fr.tuxgyver.noxely.BuildConfig object AppInfo { const val NAME = "Noxely" - const val TAGLINE = "Mettez la musique de côté pendant que vous avez du réseau." + const val TAGLINE = "Le serveur collecte, le téléphone écoute." val VERSION: String get() = BuildConfig.VERSION_NAME const val REPOSITORY = "https://git.tuxtech.fr/tuxgyver/Noxely" @@ -27,24 +27,18 @@ object AppInfo { /** Ce que fait l'application, du point de vue de qui l'utilise. */ val FEATURES = listOf( "Écouter sans réseau" to - "Ce que vous mettez de côté reste lisible dans le métro, en avion ou forfait épuisé. " + - "Audio et vidéo, avec les commandes au casque et sur l'écran verrouillé.", - "Choisir piste par piste" to - "Un album ou un livre affiche ses pistes avec leur durée et leur taille. Vous cochez " + - "ce que vous emportez, et le bouton annonce le poids exact avant de lancer.", - "Essayer avant de garder" to - "Un appui sur une piste la joue en ligne, sans rien mettre en cache. On décide après.", - "Puiser dans des catalogues libres" to - "Internet Archive, Underground (netlabels et Free Music Archive), LibriVox et " + - "Wikimedia Commons, sans compte ni clé, avec filtrage par langue.", - "Écouter et enregistrer la radio" to - "Les stations les plus écoutées, ce qu'elles diffusent à l'instant, et " + - "l'enregistrement d'un direct — qui prend le nom du morceau annoncé.", - "Guetter un morceau" to - "Vous nommez un titre : l'enregistrement se déclenche dès que la station l'annonce, " + - "et s'arrête à la fin du morceau.", + "Ce que le serveur a collecté est sur le téléphone, lisible dans le métro, en avion " + + "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.", + "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.", + "Retrouver les pochettes" to + "Les titres sans image en reçoivent une, cherchée sur leur nom.", "Rester à jour" to "L'application surveille les publications du dépôt, télécharge la nouvelle version " + "en Wi-Fi et propose de l'installer.", @@ -52,20 +46,22 @@ object AppInfo { /** Chaque promesse est tenue par du code, et dit où. */ val GUARANTEES_INTRO = - "Noxely est un outil personnel : rien n'y transite par un service tiers, et rien ne s'y " + - "télécharge sans un geste de votre part." + "Noxely est un outil personnel : le téléphone ne parle qu'à votre serveur, et rien n'y " + + "transite par un service tiers." val GUARANTEES = listOf( "Aucun compte, aucune inscription, aucune télémétrie : le projet n'embarque aucune " + "bibliothèque d'analyse d'audience.", - "Les seules connexions sortantes sont celles des adresses que vous ajoutez et des " + - "catalogues que vous interrogez.", + "La seule adresse que le mobile contacte est celle de votre serveur, plus le dépôt des " + + "mises à jour. Les catalogues et les radios ne sont joints que par le serveur.", + "Le mobile ne se réveille qu'en Wi-Fi non facturé et batterie suffisante : il ne balaie " + + "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.", "Le contenu mis en cache est exclu des sauvegardes dans le nuage et des transferts " + "d'appareil à appareil.", - "Un téléchargement interrompu par une panne de serveur repart seul, trois fois, en " + - "espaçant les tentatives ; sans réseau, il attend son retour.", + "Une synchronisation partielle ne fait pas avancer le curseur : ce qui a échoué est " + + "repris au passage suivant, jamais perdu.", "Les catalogues proposés ne présentent que des œuvres libres de droits ou du domaine " + "public ; les éléments en accès restreint sont écartés avant même d'être affichés.", "Le jeton qui sert à vérifier les mises à jour est en lecture seule et ne peut rien publier.", @@ -74,10 +70,11 @@ object AppInfo { /** Couche → outils. Six lignes suffisent. */ val STACK = listOf( "Interface" to "Jetpack Compose, Material 3, thèmes clair et sombre", + "Serveur" to "Python, FastAPI, uvicorn, SQLite, service systemd", "Lecture" to "Media3 / ExoPlayer, MediaSessionService, notification système", - "Hors connexion" to "Cache Media3 non évinçant, WorkManager pour la reprise", + "Hors connexion" to "Fichiers rangés dans MediaStore, lisibles par tout lecteur", "Bibliothèque" to "MediaStore pour l'appareil, fichiers JSON pour les imports", - "Réseau" to "HttpURLConnection, kotlinx.serialization, flux ICY pour la radio", + "Synchronisation" to "HTTP avec jeton, curseur incrémental, WorkManager", "Publication" to "APK signé, releases Gitea, mise à jour intégrée", ) } diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogBrowseScreen.kt deleted file mode 100644 index a65c379..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogBrowseScreen.kt +++ /dev/null @@ -1,233 +0,0 @@ -package fr.tuxgyver.noxely.ui.browse - -import androidx.compose.foundation.clickable -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import fr.tuxgyver.noxely.AppContainer -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.sources.CatalogId -import fr.tuxgyver.noxely.sources.CatalogItem -import fr.tuxgyver.noxely.ui.components.Artwork -import fr.tuxgyver.noxely.ui.components.formatBytes -import kotlinx.coroutines.launch - -/** - * Recherche dans un catalogue libre de droits. - * - * Un résultat n'est pas un fichier mais un album, un livre ou un morceau : l'ouvrir mène à la - * liste de ses pistes, où l'on écoute et où l'on choisit ce qu'on emporte. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CatalogBrowseScreen( - container: AppContainer, - catalogId: CatalogId, - onBack: () -> Unit, - onOpenItem: (CatalogItem) -> Unit, - contentPadding: PaddingValues, -) { - val catalog = container.catalogs.getValue(catalogId) - val scope = rememberCoroutineScope() - - var query by rememberSaveable { mutableStateOf(catalogId.suggestion) } - var language by rememberSaveable { mutableStateOf(null) } - var results by remember { mutableStateOf>(emptyList()) } - var searching by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - - fun runSearch() { - if (query.isBlank()) return - searching = true - error = null - scope.launch { - catalog.search(query, language).fold( - onSuccess = { results = it; if (it.isEmpty()) error = "Aucun résultat" }, - onFailure = { error = it.message ?: "Recherche impossible" }, - ) - searching = false - } - } - - LaunchedEffect(catalogId, language) { runSearch() } - - val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) - - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { Text(catalogId.title) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") - } - }, - ) - - Text( - text = catalogId.license, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary, - modifier = Modifier.padding(horizontal = 16.dp), - ) - - OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text("Rechercher dans ${catalogId.title}") }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - singleLine = true, - shape = MaterialTheme.shapes.large, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { runSearch() }), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - - if (catalog.languages.isNotEmpty()) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - ) { - FilterChip( - selected = language == null, - onClick = { language = null }, - label = { Text("Toutes langues") }, - ) - catalog.languages.forEach { option -> - FilterChip( - selected = language == option.code, - onClick = { language = option.code }, - label = { Text(option.label) }, - ) - } - } - Spacer(Modifier.size(8.dp)) - } - - when { - searching -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - error != null -> Box( - Modifier - .fillMaxSize() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = error!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - else -> LazyColumn( - contentPadding = listPadding, - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - items(results, key = { it.id }) { item -> - CatalogRow(item = item, onOpen = { onOpenItem(item) }) - } - } - } - } -} - -@Composable -private fun CatalogRow(item: CatalogItem, onOpen: () -> Unit) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(10.dp), - ) { - Artwork( - uri = item.artworkUri, - title = item.title, - kind = if (item.hint.contains("Vidéo")) MediaKind.VIDEO else MediaKind.AUDIO, - shape = RoundedCornerShape(14.dp), - modifier = Modifier.size(56.dp), - ) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Text( - text = item.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = listOfNotNull( - item.subtitle.takeIf { it.isNotBlank() }, - item.hint.takeIf { it.isNotBlank() }, - item.sizeBytes?.let { "~${formatBytes(it)}" }, - ).joinToString(" · "), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Voir les pistes", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogItemScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogItemScreen.kt deleted file mode 100644 index a2a71e0..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/CatalogItemScreen.kt +++ /dev/null @@ -1,255 +0,0 @@ -package fr.tuxgyver.noxely.ui.browse - -import androidx.compose.foundation.clickable -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.GraphicEq -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -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.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -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.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import fr.tuxgyver.noxely.AppContainer -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.sources.CatalogId -import fr.tuxgyver.noxely.ui.components.formatBytes -import fr.tuxgyver.noxely.ui.components.formatDuration -import kotlinx.coroutines.launch - -/** - * Les pistes d'un résultat de catalogue. - * - * Un album ou un livre en compte souvent des dizaines : chacune s'écoute d'abord en ligne, et - * n'est mise en cache que si on la coche. La taille de la sélection est annoncée avant l'envoi. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun CatalogItemScreen( - container: AppContainer, - catalogId: CatalogId, - currentMediaId: String?, - onBack: () -> Unit, - onMessage: (String) -> Unit, - contentPadding: PaddingValues, -) { - val item = container.openedCatalogItem - val scope = rememberCoroutineScope() - - var tracks by remember { mutableStateOf>(emptyList()) } - var selection by remember { mutableStateOf(emptySet()) } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var adding by remember { mutableStateOf(false) } - - LaunchedEffect(item?.id) { - val opened = item ?: run { - onBack() - return@LaunchedEffect - } - loading = true - container.catalogs.getValue(catalogId).tracks(opened).fold( - onSuccess = { tracks = it; error = if (it.isEmpty()) "Aucune piste lisible" else null }, - onFailure = { error = it.message ?: "Contenu illisible" }, - ) - loading = false - } - - val selectedSize = remember(selection, tracks) { - tracks.filter { it.id in selection }.sumOf { it.sizeBytes ?: 0L } - } - val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) - - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { - Text( - text = item?.title ?: "Pistes", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") - } - }, - actions = { - if (tracks.isNotEmpty()) { - TextButton(onClick = { - selection = if (selection.size == tracks.size) emptySet() - else tracks.mapTo(mutableSetOf()) { it.id } - }) { - Text(if (selection.size == tracks.size) "Aucune" else "Toutes") - } - } - }, - ) - - if (tracks.isNotEmpty()) { - val total = tracks.sumOf { it.sizeBytes ?: 0L } - Text( - text = buildString { - append("${tracks.size} piste(s)") - if (total > 0) append(" · ${formatBytes(total)} au total") - append(" · appuyez pour écouter en ligne") - }, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - - when { - loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - error != null -> Box( - Modifier - .fillMaxSize() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = error!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - else -> LazyColumn( - contentPadding = listPadding, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - items(tracks, key = { it.id }) { track -> - TrackRow( - track = track, - playing = track.id == currentMediaId, - selected = track.id in selection, - onPreview = { - // Un aperçu ne rejoint pas la bibliothèque : on le confie au - // registre transitoire pour que la lecture sache le résoudre. - container.library.preview(tracks) - container.player.play(tracks, tracks.indexOf(track)) - }, - onToggle = { - selection = if (track.id in selection) selection - track.id - else selection + track.id - }, - ) - } - item { Spacer(Modifier.size(88.dp)) } - } - } - } - - SelectionBar( - count = selection.size, - actionLabel = if (selectedSize > 0) "Garder ${formatBytes(selectedSize)}" else "Garder", - busy = adding, - onClear = { selection = emptySet() }, - onConfirm = { - adding = true - scope.launch { - val chosen = tracks.filter { it.id in selection } - val added = container.imported.add(chosen) - chosen.forEach(container.downloads::download) - adding = false - selection = emptySet() - onMessage( - if (added == 0) "Ces pistes sont déjà dans la bibliothèque" - else "$added piste(s) ajoutées, téléchargement lancé" - ) - } - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(contentPadding), - ) - } -} - -@Composable -private fun TrackRow( - track: MediaEntry, - playing: Boolean, - selected: Boolean, - onPreview: () -> Unit, - onToggle: () -> Unit, -) { - Surface( - color = when { - playing -> MaterialTheme.colorScheme.primaryContainer - selected -> MaterialTheme.colorScheme.secondaryContainer - else -> MaterialTheme.colorScheme.surface - }, - shape = MaterialTheme.shapes.small, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onPreview), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 6.dp), - ) { - Icon( - imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline, - contentDescription = if (playing) "En lecture" else "Écouter en ligne", - tint = if (playing) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text( - text = track.title, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = listOfNotNull( - track.durationMs.takeIf { it > 0 }?.let(::formatDuration), - track.sizeBytes?.takeIf { it > 0 }?.let(::formatBytes), - ).joinToString(" · ").ifBlank { "Taille inconnue" }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Checkbox(checked = selected, onCheckedChange = { onToggle() }) - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SelectionBar.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SelectionBar.kt deleted file mode 100644 index 38a8630..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SelectionBar.kt +++ /dev/null @@ -1,78 +0,0 @@ -package fr.tuxgyver.noxely.ui.browse - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Download -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Icon -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.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -/** Barre d'action des écrans de sélection : combien d'éléments, et que faire de la sélection. */ -@Composable -fun SelectionBar( - count: Int, - actionLabel: String, - busy: Boolean, - onClear: () -> Unit, - onConfirm: () -> Unit, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = count > 0, - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = modifier, - ) { - Surface( - color = MaterialTheme.colorScheme.secondaryContainer, - shape = MaterialTheme.shapes.large, - tonalElevation = 3.dp, - shadowElevation = 8.dp, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), - ) { - Text( - text = "$count sélectionné(s)", - style = MaterialTheme.typography.titleSmall, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onClear, enabled = !busy) { Text("Effacer") } - Button(onClick = onConfirm, enabled = !busy) { - if (busy) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - } else { - Icon(Icons.Rounded.Download, contentDescription = null, modifier = Modifier.size(18.dp)) - } - Spacer(Modifier.size(8.dp)) - Text(actionLabel) - } - } - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SourceBrowseScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SourceBrowseScreen.kt deleted file mode 100644 index 669f129..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/browse/SourceBrowseScreen.kt +++ /dev/null @@ -1,178 +0,0 @@ -package fr.tuxgyver.noxely.ui.browse - -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.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.model.MediaEntry -import fr.tuxgyver.noxely.model.SourceKind -import fr.tuxgyver.noxely.ui.components.MediaRow - -/** - * Parcours d'une source déjà présente dans la bibliothèque : on y choisit, un par un ou en lot, - * ce qui doit être gardé sur l'appareil. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SourceBrowseScreen( - container: AppContainer, - source: SourceKind, - currentMediaId: String?, - onBack: () -> Unit, - onPlay: (List, Int) -> Unit, - contentPadding: PaddingValues, -) { - val entries by container.library.entries.collectAsStateWithLifecycle() - val offlineStates by container.downloads.states.collectAsStateWithLifecycle() - - val ofSource = remember(entries, source) { entries.filter { it.source == source } } - val collections = remember(ofSource) { ofSource.map { it.subtitle }.distinct().sorted() } - - var collection by rememberSaveable { mutableStateOf(null) } - var selection by remember { mutableStateOf(emptySet()) } - - val visible = remember(ofSource, collection) { - if (collection == null) ofSource else ofSource.filter { it.subtitle == collection } - } - val selectable = remember(visible, offlineStates) { - visible.filter { it.isRemote && offlineStates[it.id]?.isComplete != true } - } - - // La barre de titre occupe déjà l'espace haut du Scaffold : la liste ne reprend que le bas. - val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) - - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { Text(source.label) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") - } - }, - actions = { - if (selectable.isNotEmpty()) { - TextButton(onClick = { - selection = if (selection.size == selectable.size) emptySet() - else selectable.mapTo(mutableSetOf()) { it.id } - }) { - Text(if (selection.size == selectable.size) "Aucun" else "Tout") - } - } - }, - ) - - if (collections.size > 1) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp, vertical = 4.dp), - ) { - FilterChip( - selected = collection == null, - onClick = { collection = null }, - label = { Text("Tout") }, - ) - collections.forEach { name -> - FilterChip( - selected = collection == name, - onClick = { collection = name }, - label = { Text(name) }, - ) - } - } - } - - if (visible.isEmpty()) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .fillMaxSize() - .padding(32.dp), - ) { - Text( - text = "Aucun contenu pour cette source. Ajoutez-en depuis l'écran Sources.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } else { - LazyColumn( - contentPadding = listPadding, - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - items(visible, key = { it.id }) { entry -> - val canSelect = entry.isRemote && offlineStates[entry.id]?.isComplete != true - MediaRow( - entry = entry, - offline = offlineStates[entry.id], - isCurrent = entry.id == currentMediaId, - selected = if (canSelect) entry.id in selection else null, - onClick = { - if (canSelect) { - selection = if (entry.id in selection) selection - entry.id - else selection + entry.id - } else { - onPlay(visible, visible.indexOf(entry)) - } - }, - onDownload = { container.downloads.download(entry) }, - onRemoveOffline = { container.downloads.remove(entry.id) }, - ) - } - item { Spacer(Modifier.size(88.dp)) } - } - } - } - - SelectionBar( - count = selection.size, - actionLabel = "Télécharger", - busy = false, - onClear = { selection = emptySet() }, - onConfirm = { - visible.filter { it.id in selection }.forEach(container.downloads::download) - selection = emptySet() - }, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(contentPadding), - ) - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt deleted file mode 100644 index 0dda2b6..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/RadioScreen.kt +++ /dev/null @@ -1,446 +0,0 @@ -package fr.tuxgyver.noxely.ui.radio - -import androidx.compose.foundation.clickable -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.FiberManualRecord -import androidx.compose.material.icons.rounded.Alarm -import androidx.compose.material.icons.rounded.GraphicEq -import androidx.compose.material.icons.rounded.PlayCircleOutline -import androidx.compose.material.icons.rounded.Radar -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Stop -import androidx.compose.material3.Button -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -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.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import fr.tuxgyver.noxely.AppContainer -import fr.tuxgyver.noxely.model.MediaEntry -import fr.tuxgyver.noxely.model.MediaKind -import fr.tuxgyver.noxely.model.SourceKind -import fr.tuxgyver.noxely.radio.RADIO_GENRES -import fr.tuxgyver.noxely.radio.RadioStation -import fr.tuxgyver.noxely.radio.Recorder -import fr.tuxgyver.noxely.radio.RecordingService -import fr.tuxgyver.noxely.ui.components.Artwork -import fr.tuxgyver.noxely.ui.components.formatBytes -import fr.tuxgyver.noxely.ui.components.formatDuration -import kotlinx.coroutines.delay -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch - -/** - * Les stations les plus écoutées, à écouter en direct ou à enregistrer. - * - * L'enregistrement produit un fichier local qui rejoint la bibliothèque : il n'a donc rien à - * télécharger ensuite, il est déjà hors connexion. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun RadioScreen( - container: AppContainer, - currentMediaId: String?, - onBack: () -> Unit, - onMessage: (String) -> Unit, - onOpenWatches: () -> Unit, - contentPadding: PaddingValues, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val status by Recorder.status.collectAsStateWithLifecycle() - - var query by rememberSaveable { mutableStateOf("") } - var genre by rememberSaveable { mutableStateOf(null) } - var stations by remember { mutableStateOf>(emptyList()) } - var loading by remember { mutableStateOf(true) } - var error by remember { mutableStateOf(null) } - var nowPlaying by remember { mutableStateOf(emptyMap()) } - var scheduleFor by remember { mutableStateOf(null) } - - fun load() { - loading = true - error = null - scope.launch { - val outcome = if (query.isBlank()) container.radio.popular(genre) - else container.radio.search(query) - outcome.fold( - onSuccess = { stations = it; if (it.isEmpty()) error = "Aucune station" }, - onFailure = { error = it.message ?: "Annuaire indisponible" }, - ) - loading = false - } - } - - LaunchedEffect(genre) { load() } - - // Aucun annuaire n'expose le morceau en cours : il faut ouvrir chaque flux. On s'en tient - // donc aux premières stations affichées, quatre à la fois, et le résultat est conservé. - LaunchedEffect(stations) { - nowPlaying = emptyMap() - stations.take(PROBED_STATIONS).chunked(4).forEach { group -> - val found = group.map { station -> - async { station.id to container.radio.nowPlaying(station.streamUrl) } - }.awaitAll() - nowPlaying = nowPlaying + found.mapNotNull { (id, title) -> - title?.let { id to it } - } - } - } - - val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) - - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { Text("Radio") }, - actions = { - IconButton(onClick = onOpenWatches) { - Icon(Icons.Rounded.Radar, contentDescription = "Morceaux guettés") - } - }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") - } - }, - ) - - if (status.isRecording) { - // La progression n'est publiée que tous les 256 Ko : le chronomètre se rafraîchit - // donc de lui-même, sinon il semblerait figé entre deux paliers. - var tick by remember { mutableStateOf(0L) } - LaunchedEffect(status.startedAt) { - while (true) { - tick = System.currentTimeMillis() - delay(1_000) - } - } - RecordingBanner( - station = status.station!!, - title = status.currentTitle, - waitingFor = status.waitingFor, - elapsedMs = (tick - status.startedAt).coerceAtLeast(0L), - bytes = status.bytes, - onStop = { - RecordingService.stop(context) - onMessage("Enregistrement versé dans la bibliothèque") - }, - ) - } - - OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text("Rechercher une station") }, - leadingIcon = { Icon(Icons.Rounded.Search, contentDescription = null) }, - singleLine = true, - shape = MaterialTheme.shapes.large, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { load() }), - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - ) - - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 16.dp), - ) { - FilterChip( - selected = genre == null, - onClick = { genre = null; query = "" }, - label = { Text("Les plus écoutées") }, - ) - RADIO_GENRES.forEach { option -> - FilterChip( - selected = genre == option, - onClick = { genre = option; query = "" }, - label = { Text(option.replaceFirstChar(Char::uppercase)) }, - ) - } - } - Spacer(Modifier.size(8.dp)) - - when { - loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - CircularProgressIndicator() - } - - error != null -> Box( - Modifier - .fillMaxSize() - .padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = error!!, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - else -> LazyColumn( - contentPadding = listPadding, - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - items(stations, key = { it.id }) { station -> - StationRow( - station = station, - nowPlaying = nowPlaying[station.id], - playing = station.asEntry().id == currentMediaId, - recording = status.station?.id == station.id, - onSchedule = { scheduleFor = station }, - onListen = { - val entry = station.asEntry() - container.library.preview(listOf(entry)) - container.player.play(listOf(entry), 0) - }, - onRecord = { - if (status.isRecording) { - onMessage("Un enregistrement est déjà en cours") - } else { - RecordingService.start(context, station) - onMessage("Enregistrement de ${station.name} démarré") - } - }, - ) - } - item { Spacer(Modifier.size(24.dp)) } - } - } - } - - scheduleFor?.let { station -> - ScheduleDialog( - station = station, - onDismiss = { scheduleFor = null }, - onConfirm = { wanted -> - RecordingService.start(context, station, whenTitleContains = wanted) - onMessage("Noxely guette « $wanted » sur ${station.name}") - scheduleFor = null - }, - ) - } -} - -/** Au-delà, le nombre de connexions simultanées ne se justifierait plus. */ -private const val PROBED_STATIONS = 12 - -/** Une station écoutée en direct est un média éphémère : pas de durée, pas de mise en cache. */ -private fun RadioStation.asEntry() = MediaEntry( - id = "radio:$id", - source = SourceKind.URL, - kind = MediaKind.AUDIO, - title = name, - subtitle = subtitle.ifBlank { "Radio en direct" }, - uri = streamUrl, - artworkUri = faviconUrl, -) - -/** Programme un enregistrement : il démarrera dès que la station annoncera ce titre. */ -@Composable -private fun ScheduleDialog( - station: RadioStation, - onDismiss: () -> Unit, - onConfirm: (String) -> Unit, -) { - var wanted by remember { mutableStateOf("") } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Guetter un titre") }, - text = { - Column { - Text( - text = "Noxely écoutera ${station.name} et déclenchera l'enregistrement dès " + - "qu'un titre contenant ce texte sera annoncé, puis s'arrêtera à la fin du morceau.", - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.size(12.dp)) - OutlinedTextField( - value = wanted, - onValueChange = { wanted = it }, - placeholder = { Text("Titre ou artiste") }, - singleLine = true, - ) - } - }, - confirmButton = { - TextButton(enabled = wanted.isNotBlank(), onClick = { onConfirm(wanted.trim()) }) { - Text("Guetter") - } - }, - dismissButton = { TextButton(onClick = onDismiss) { Text("Annuler") } }, - ) -} - -@Composable -private fun RecordingBanner( - station: RadioStation, - title: String?, - waitingFor: String?, - elapsedMs: Long, - bytes: Long, - onStop: () -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 4.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(12.dp), - ) { - Icon( - Icons.Rounded.FiberManualRecord, - contentDescription = null, - tint = Color(0xFFD32F2F), - ) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text( - text = waitingFor?.let { "En attente de « $it »" } ?: title ?: station.name, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = listOfNotNull( - title?.let { station.name }, - formatDuration(elapsedMs), - formatBytes(bytes), - ).joinToString(" · "), - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Button(onClick = onStop) { - Icon(Icons.Rounded.Stop, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(6.dp)) - Text("Arrêter") - } - } - } -} - -@Composable -private fun StationRow( - station: RadioStation, - nowPlaying: String?, - playing: Boolean, - recording: Boolean, - onListen: () -> Unit, - onRecord: () -> Unit, - onSchedule: () -> Unit, -) { - Surface( - color = if (playing) MaterialTheme.colorScheme.primaryContainer - else MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.medium, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onListen), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(10.dp), - ) { - Artwork( - uri = station.faviconUrl, - title = station.name, - kind = MediaKind.AUDIO, - shape = RoundedCornerShape(12.dp), - modifier = Modifier.size(48.dp), - ) - Spacer(Modifier.width(14.dp)) - Column(Modifier.weight(1f)) { - Text( - text = station.name, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = nowPlaying ?: station.subtitle, - style = MaterialTheme.typography.bodySmall, - color = if (nowPlaying != null) MaterialTheme.colorScheme.secondary - else MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - imageVector = if (playing) Icons.Rounded.GraphicEq else Icons.Rounded.PlayCircleOutline, - contentDescription = if (playing) "En écoute" else "Écouter en direct", - tint = if (playing) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - IconButton(onClick = onSchedule) { - Icon( - Icons.Rounded.Alarm, - contentDescription = "Enregistrer quand un titre passe", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - IconButton(onClick = onRecord) { - Icon( - Icons.Rounded.FiberManualRecord, - contentDescription = "Enregistrer cette station", - tint = if (recording) Color(0xFFD32F2F) else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt deleted file mode 100644 index c5d0370..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/radio/WatchListScreen.kt +++ /dev/null @@ -1,271 +0,0 @@ -package fr.tuxgyver.noxely.ui.radio - -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.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.CheckCircle -import androidx.compose.material.icons.rounded.DeleteOutline -import androidx.compose.material.icons.rounded.HourglassEmpty -import androidx.compose.material.icons.rounded.Radar -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import fr.tuxgyver.noxely.AppContainer -import fr.tuxgyver.noxely.radio.Watch -import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale - -/** - * Les morceaux guettés sur l'ensemble des stations : ceux qu'on attend, et ceux qui sont passés. - * - * Le balayage automatique tourne au quart d'heure — le minimum qu'autorise le système. Un morceau - * durant trois à quatre minutes, il en manque forcément : le bouton « Balayer » sert à regarder - * tout de suite, quand on sait qu'un titre tourne. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun WatchListScreen( - container: AppContainer, - onBack: () -> Unit, - onMessage: (String) -> Unit, - contentPadding: PaddingValues, -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val watches by container.watches.watches.collectAsStateWithLifecycle() - - var query by remember { mutableStateOf("") } - var scanning by remember { mutableStateOf(false) } - val watching by fr.tuxgyver.noxely.radio.WatchService.isRunning.collectAsStateWithLifecycle() - - val pending = watches.filterNot { it.isFound } - val found = watches.filter { it.isFound } - val listPadding = PaddingValues(bottom = contentPadding.calculateBottomPadding()) - - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { Text("Guets") }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Retour") - } - }, - actions = { - if (scanning) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(22.dp).padding(end = 8.dp), - ) - } else if (pending.isNotEmpty()) { - IconButton(onClick = { - scanning = true - scope.launch { - val outcome = runCatching { container.watchScanner.scan(context) }.getOrNull() - scanning = false - onMessage( - when { - outcome == null -> "Balayage impossible" - outcome.matched != null -> - "Trouvé sur ${outcome.matchedOn} : ${outcome.matched}" - else -> - "${outcome.stationsProbed} stations écoutées, ${outcome.titlesRead} titres lus, rien de guetté" - } - ) - } - }) { - Icon(Icons.Rounded.Radar, contentDescription = "Balayer maintenant") - } - } - }, - ) - - if (pending.isNotEmpty()) { - Surface( - color = if (watching) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(14.dp)) { - Column(Modifier.weight(1f)) { - Text( - text = if (watching) "Veille active" else "Veille en arrière-plan", - style = MaterialTheme.typography.titleSmall, - ) - Text( - text = if (watching) { - "Les stations sont balayées toutes les 3 minutes, écran éteint compris." - } else { - "Sans elle, le balayage n'a lieu qu'au quart d'heure : un morceau de " + - "trois minutes passe souvent entre deux." - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.width(10.dp)) - Button(onClick = { - if (watching) fr.tuxgyver.noxely.radio.WatchService.stop(context) - else fr.tuxgyver.noxely.radio.WatchService.start(context) - }) { - Text(if (watching) "Arrêter" else "Activer") - } - } - } - Spacer(Modifier.size(8.dp)) - } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), - ) { - OutlinedTextField( - value = query, - onValueChange = { query = it }, - placeholder = { Text("Titre ou artiste à guetter") }, - singleLine = true, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.weight(1f), - ) - Spacer(Modifier.width(8.dp)) - Button( - enabled = query.isNotBlank(), - onClick = { - // La valeur est capturée avant d'être effacée : la coroutine lirait sinon - // un champ déjà vidé, et n'enregistrerait rien. - val wanted = query.trim() - scope.launch { container.watches.add(wanted) } - onMessage("« $wanted » est guetté sur toutes les stations") - query = "" - }, - ) { - Icon(Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp)) - } - } - - LazyColumn( - contentPadding = listPadding, - verticalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 16.dp), - ) { - if (watches.isEmpty()) { - item { - Box( - Modifier.fillMaxWidth().padding(vertical = 48.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "Nommez un morceau : Noxely écoutera les stations les plus suivies " + - "et l'enregistrera dès qu'il passera.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - } - } - - if (pending.isNotEmpty()) { - item { SectionTitle("En attente (${pending.size})") } - items(pending, key = { it.id }) { watch -> - WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } } - } - } - - if (found.isNotEmpty()) { - item { SectionTitle("Trouvés (${found.size})") } - items(found, key = { it.id }) { watch -> - WatchRow(watch) { scope.launch { container.watches.remove(watch.id) } } - } - } - - item { Spacer(Modifier.size(24.dp)) } - } - } -} - -@Composable -private fun SectionTitle(text: String) { - Spacer(Modifier.size(10.dp)) - Text(text, style = MaterialTheme.typography.titleSmall) -} - -@Composable -private fun WatchRow(watch: Watch, onRemove: () -> Unit) { - Surface( - color = if (watch.isFound) MaterialTheme.colorScheme.secondaryContainer - else MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.medium, - tonalElevation = 1.dp, - modifier = Modifier.fillMaxWidth(), - ) { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(12.dp)) { - Icon( - imageVector = if (watch.isFound) Icons.Rounded.CheckCircle else Icons.Rounded.HourglassEmpty, - contentDescription = null, - tint = if (watch.isFound) MaterialTheme.colorScheme.secondary - else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text( - text = watch.query, - style = MaterialTheme.typography.titleSmall, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = if (watch.isFound) { - "Capté sur ${watch.foundOn} · ${watch.foundTitle?.take(48).orEmpty()}" - } else { - val last = watch.lastScanAt - ?.let { SimpleDateFormat("HH:mm", Locale.FRANCE).format(Date(it)) } - "${watch.scans} balayage(s)" + (last?.let { ", dernier à $it" } ?: ", pas encore balayé") - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton(onClick = onRemove) { - Icon(Icons.Rounded.DeleteOutline, contentDescription = "Retirer ce guet") - } - } - } -} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt new file mode 100644 index 0000000..845b59e --- /dev/null +++ b/app/src/main/java/fr/tuxgyver/noxely/ui/server/ServerScreen.kt @@ -0,0 +1,253 @@ +package fr.tuxgyver.noxely.ui.server + +import androidx.compose.foundation.layout.Arrangement +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CloudSync +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.ScrollableTabRow +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.input.PasswordVisualTransformation +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.ui.sources.UpdateCard +import kotlinx.coroutines.launch +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private val SETTINGS_TABS = listOf("🔌 Connexion", "🔄 Synchronisation", "📱 Application") + +/** + * Réglages, rangés par catégorie. + * + * L'écran mêlait la connexion au serveur, l'état des synchronisations et les affaires de + * l'application elle-même. Trois sujets, trois onglets : on ne défile plus pour trouver. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ServerScreen( + container: AppContainer, + onOpenAbout: () -> Unit, + onMessage: (String) -> Unit, + contentPadding: PaddingValues, +) { + var tab by rememberSaveable { mutableIntStateOf(0) } + val config by container.serverSettings.config.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + + var url by remember { mutableStateOf("") } + var token by remember { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var probe by remember { mutableStateOf(null) } + + LaunchedEffect(config.baseUrl, config.token) { + if (url.isBlank()) url = config.baseUrl + if (token.isBlank()) token = config.token + } + + Column(Modifier.fillMaxSize()) { + Column(Modifier.padding(horizontal = 16.dp).padding(top = 12.dp, bottom = 8.dp)) { + Text("Réglages", style = MaterialTheme.typography.displaySmall) + Text( + text = "Le serveur cherche, guette les radios et enregistre. Le téléphone ne fait " + + "que récupérer les fichiers prêts — c'est ce qui préserve la batterie.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + ScrollableTabRow(selectedTabIndex = tab, edgePadding = 16.dp) { + SETTINGS_TABS.forEachIndexed { index, label -> + Tab( + selected = tab == index, + onClick = { tab = index }, + text = { Text(label, style = MaterialTheme.typography.labelLarge) }, + ) + } + } + + LazyColumn( + contentPadding = contentPadding, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp), + ) { + item { Spacer(Modifier.size(4.dp)) } + + when (tab) { + 0 -> { + item { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(20.dp)) { + OutlinedTextField( + value = url, + onValueChange = { url = it; probe = null }, + label = { Text("Adresse du serveur") }, + placeholder = { Text("http://192.168.1.20:8710") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(10.dp)) + OutlinedTextField( + value = token, + onValueChange = { token = it; probe = null }, + label = { Text("Jeton") }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.size(14.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Button( + enabled = !busy && url.isNotBlank() && token.isNotBlank(), + onClick = { + busy = true + probe = null + scope.launch { + container.sync.probe(url.trim(), token.trim()).fold( + onSuccess = { name -> + container.serverSettings.update { + it.copy(baseUrl = url.trim(), token = token.trim()) + } + probe = "Connecté à $name" + }, + onFailure = { probe = it.message ?: "Échec de connexion" }, + ) + busy = false + } + }, + ) { Text("Tester et enregistrer") } + + Spacer(Modifier.width(10.dp)) + + 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") } + } + } + probe?.let { + Spacer(Modifier.size(10.dp)) + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = if (it.startsWith("Connecté")) MaterialTheme.colorScheme.secondary + else MaterialTheme.colorScheme.error, + ) + } + } + } + } + + + } + + 1 -> { + item { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.CloudSync, contentDescription = null) + Spacer(Modifier.width(14.dp)) + Column { + Text("Synchronisation automatique", style = MaterialTheme.typography.titleSmall) + Text( + text = "Toutes les six heures, en Wi-Fi non facturé et batterie suffisante.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (config.lastSyncAt > 0) { + Text( + text = "Dernière : ${ + SimpleDateFormat("d MMM HH:mm", Locale.FRANCE) + .format(Date(config.lastSyncAt)) + } — ${config.lastResult}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + } + } + } + } + + } + + else -> { + item { UpdateCard(container) } + + item { + Surface( + color = MaterialTheme.colorScheme.surface, + shape = MaterialTheme.shapes.large, + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Row(Modifier.padding(20.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Rounded.Info, contentDescription = null) + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text("À propos", style = MaterialTheme.typography.titleMedium) + Text( + text = "Version ${BuildConfig.VERSION_NAME}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + ) + } + TextButton(onClick = onOpenAbout) { Text("Ouvrir") } + } + } + } + } + } + + item { Spacer(Modifier.size(24.dp)) } + } + } +} diff --git a/app/src/main/java/fr/tuxgyver/noxely/ui/sources/SourcesScreen.kt b/app/src/main/java/fr/tuxgyver/noxely/ui/sources/SourcesScreen.kt deleted file mode 100644 index fc1fdb3..0000000 --- a/app/src/main/java/fr/tuxgyver/noxely/ui/sources/SourcesScreen.kt +++ /dev/null @@ -1,431 +0,0 @@ -package fr.tuxgyver.noxely.ui.sources - -import androidx.compose.foundation.layout.Arrangement -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.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.clickable -import androidx.compose.foundation.lazy.items -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight -import androidx.compose.material.icons.automirrored.rounded.MenuBook -import androidx.compose.material.icons.rounded.Headphones -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.LibraryMusic -import androidx.compose.material.icons.rounded.Public -import androidx.compose.material.icons.rounded.Radio -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.Link -import androidx.compose.material.icons.rounded.Podcasts -import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material.icons.rounded.Smartphone -import androidx.compose.material3.Button -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState -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.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import fr.tuxgyver.noxely.AppContainer -import fr.tuxgyver.noxely.model.SourceKind -import fr.tuxgyver.noxely.sources.CatalogId -import kotlinx.coroutines.launch - -@Composable -fun SourcesScreen( - container: AppContainer, - contentPadding: PaddingValues, - onMessage: (String) -> Unit, - onBrowseSource: (SourceKind) -> Unit, - onBrowseCatalog: (CatalogId) -> Unit, - onBrowseRadio: () -> Unit, - onOpenAbout: () -> Unit, -) { - val entries by container.library.entries.collectAsStateWithLifecycle() - var sheet by remember { mutableStateOf(null) } - - val deviceCount = entries.count { it.source == SourceKind.LOCAL } - val linkCount = entries.count { it.source == SourceKind.URL } - val podcastCount = entries.count { it.source == SourceKind.PODCAST } - - LazyColumn( - contentPadding = contentPadding, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 16.dp), - ) { - item { - Column(Modifier.padding(top = 12.dp)) { - Text("Sources", style = MaterialTheme.typography.displaySmall) - Text( - "D'où vient le contenu que vous gardez hors connexion.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - item { - SourceCard( - icon = Icons.Rounded.Smartphone, - title = "Appareil", - status = "$deviceCount éléments", - description = "Musique et vidéos déjà présentes sur le téléphone. Toujours disponibles.", - actionLabel = "Actualiser", - actionIcon = Icons.Rounded.Refresh, - onAction = { container.library.refreshLocal() }, - onOpen = { onBrowseSource(SourceKind.LOCAL) }, - ) - } - - item { - SourceCard( - icon = Icons.Rounded.Link, - title = "Lien direct", - status = "$linkCount éléments", - description = "Une URL vers un fichier audio ou vidéo. Le fichier est ensuite copié dans la cachette.", - actionLabel = "Ajouter un lien", - onAction = { sheet = ImportKind.URL }, - onOpen = { onBrowseSource(SourceKind.URL) }, - ) - } - - item { - SourceCard( - icon = Icons.Rounded.Podcasts, - title = "Podcast", - status = "$podcastCount épisodes", - description = "Un flux RSS. Les épisodes deviennent téléchargeables un par un.", - actionLabel = "Ajouter un flux", - onAction = { sheet = ImportKind.RSS }, - onOpen = { onBrowseSource(SourceKind.PODCAST) }, - ) - } - - item { - val recordings = entries.count { it.source == SourceKind.RECORDING } - SourceCard( - icon = Icons.Rounded.Radio, - title = "Radio", - status = if (recordings > 0) "$recordings enregistrement(s)" else "Direct et enregistrement", - description = "Les stations les plus écoutées. À suivre en direct, ou à enregistrer pour l'écouter plus tard.", - actionLabel = "Ouvrir la radio", - onAction = onBrowseRadio, - onOpen = onBrowseRadio, - ) - } - - item { - Spacer(Modifier.size(8.dp)) - Text("Catalogues libres de droits", style = MaterialTheme.typography.titleMedium) - Text( - "Rechercher, choisir, emporter. Aucun compte requis.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - items(CatalogId.entries) { catalog -> - SourceCard( - icon = when (catalog) { - CatalogId.LIBRIVOX -> Icons.AutoMirrored.Rounded.MenuBook - CatalogId.WIKIMEDIA -> Icons.Rounded.LibraryMusic - CatalogId.UNDERGROUND -> Icons.Rounded.Headphones - else -> Icons.Rounded.Public - }, - title = catalog.title, - status = catalog.license, - description = catalog.description, - actionLabel = "Parcourir", - actionIcon = Icons.Rounded.Search, - onAction = { onBrowseCatalog(catalog) }, - onOpen = { onBrowseCatalog(catalog) }, - ) - } - - item { - Spacer(Modifier.size(8.dp)) - Text("Services tiers", style = MaterialTheme.typography.titleMedium) - } - - item { - Spacer(Modifier.size(8.dp)) - UpdateCard(container) - } - - item { - SourceCard( - icon = Icons.Rounded.Info, - title = "À propos", - status = "Version ${fr.tuxgyver.noxely.BuildConfig.VERSION_NAME}", - description = "Ce que fait Noxely, ce qu'elle garantit, avec quoi elle est faite et qui en répond.", - actionLabel = "Ouvrir", - onAction = onOpenAbout, - onOpen = onOpenAbout, - ) - } - - unavailableSourceCards( - listOf( - UnavailableSource( - "Spotify", - "Le SDK Spotify ne donne pas accès au flux audio : la lecture hors connexion n'existe que dans l'application Spotify. Un podcast diffusé aussi en RSS public peut en revanche être ajouté ci-dessus.", - ), - UnavailableSource( - "YouTube", - "Les conditions d'utilisation interdisent d'extraire ou de stocker les flux. Le hors-ligne passe par YouTube Premium, dans leur application.", - ), - UnavailableSource( - "Google Drive / Photos", - "Techniquement possible pour vos propres fichiers, mais cela demande un identifiant OAuth Google propre à votre compte développeur, qui n'est pas fourni ici.", - ), - ) - ) - } - - val current = sheet - if (current != null) { - ImportSheet( - kind = current, - container = container, - onDismiss = { sheet = null }, - onMessage = onMessage, - ) - } -} - -private data class UnavailableSource(val title: String, val explanation: String) - -private fun androidx.compose.foundation.lazy.LazyListScope.unavailableSourceCards(sources: List) { - sources.forEach { source -> - item { - Surface( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) { - Row(Modifier.padding(16.dp)) { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.size(12.dp)) - Column { - Text(source.title, style = MaterialTheme.typography.titleSmall) - Spacer(Modifier.size(4.dp)) - Text( - source.explanation, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - } -} - -@Composable -private fun SourceCard( - icon: ImageVector, - title: String, - status: String, - description: String, - actionLabel: String, - actionIcon: ImageVector? = null, - onAction: () -> Unit, - onOpen: () -> Unit, -) { - Surface( - color = MaterialTheme.colorScheme.surface, - shape = MaterialTheme.shapes.large, - tonalElevation = 2.dp, - modifier = Modifier - .fillMaxWidth() - .clickable(onClick = onOpen), - ) { - Column(Modifier.padding(20.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Surface( - color = MaterialTheme.colorScheme.primaryContainer, - shape = MaterialTheme.shapes.small, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.padding(10.dp), - ) - } - Spacer(Modifier.size(14.dp)) - Column(Modifier.weight(1f)) { - Text(title, style = MaterialTheme.typography.titleMedium) - Text( - status, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary, - ) - } - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = "Parcourir", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - Spacer(Modifier.size(10.dp)) - Text( - description, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.size(12.dp)) - Button(onClick = onAction) { - if (actionIcon != null) { - Icon(actionIcon, contentDescription = null, modifier = Modifier.size(18.dp)) - Spacer(Modifier.size(8.dp)) - } - Text(actionLabel) - } - } - } -} - -internal enum class ImportKind(val title: String, val hint: String) { - URL("Ajouter un lien direct", "https://exemple.org/morceau.mp3"), - RSS("Ajouter un flux podcast", "https://exemple.org/podcast.xml"), -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun ImportSheet( - kind: ImportKind, - container: AppContainer, - onDismiss: () -> Unit, - onMessage: (String) -> Unit, -) { - val sheetState = rememberModalBottomSheetState() - val scope = rememberCoroutineScope() - var url by remember { mutableStateOf("") } - var busy by remember { mutableStateOf(false) } - var error by remember { mutableStateOf(null) } - var mode by remember { mutableStateOf(kind) } - - ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { - Column( - Modifier - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp) - .navigationBarsPadding(), - ) { - Text(mode.title, style = MaterialTheme.typography.headlineSmall) - Spacer(Modifier.size(16.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - ImportKind.entries.forEach { option -> - FilterChip( - selected = mode == option, - onClick = { mode = option; error = null }, - label = { Text(if (option == ImportKind.URL) "Lien direct" else "Flux podcast") }, - ) - } - } - - Spacer(Modifier.size(16.dp)) - OutlinedTextField( - value = url, - onValueChange = { url = it; error = null }, - placeholder = { Text(mode.hint) }, - singleLine = true, - isError = error != null, - shape = MaterialTheme.shapes.medium, - modifier = Modifier.fillMaxWidth(), - ) - error?.let { - Spacer(Modifier.size(8.dp)) - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - - Spacer(Modifier.size(20.dp)) - Row( - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - TextButton(onClick = onDismiss, enabled = !busy) { Text("Annuler") } - Spacer(Modifier.size(8.dp)) - Button( - enabled = !busy && url.isNotBlank(), - onClick = { - busy = true - error = null - scope.launch { - val outcome = runImport(container, mode, url) - busy = false - outcome.fold( - onSuccess = { message -> - onMessage(message) - onDismiss() - }, - onFailure = { error = it.message ?: "Import impossible" }, - ) - } - }, - ) { - if (busy) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(18.dp), - ) - Spacer(Modifier.size(8.dp)) - } - Text("Importer") - } - } - } - } -} - -private suspend fun runImport( - container: AppContainer, - kind: ImportKind, - url: String, -): Result = when (kind) { - ImportKind.URL -> container.urlImporter.import(url).mapCatching { entry -> - val added = container.imported.add(listOf(entry)) - if (added == 0) "« ${entry.title} » est déjà dans la bibliothèque" - else "« ${entry.title} » ajouté" - } - - ImportKind.RSS -> container.rssImporter.import(url).mapCatching { feed -> - val added = container.imported.add(feed.episodes) - if (added == 0) "${feed.title} : aucun nouvel épisode" - else "${feed.title} : $added épisode(s) ajouté(s)" - } -} diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..054eef0 --- /dev/null +++ b/server/.env.example @@ -0,0 +1,19 @@ +# Jeton partagé entre le serveur et l'application Android. Sans lui, l'API refuse tout. +# Générer par exemple avec : openssl rand -base64 24 +NOXELY_TOKEN= + +# Où sont rangés la base et les fichiers média. +# Par défaut dans le projet, pour que `make server-run` fonctionne sans droits particuliers. +# En service systemd, /var/lib/noxely est plus propre : l'unité le crée elle-même +# (StateDirectory), inutile de le préparer à la main. +NOXELY_DATA=./data + +# Interface et port d'écoute. +NOXELY_HOST=0.0.0.0 +NOXELY_PORT=8710 + +# Balayage des radios. Il est continu : un tour enchaîne sur le suivant. +# NOXELY_WATCH_INTERVAL n'est qu'une pause de sécurité entre deux tours. +NOXELY_WATCH_STATIONS=40 +NOXELY_WATCH_INTERVAL=5 +NOXELY_WATCH_CONCURRENCY=8 diff --git a/server/README.md b/server/README.md new file mode 100644 index 0000000..00f68bc --- /dev/null +++ b/server/README.md @@ -0,0 +1,44 @@ +# 🖥️ Noxely — serveur + +Le serveur porte tout ce qui coûtait de la batterie au téléphone : la recherche dans les +catalogues, le guet des radios, l'enregistrement et le téléchargement. Le mobile ne fait plus que +récupérer des fichiers prêts. + +## Installation + +```bash +make server-venv # crée le venv et installe les dépendances +cp server/.env.example server/.env # puis renseigner NOXELY_TOKEN +make server-run # lancement au premier plan, pour essayer +``` + +L'interface de pilotage est à `http://:8710` — recherche, guets, file et bibliothèque. +Le jeton se saisit une fois dans l'en-tête de la page. + +## En service + +```bash +make server-install # unité systemd (sudo demandé) +make server-status # état +make server-logs # journal suivi +``` + +## API + +| Route | Qui l'utilise | +|---|---| +| `GET /api/health` | tout le monde, sans jeton | +| `GET /api/library?since=` | **le mobile** — uniquement ce qui a changé | +| `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 | + +Toutes les routes sauf `/api/health` exigent l'en-tête `X-Noxely-Token`. + +## Données + +``` +$NOXELY_DATA/ +├── noxely.sqlite3 index des médias et des guets +└── media/ les fichiers eux-mêmes +``` diff --git a/server/deploy/noxely-server.service b/server/deploy/noxely-server.service new file mode 100644 index 0000000..7d9f9fe --- /dev/null +++ b/server/deploy/noxely-server.service @@ -0,0 +1,27 @@ +# Noxely — serveur : recherche, guet radio, enregistrement et mise à disposition hors ligne. +# Installé par `make server-install` : les marqueurs @...@ sont remplacés à l'installation, +# jamais figés dans le dépôt — le chemin et l'utilisateur diffèrent d'une machine à l'autre. + +[Unit] +Description=Noxely - serveur de collecte et de synchronisation +Documentation=file://@PROJECT@/server/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=@USER@ +WorkingDirectory=@PROJECT@/server +# NOXELY_TOKEN, NOXELY_DATA, NOXELY_HOST, NOXELY_PORT +EnvironmentFile=@PROJECT@/server/.env +Environment=PYTHONUNBUFFERED=1 +# Crée /var/lib/noxely avec les bons droits : rien à préparer à la main. +StateDirectory=noxely +ExecStart=@PROJECT@/server/venv/bin/python -m noxely_server +Restart=on-failure +RestartSec=10 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/server/noxely_server/__init__.py b/server/noxely_server/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/noxely_server/__main__.py b/server/noxely_server/__main__.py new file mode 100644 index 0000000..932dbdd --- /dev/null +++ b/server/noxely_server/__main__.py @@ -0,0 +1,44 @@ +"""Point d'entrée : `python -m noxely_server`.""" +from __future__ import annotations + +import sys + +import uvicorn + +from .config import load + + +def ensure_usable(data_dir) -> None: + """Le dossier de données est la première chose qui peut manquer : on le dit clairement.""" + try: + data_dir.mkdir(parents=True, exist_ok=True) + probe = data_dir / ".ecriture" + probe.touch() + probe.unlink() + except PermissionError: + print( + f"[noxely] Droits insuffisants sur {data_dir}.\n" + f" Choisissez un dossier accessible dans server/.env (NOXELY_DATA=./data),\n" + f" ou créez-le : sudo install -d -o $(whoami) {data_dir}", + file=sys.stderr, + ) + raise SystemExit(1) + except OSError as error: + print(f"[noxely] Dossier de données inutilisable ({data_dir}) : {error}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + config = load() + ensure_usable(config.data_dir) + + # Importé après la vérification : l'application ouvre la base dès son chargement. + from .api import app + + print(f"[noxely] données : {config.data_dir}") + print(f"[noxely] écoute : http://{config.host}:{config.port}") + uvicorn.run(app, host=config.host, port=config.port, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/server/noxely_server/api.py b/server/noxely_server/api.py new file mode 100644 index 0000000..c356fb0 --- /dev/null +++ b/server/noxely_server/api.py @@ -0,0 +1,198 @@ +"""API HTTP du serveur Noxely. + +Le client Android ne consomme que `/api/health`, `/api/library` et `/api/media/{id}/file` : +tout le reste sert au pilotage depuis un navigateur. Cette séparation est volontaire — le +mobile doit pouvoir se contenter d'un sondage bref, c'est là qu'est le gain de batterie. +""" +from __future__ import annotations + +import asyncio +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 . 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" +WEB_DIR = Path(__file__).parent / "web" + +config = config_module.load() +storage = Storage(config.db_path) +downloader = Downloader(config, storage) +watcher = Watcher(config, storage) + + +def authorised(x_noxely_token: str = Header(default="")) -> None: + """Sans jeton valide, le serveur ne répond rien : il expose des fichiers.""" + if x_noxely_token != config.token: + raise HTTPException(status_code=401, detail="Jeton invalide") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + tasks = [ + asyncio.create_task(downloader.run_forever()), + asyncio.create_task(watcher.run_forever()), + ] + yield + for task in tasks: + task.cancel() + + +app = FastAPI(title="Noxely", version=VERSION, lifespan=lifespan) + + +# --- Ce que consomme le mobile ------------------------------------------ + +@app.get("/api/health") +def health() -> dict: + return {"name": "Noxely", "version": VERSION} + + +@app.get("/api/library", dependencies=[Depends(authorised)]) +def library(since: int = Query(default=0)) -> dict: + """Uniquement ce qui a changé depuis le dernier passage du client.""" + items = storage.media_since(since) + return { + "items": [m.public() for m in items], + "cursor": items[-1].updated_at if items else since, + } + + +@app.get("/api/media/{media_id}/file", dependencies=[Depends(authorised)]) +def media_file(media_id: str): + item = storage.media(media_id) + if not item or item.state != "ready": + raise HTTPException(status_code=404, detail="Média indisponible") + path = config.media_dir / item.filename + if not path.exists(): + raise HTTPException(status_code=404, detail="Fichier absent") + return FileResponse(path, media_type=item.mime, filename=item.filename) + + +@app.delete("/api/media/{media_id}", dependencies=[Depends(authorised)]) +def delete_media(media_id: str) -> dict: + item = storage.delete_media(media_id) + if item: + (config.media_dir / item.filename).unlink(missing_ok=True) + return {"deleted": bool(item)} + + +# --- Pilotage depuis le navigateur -------------------------------------- + +@app.get("/api/catalogs", dependencies=[Depends(authorised)]) +def list_catalogs() -> dict: + return { + "catalogs": {"all": "Toutes les sources", **catalogs.CATALOGS}, + "genres": radio.GENRES, + } + + +@app.get("/api/search", dependencies=[Depends(authorised)]) +async def search(catalog: str, q: str, language: str | None = None) -> dict: + try: + if catalog == "all": + return {"results": await catalogs.search_all(q, language)} + return {"results": await catalogs.search(catalog, q, language)} + except Exception as error: + raise HTTPException(status_code=502, detail=str(error)[:200]) + + +@app.get("/api/tracks", dependencies=[Depends(authorised)]) +async def tracks(catalog: str, id: str, title: str = "", kind: str = "Audio") -> dict: + try: + return {"tracks": await catalogs.tracks(catalog, id, title, kind)} + except Exception as error: + raise HTTPException(status_code=502, detail=str(error)[:200]) + + +@app.post("/api/queue", dependencies=[Depends(authorised)]) +def queue(payload: dict = Body(...)) -> dict: + """Met des pistes en file : le téléchargement se fait en tâche de fond.""" + added = 0 + for track in payload.get("tracks", []): + url = track.get("url") + if not url: + continue + extension = url.split("?")[0].rsplit(".", 1)[-1].lower() + if extension not in ("mp3", "m4a", "ogg", "opus", "flac", "wav", "mp4", "webm"): + extension = "mp3" + storage.add_media( + title=track.get("title") or "Sans titre", + artist=track.get("artist") or "", + source=payload.get("catalog") or "catalogue", + origin=payload.get("origin") or "", + filename=safe_name(track.get("title") or "media", extension), + remote_url=url, + duration_ms=int(track.get("duration_ms") or 0), + artwork_url=track.get("artwork_url"), + ) + added += 1 + return {"queued": added} + + +@app.get("/api/radio/stations", dependencies=[Depends(authorised)]) +async def radio_stations(genre: str | None = None, name: str | None = None) -> dict: + return {"stations": await radio.stations(genre, name)} + + +@app.get("/api/watches", dependencies=[Depends(authorised)]) +def watches() -> dict: + return { + "watches": storage.watches(), + "recording": watcher.recording, + "sweeps": watcher.sweeps, + "last_sweep": watcher.last_sweep, + } + + +@app.post("/api/watches", dependencies=[Depends(authorised)]) +def add_watch(payload: dict = Body(...)) -> dict: + query = (payload.get("query") or "").strip() + if not query: + raise HTTPException(status_code=400, detail="Requête vide") + return {"id": storage.add_watch(query)} + + +@app.delete("/api/watches/{watch_id}", dependencies=[Depends(authorised)]) +def delete_watch(watch_id: str) -> dict: + storage.delete_watch(watch_id) + return {"deleted": True} + + +@app.post("/api/watches/sweep", dependencies=[Depends(authorised)]) +async def sweep() -> dict: + try: + return await watcher.sweep() + except Exception as error: + # L'annuaire peut être momentanément injoignable : le dire, pas renvoyer une trace. + raise HTTPException(status_code=502, detail=f"Annuaire indisponible : {str(error)[:120]}") + + +@app.get("/api/queue", dependencies=[Depends(authorised)]) +def queue_state() -> dict: + pending = storage.media_since(0, state=None) + return {"items": [m.public() for m in pending if m.state != "ready"][:50]} + + +@app.get("/api/bootstrap") +def bootstrap(request: Request) -> dict: + """Livre le jeton — uniquement à une requête venue de la machine elle-même. + + Depuis un autre poste, il faut le saisir : c'est précisément ce que le jeton protège. + `make server-token` l'affiche pour le copier. + """ + client = request.client.host if request.client else "" + if client not in ("127.0.0.1", "::1", "localhost"): + raise HTTPException(status_code=403, detail="Jeton lisible seulement en local") + return {"token": config.token} + + +@app.get("/", response_class=HTMLResponse) +def index() -> str: + return (WEB_DIR / "index.html").read_text(encoding="utf-8") diff --git a/server/noxely_server/config.py b/server/noxely_server/config.py new file mode 100644 index 0000000..c298ca5 --- /dev/null +++ b/server/noxely_server/config.py @@ -0,0 +1,67 @@ +"""Configuration du serveur Noxely, lue de l'environnement. + +Aucune valeur sensible n'est écrite dans le dépôt : le jeton et les chemins viennent d'un +fichier .env local, à l'image des autres services de la maison. +""" +from __future__ import annotations + +import os +import secrets +from dataclasses import dataclass +from pathlib import Path + + +def _persisted_token(data_dir: Path) -> str: + """Jeton auto-généré, conservé entre deux démarrages. + + Un jeton tiré à chaque lancement obligerait à reconfigurer le téléphone après le moindre + redémarrage : il est donc écrit une fois, en lecture seule pour son propriétaire. + """ + path = data_dir / "token" + if path.exists(): + existing = path.read_text().strip() + if existing: + return existing + token = secrets.token_urlsafe(24) + data_dir.mkdir(parents=True, exist_ok=True) + path.write_text(token + "\n") + path.chmod(0o600) + print(f"[noxely] jeton généré et conservé dans {path}") + return token + + +@dataclass(frozen=True) +class Config: + data_dir: Path + token: str + host: str + port: int + #: Nombre de stations balayées à chaque passage du guet. + watch_stations: int + #: Pause entre deux balayages, en secondes. Le balayage est continu : cette pause n'est + #: qu'un garde-fou pour ne pas marteler l'annuaire si un tour échoue instantanément. + watch_interval: int + #: Stations sondées en parallèle. Plus il est haut, plus un tour complet est bref. + watch_concurrency: int + + @property + def media_dir(self) -> Path: + return self.data_dir / "media" + + @property + def db_path(self) -> Path: + return self.data_dir / "noxely.sqlite3" + + +def load() -> Config: + data_dir = Path(os.environ.get("NOXELY_DATA", "./data")).expanduser().resolve() + token = os.environ.get("NOXELY_TOKEN", "").strip() or _persisted_token(data_dir) + return Config( + data_dir=data_dir, + token=token, + host=os.environ.get("NOXELY_HOST", "0.0.0.0"), + port=int(os.environ.get("NOXELY_PORT", "8710")), + 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")), + ) diff --git a/server/noxely_server/sources/__init__.py b/server/noxely_server/sources/__init__.py new file mode 100644 index 0000000..3075992 --- /dev/null +++ b/server/noxely_server/sources/__init__.py @@ -0,0 +1,6 @@ +"""Catalogues libres et annuaire radio, portés depuis le client Android. + +Les pièges vérifiés côté mobile restent valables ici et sont conservés en commentaire : +LibriVox ignore son paramètre `language`, archive.org sert des éléments en accès restreint, +et un titre ICY court jusqu'au « '; » et non jusqu'à la première apostrophe. +""" diff --git a/server/noxely_server/sources/catalogs.py b/server/noxely_server/sources/catalogs.py new file mode 100644 index 0000000..85f7b42 --- /dev/null +++ b/server/noxely_server/sources/catalogs.py @@ -0,0 +1,235 @@ +"""Recherche dans les catalogues libres de droits.""" +from __future__ import annotations + +import asyncio +import re +import urllib.parse + +import httpx + +# Wikimedia refuse (403) les agents génériques : sa politique demande un nom identifiable +# et un moyen de contact. Les autres services s'en accommodent aussi. +UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"} +TIMEOUT = httpx.Timeout(30.0, connect=15.0) + +AUDIO_EXT = (".mp3", ".m4a", ".ogg", ".opus", ".flac", ".wav") +VIDEO_EXT = (".mp4", ".m4v", ".webm", ".ogv") + +CATALOGS = { + "archive": "Internet Archive", + "underground": "Underground (netlabels)", + "librivox": "LibriVox", + "wikimedia": "Wikimedia Commons", +} + + +async def search_all(query: str, language: str | None = None) -> list[dict]: + """Interroge les quatre catalogues en parallèle et fond les résultats. + + Chaque ligne porte son catalogue d'origine : c'est lui qu'il faudra pour en lister les + pistes. Un catalogue en panne n'empêche pas les autres de répondre — on préfère une liste + incomplète à une erreur globale. + """ + names = list(CATALOGS) + outcomes = await asyncio.gather( + *(search(name, query, language) for name in names), return_exceptions=True + ) + merged: list[dict] = [] + for name, outcome in zip(names, outcomes): + if isinstance(outcome, Exception): + continue + for item in outcome: + merged.append({**item, "catalog": name}) + # Les résultats les plus légers d'abord : sur un téléphone, la taille prime. + merged.sort(key=lambda item: item["size"] or 1 << 62) + return merged + + +async def search(catalog: str, query: str, language: str | None = None) -> list[dict]: + if catalog == "librivox": + return await _librivox(query, language) + if catalog == "wikimedia": + return await _wikimedia(query) + collections = ["netlabels", "freemusicarchive"] if catalog == "underground" else [] + return await _archive(query, language, collections) + + +async def tracks(catalog: str, item_id: str, title: str = "", kind: str = "Audio") -> list[dict]: + """Les fichiers d'un résultat : un album en a plusieurs, un morceau un seul.""" + if catalog == "librivox": + return await _librivox_tracks(item_id) + if catalog == "wikimedia": + return [{"title": title or item_id, "artist": "Wikimedia Commons", "url": item_id, "size": 0}] + return await _archive_tracks(item_id, title, kind) + + +# --- archive.org -------------------------------------------------------- + +async def _archive(query: str, language: str | None, collections: list[str]) -> list[dict]: + clauses = [f"({query})", "mediatype:(audio OR movies)"] + if language: + clauses.append(f"language:({language})") + if collections: + clauses.append("collection:(" + " OR ".join(collections) + ")") + # Les éléments restreints répondent 401 au téléchargement : écartés dès la recherche. + clauses.append("NOT access-restricted-item:true") + + params = { + "q": " AND ".join(clauses), + "fl[]": ["identifier", "title", "creator", "mediatype", "item_size"], + "rows": "30", "page": "1", "output": "json", + } + async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: + response = await client.get("https://archive.org/advancedsearch.php", params=params) + response.raise_for_status() + docs = response.json().get("response", {}).get("docs", []) + + return [ + { + "id": doc["identifier"], + "title": _first(doc.get("title")) or doc["identifier"], + "artist": _first(doc.get("creator")) or "Internet Archive", + "kind": "Vidéo" if doc.get("mediatype") == "movies" else "Audio", + "catalog": "archive", + "size": int(doc.get("item_size") or 0), + } + for doc in docs if doc.get("identifier") + ] + + +async def _archive_tracks(identifier: str, title: str, kind: str) -> list[dict]: + async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client: + response = await client.get(f"https://archive.org/metadata/{identifier}") + response.raise_for_status() + meta = response.json() + + if str(meta.get("metadata", {}).get("access-restricted-item", "")).lower() == "true": + raise ValueError(f"« {title or identifier} » est en accès restreint") + + wants_video = kind == "Vidéo" + extensions = VIDEO_EXT if wants_video else AUDIO_EXT + files = [f for f in meta.get("files", []) if f.get("name", "").lower().endswith(extensions)] + if not files: + raise ValueError("Aucun fichier lisible dans cet élément") + + # Un élément expose l'original et ses dérivés : on garde le plus léger de chaque morceau, + # autant de pistes que d'œuvres et non que de formats. + groups: dict[str, dict] = {} + for entry in files: + stem = entry["name"].rsplit(".", 1)[0].lower() + size = int(entry.get("size") or 1 << 62) + if stem not in groups or size < int(groups[stem].get("size") or 1 << 62): + groups[stem] = entry + + out = [] + for entry in sorted(groups.values(), key=lambda f: f["name"]): + name = entry["name"] + out.append({ + "title": entry.get("title") or name.rsplit(".", 1)[0], + "artist": title or identifier, + "url": f"https://archive.org/download/{identifier}/" + urllib.parse.quote(name), + "size": int(entry.get("size") or 0), + "duration_ms": int(float(entry.get("length") or 0) * 1000), + "artwork_url": f"https://archive.org/services/img/{identifier}", + }) + return out + + +# --- LibriVox ----------------------------------------------------------- + +async def _librivox(query: str, language: str | None) -> list[dict]: + term = urllib.parse.quote(query.strip()) + words = query.strip().split() + # `title` est une correspondance exacte sans « ^ », et `author` ne matche que le nom de + # famille : trois interrogations valent mieux qu'une qui échouerait. + criteria = [f"title=^{term}", f"author={term}"] + if len(words) > 1: + criteria.append("author=" + urllib.parse.quote(words[-1])) + + seen: dict[str, dict] = {} + limit = 25 if not language else 100 + async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: + for criterion in criteria: + url = f"https://librivox.org/api/feed/audiobooks/?format=json&limit={limit}&{criterion}" + response = await client.get(url) + if response.status_code == 404: # « aucun résultat », pas une panne + continue + response.raise_for_status() + for book in response.json().get("books", []): + # Le paramètre `language` de l'API est accepté puis ignoré : filtrage ici. + if language and (book.get("language") or "").lower() != language.lower(): + continue + feed = book.get("url_rss") + if feed and feed not in seen: + authors = ", ".join( + " ".join(filter(None, [a.get("first_name"), a.get("last_name")])) + for a in book.get("authors", []) + ) + seen[feed] = { + "id": feed, + "title": book.get("title") or "Livre audio", + "artist": authors or "LibriVox", + "kind": "Audio", + "size": 0, + "catalog": "librivox", + } + return list(seen.values()) + + +async def _librivox_tracks(feed_url: str) -> list[dict]: + async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT, follow_redirects=True) as client: + response = await client.get(feed_url) + response.raise_for_status() + xml = response.text + + title = re.search(r"(.*?)", xml, re.S) + book = (title.group(1).strip() if title else "Livre audio") + out = [] + for item in re.findall(r"(.*?)", xml, re.S): + url = re.search(r']*url="([^"]+)"', item) + name = re.search(r"(.*?)", item, re.S) + if url: + out.append({ + "title": (name.group(1).strip() if name else "Épisode"), + "artist": book, + "url": url.group(1), + "size": 0, + }) + return out + + +# --- Wikimedia Commons -------------------------------------------------- + +async def _wikimedia(query: str) -> list[dict]: + params = { + "action": "query", "format": "json", "generator": "search", + "gsrsearch": f"filetype:audio {query.strip()}", "gsrnamespace": "6", "gsrlimit": "30", + "prop": "imageinfo", "iiprop": "url|size|mime|extmetadata", + } + async with httpx.AsyncClient(headers=UA, timeout=TIMEOUT) as client: + response = await client.get("https://commons.wikimedia.org/w/api.php", params=params) + response.raise_for_status() + pages = response.json().get("query", {}).get("pages", {}) + + out = [] + for page in pages.values(): + info = (page.get("imageinfo") or [{}])[0] + url = info.get("url") + if not url or not url.split("?")[0].lower().endswith(AUDIO_EXT): + continue + licence = (info.get("extmetadata", {}).get("LicenseShortName", {}) or {}).get("value") + out.append({ + "id": url, + "title": page.get("title", "").removeprefix("File:").rsplit(".", 1)[0], + "artist": licence or "Wikimedia Commons", + "kind": "Audio", + "size": int(info.get("size") or 0), + "catalog": "wikimedia", + }) + return out + + +def _first(value): + if isinstance(value, list): + return value[0] if value else None + return value diff --git a/server/noxely_server/sources/radio.py b/server/noxely_server/sources/radio.py new file mode 100644 index 0000000..99a05f9 --- /dev/null +++ b/server/noxely_server/sources/radio.py @@ -0,0 +1,92 @@ +"""Annuaire des stations et lecture des métadonnées ICY.""" +from __future__ import annotations + +import asyncio +import re +import urllib.parse + +import httpx + +UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"} +BASE = "https://de1.api.radio-browser.info/json/stations/search" + +#: Un titre ICY court jusqu'au « '; » : les apostrophes y sont trop fréquentes pour s'arrêter +#: à la première (« Ain't That a Kick in the Head » deviendrait « Ain »). +STREAM_TITLE = re.compile(r"StreamTitle='(.*?)';") + +GENRES = ["pop", "rock", "jazz", "classical", "electronic", "news", "chanson"] + + +async def stations(genre: str | None = None, name: str | None = None, limit: int = 60) -> list[dict]: + params = {"limit": str(limit), "hidebroken": "true", "order": "clickcount", "reverse": "true"} + if genre: + params["tag"] = genre + if name: + params["name"] = name + + # L'annuaire est un service communautaire : une coupure passagère ne doit pas faire + # tomber le balayage. Mesuré en conditions réelles, d'où cette seconde tentative. + rows = None + async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(30.0)) as client: + for attempt in range(2): + try: + response = await client.get(BASE, params=params) + response.raise_for_status() + rows = response.json() + break + except Exception: + if attempt == 1: + raise + await asyncio.sleep(2) + + out = [] + for row in rows: + url = row.get("url_resolved") or "" + # Les flux HLS se lisent mais ne s'enregistrent pas en copiant les octets. + if int(row.get("hls") or 0) != 0 or not url.startswith("http"): + continue + out.append({ + "id": row.get("stationuuid") or url, + "name": (row.get("name") or "Station").strip(), + "url": url, + "codec": row.get("codec") or "", + "bitrate": int(row.get("bitrate") or 0), + "country": row.get("country") or "", + }) + return out + + +async def now_playing(stream_url: str) -> str | None: + """Le morceau diffusé à l'instant : aucun annuaire ne l'expose, il faut ouvrir le flux.""" + headers = {**UA, "Icy-MetaData": "1"} + try: + async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(10.0)) as client: + async with client.stream("GET", stream_url) as response: + if response.status_code >= 300: + return None + interval = int(response.headers.get("icy-metaint") or 0) + if interval <= 0: + return None + return await _read_title(response, interval) + except Exception: + return None + + +async def _read_title(response, interval: int) -> str | None: + # Client asynchrone : `iter_bytes` n'itère rien ici, il faut `aiter_bytes`. + buffer = bytearray() + needed = interval + 1 + async for chunk in response.aiter_bytes(): + buffer.extend(chunk) + if len(buffer) < needed: + continue + length = buffer[interval] * 16 + if length == 0: + return None + needed = interval + 1 + length + if len(buffer) < needed: + continue + meta = bytes(buffer[interval + 1:needed]).decode("utf-8", "replace") + found = STREAM_TITLE.search(meta) + return found.group(1).strip() or None if found else None + return None diff --git a/server/noxely_server/storage.py b/server/noxely_server/storage.py new file mode 100644 index 0000000..24c82dc --- /dev/null +++ b/server/noxely_server/storage.py @@ -0,0 +1,179 @@ +"""Index des médias et des guets, en SQLite. + +Les fichiers vivent sur le disque ; la base ne porte que leurs métadonnées et l'état de la +synchronisation. Un client ne demande que ce qui a changé depuis son dernier passage, d'où le +champ `updated_at` sur chaque ligne. +""" +from __future__ import annotations + +import sqlite3 +import time +import uuid +from dataclasses import asdict, dataclass +from pathlib import Path + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS media ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + artist TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL, + origin TEXT NOT NULL DEFAULT '', + filename TEXT NOT NULL, + mime TEXT NOT NULL DEFAULT 'audio/mpeg', + size INTEGER NOT NULL DEFAULT 0, + duration_ms INTEGER NOT NULL DEFAULT 0, + artwork_url TEXT, + state TEXT NOT NULL DEFAULT 'pending', + error TEXT, + remote_url TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS media_updated ON media(updated_at); + +CREATE TABLE IF NOT EXISTS watches ( + id TEXT PRIMARY KEY, + query TEXT NOT NULL, + created_at INTEGER NOT NULL, + scans INTEGER NOT NULL DEFAULT 0, + last_scan INTEGER, + found_at INTEGER, + found_on TEXT, + found_title TEXT +); +""" + + +@dataclass +class Media: + id: str + title: str + artist: str + source: str + origin: str + filename: str + mime: str + size: int + duration_ms: int + artwork_url: str | None + state: str + error: str | None + remote_url: str | None + created_at: int + updated_at: int + + def public(self) -> dict: + """Vue destinée au client : ni chemin local, ni URL d'origine.""" + data = asdict(self) + data.pop("remote_url") + data.pop("filename") + return data + + +def now() -> int: + return int(time.time() * 1000) + + +class Storage: + def __init__(self, db_path: Path) -> None: + db_path.parent.mkdir(parents=True, exist_ok=True) + self._db = sqlite3.connect(db_path, check_same_thread=False) + self._db.row_factory = sqlite3.Row + self._db.executescript(SCHEMA) + self._db.commit() + + # --- Médias --------------------------------------------------------- + + def add_media(self, *, title: str, artist: str, source: str, origin: str, + filename: str, remote_url: str | None, mime: str = "audio/mpeg", + duration_ms: int = 0, artwork_url: str | None = None, + state: str = "pending") -> str: + media_id = uuid.uuid4().hex + stamp = now() + self._db.execute( + "INSERT INTO media (id,title,artist,source,origin,filename,mime,size,duration_ms," + "artwork_url,state,error,remote_url,created_at,updated_at) " + "VALUES (?,?,?,?,?,?,?,0,?,?,?,NULL,?,?,?)", + (media_id, title, artist, source, origin, filename, mime, duration_ms, + artwork_url, state, remote_url, stamp, stamp), + ) + self._db.commit() + return media_id + + def update_media(self, media_id: str, **fields) -> None: + if not fields: + return + fields["updated_at"] = now() + assignments = ", ".join(f"{key} = ?" for key in fields) + self._db.execute( + f"UPDATE media SET {assignments} WHERE id = ?", (*fields.values(), media_id) + ) + self._db.commit() + + def media(self, media_id: str) -> Media | None: + row = self._db.execute("SELECT * FROM media WHERE id = ?", (media_id,)).fetchone() + return Media(**dict(row)) if row else None + + def media_since(self, since: int = 0, state: str | None = "ready") -> list[Media]: + query = "SELECT * FROM media WHERE updated_at > ?" + params: list = [since] + if state: + query += " AND state = ?" + params.append(state) + query += " ORDER BY updated_at ASC" + return [Media(**dict(r)) for r in self._db.execute(query, params)] + + def pending_downloads(self) -> list[Media]: + return [ + Media(**dict(r)) + for r in self._db.execute( + "SELECT * FROM media WHERE state IN ('pending','failed') AND remote_url IS NOT NULL " + "ORDER BY created_at ASC LIMIT 20" + ) + ] + + def delete_media(self, media_id: str) -> Media | None: + item = self.media(media_id) + if item: + self._db.execute("DELETE FROM media WHERE id = ?", (media_id,)) + self._db.commit() + return item + + # --- Guets ---------------------------------------------------------- + + def add_watch(self, query: str) -> str: + watch_id = uuid.uuid4().hex + self._db.execute( + "INSERT INTO watches (id,query,created_at) VALUES (?,?,?)", + (watch_id, query.strip(), now()), + ) + self._db.commit() + return watch_id + + def watches(self) -> list[dict]: + return [dict(r) for r in self._db.execute("SELECT * FROM watches ORDER BY created_at DESC")] + + def pending_watches(self) -> list[dict]: + return [dict(r) for r in self._db.execute("SELECT * FROM watches WHERE found_at IS NULL")] + + def mark_watch_found(self, watch_id: str, station: str, title: str) -> None: + self._db.execute( + "UPDATE watches SET found_at=?, found_on=?, found_title=? WHERE id=?", + (now(), station, title, watch_id), + ) + self._db.commit() + + def mark_watches_scanned(self, ids: list[str]) -> None: + if not ids: + return + stamp = now() + self._db.executemany( + "UPDATE watches SET scans = scans + 1, last_scan = ? WHERE id = ?", + [(stamp, i) for i in ids], + ) + self._db.commit() + + def delete_watch(self, watch_id: str) -> None: + self._db.execute("DELETE FROM watches WHERE id = ?", (watch_id,)) + self._db.commit() diff --git a/server/noxely_server/web/index.html b/server/noxely_server/web/index.html new file mode 100644 index 0000000..bdb21e6 --- /dev/null +++ b/server/noxely_server/web/index.html @@ -0,0 +1,267 @@ + + + + + +Noxely — serveur + + + +
+

🎧 Noxely — serveur

+ … + + + +
+
+
+ + + + +
+ + + + + + + +
+ + + + diff --git a/server/noxely_server/workers.py b/server/noxely_server/workers.py new file mode 100644 index 0000000..2f05880 --- /dev/null +++ b/server/noxely_server/workers.py @@ -0,0 +1,188 @@ +"""Tâches de fond : file de téléchargement, guet radio et enregistrement. + +C'est ici que se déplace la consommation qui pesait sur le téléphone : le serveur balaie les +stations et télécharge, le mobile ne fait plus que récupérer des fichiers prêts. +""" +from __future__ import annotations + +import asyncio +import re +import time +from pathlib import Path + +import httpx + +from .config import Config +from .sources import radio +from .storage import Storage + +UA = {"User-Agent": "Noxely/1.0 (https://git.tuxtech.fr/tuxgyver/Noxely)"} + + +def safe_name(text: str, extension: str) -> str: + cleaned = re.sub(r"[^\w \-.]", "", text).strip().replace(" ", "_")[:80] or "media" + return f"{cleaned}.{extension}" + + +class Downloader: + """Vide la file des médias en attente, un à la fois.""" + + def __init__(self, config: Config, storage: Storage) -> None: + self.config = config + self.storage = storage + self.config.media_dir.mkdir(parents=True, exist_ok=True) + + async def run_forever(self) -> None: + while True: + try: + await self.drain() + except Exception as error: # une panne ne doit pas arrêter la boucle + print(f"[noxely] téléchargement : {error}") + await asyncio.sleep(10) + + async def drain(self) -> None: + for item in self.storage.pending_downloads(): + await self.fetch(item.id, item.remote_url, item.filename) + + async def fetch(self, media_id: str, url: str, filename: str) -> None: + target = self.config.media_dir / filename + partial = target.with_suffix(target.suffix + ".part") + self.storage.update_media(media_id, state="downloading", error=None) + try: + async with httpx.AsyncClient(headers=UA, timeout=httpx.Timeout(120.0), + follow_redirects=True) as client: + async with client.stream("GET", url) as response: + response.raise_for_status() + with partial.open("wb") as handle: + async for chunk in response.aiter_bytes(65536): + handle.write(chunk) + partial.replace(target) + self.storage.update_media(media_id, state="ready", size=target.stat().st_size) + except Exception as error: + partial.unlink(missing_ok=True) + self.storage.update_media(media_id, state="failed", error=str(error)[:200]) + + +class Watcher: + """Balaie les stations à la recherche des morceaux guettés, et les enregistre.""" + + def __init__(self, config: Config, storage: Storage) -> None: + self.config = config + self.storage = storage + self.recording: str | None = None + #: Dernier tour de balayage, pour que l'interface montre que ça tourne. + self.last_sweep: dict = {} + self.sweeps = 0 + + async def run_forever(self) -> None: + """Balaie en continu : un tour enchaîne sur le suivant sans attendre. + + 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. + """ + 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. + await asyncio.sleep(30) + continue + except Exception as error: + print(f"[noxely] guet : {error}") + await asyncio.sleep(30) + continue + await asyncio.sleep(self.config.watch_interval) + + 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) + titles = 0 + 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 + hit = next((w for w in pending if w["query"].lower() in title.lower()), None) + if not hit: + continue + 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"]} + + self.storage.mark_watches_scanned([w["id"] for w in pending]) + return {"stations": len(found), "titles": titles, "at": _now(), "matched": None} + + async def record(self, station: dict, title: str, trigger: str) -> None: + """Enregistre jusqu'à ce que la station annonce un autre morceau.""" + self.recording = station["name"] + extension = "aac" if station["codec"].upper().startswith("AAC") else "mp3" + filename = safe_name(title, extension) + media_id = self.storage.add_media( + title=title, artist=station["name"], source="radio", origin=station["name"], + filename=filename, remote_url=None, + mime="audio/aac" if extension == "aac" else "audio/mpeg", state="downloading", + ) + target = self.config.media_dir / filename + headers = {**UA, "Icy-MetaData": "1"} + written = 0 + try: + async with httpx.AsyncClient(headers=headers, timeout=httpx.Timeout(60.0)) as client: + async with client.stream("GET", station["url"]) as response: + response.raise_for_status() + interval = int(response.headers.get("icy-metaint") or 0) + with target.open("wb") as handle: + written = await self._copy(response, handle, interval, title) + 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) + + +def _chunks(items: list, size: int): + for index in range(0, len(items), size): + yield items[index:index + size] diff --git a/server/requirements.txt b/server/requirements.txt new file mode 100644 index 0000000..4c611b5 --- /dev/null +++ b/server/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.110.0 +uvicorn[standard]>=0.29.0 +httpx>=0.27.0