2 Commits

Author SHA1 Message Date
2453c34fb9 Sort webapp du pmoserver dans la crate pmoapp 2025-10-06 11:52:53 +02:00
6c13e8cb8e Sort le serveur dans une crate pmoserver 2025-10-06 11:52:53 +02:00
2462 changed files with 535334 additions and 206794 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,3 +0,0 @@
# Configuration PATH pour Claude Code
# Ce fichier sera lu automatiquement pour configurer l'environnement
export PATH="/Users/coissac/mamba/condabin:/opt/homebrew/lib/ruby/gems/3.4.0/bin:/opt/homebrew/opt/ruby/bin:/Users/coissac/go/bin:/Users/coissac/.cargo/bin:/Users/coissac/.modular/pkg/packages.modular.com_mojo/bin:/Applications/quarto/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Library/Frameworks/Python.framework/Versions/3.12/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/System/Cryptexes/App/usr/bin:/usr/bin:/bin:/usr/sbin:/sbin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/local/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/bin:/var/run/com.apple.security.cryptexd/codex.system/bootstrap/usr/appleinternal/bin:/opt/pmk/env/global/bin:/opt/X11/bin:/usr/local/dorado/dorado-0.9.5-osx-arm64/bin:/usr/local/go/bin:/usr/local/src/last-main/bin:/Users/coissac/travail/__MOI__/GO/obitools4/build:/opt/podman/bin:/Applications/quarto/bin:/Users/coissac/.cargo/bin:/Users/coissac/.vscode-oss/extensions/vadimcn.vscode-lldb-1.12.0/bin:/Users/coissac/.vscode-oss/extensions/ms-python.debugpy-2025.14.1-darwin-arm64/bundled/scripts/noConfigScripts:/Users/coissac/.orbstack/bin"

View File

@@ -1,31 +0,0 @@
# PMOMusic Project Configuration
## Version Control
Ce projet utilise **Jujutsu (jj)** pour le contrôle de version, PAS git.
- Utiliser les commandes `jj` au lieu des commandes `git`
- Bookmark principal : `main`
- Ne jamais suggérer de commandes git
## Environnement
Le PATH et les variables d'environnement sont configurés dans `.claude-env` à la racine du projet.
## Configuration de l'application
- Fichier de configuration principal : `.pmomusic/config.yaml`
- Configuration UPNP personnalisable pour différencier les instances en développement
## Développement
Pendant le développement, plusieurs serveurs PMOMusic peuvent tourner en parallèle. Utiliser la configuration UPNP dans `.pmomusic/config.yaml` pour différencier les instances :
```yaml
host:
upnp:
manufacturer: "PMOMusic-Dev1"
udn_prefix: "pmomusic-dev1"
model_name_prefix: "PMOMusic-Dev1"
friendly_name_prefix: "PMOMusic-Dev1"
```
## Architecture
- Projet Rust multi-crates avec workspaces
- Crates principales : pmoupnp, pmomediaserver, pmomediarenderer, pmoconfig
- Pattern d'extension de configuration via traits (voir pmocache/src/config_ext.rs)

View File

@@ -1,70 +0,0 @@
# Build artifacts
target/
**/target/
# Webapp build artifacts and dependencies
pmoapp/webapp/node_modules/
pmoapp/webapp/dist/
**/node_modules/
# Git
.git/
.gitignore
.jj/
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# macOS
.DS_Store
# Cache directories
cache/
.pmomusic/
# Log files
*.log
# Documentation
doc/
*.md
!Readme.md
# Test files
test_upnp/
examples/
# Temporary files
*.tmp
*.temp
*.pcap
# Database files
db/
# Old code and backups
old_code/
*.txt
!Cargo.lock
# Development tools
tools/
gupnp-tools/
# Build scripts (we have them in the Dockerfile)
Makefile
setup-deps.sh
setup-env.sh
# SVG and other assets not needed for runtime
*.svg
# Audio test files
*.ogg
*.flac
*.wav
*.mp3

View File

@@ -1,25 +0,0 @@
name: Build and Push Docker Image
on:
push:
branches:
- main # Changez cela si votre branche principale a un autre nom
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Setup cache
uses: actions/cache@v3
with:
path: ~/.npm
key: dont-cache-${{ github.run_id }}
- name: Build and push image
uses: https://gargoton.petite-maison-orange.fr/pmo-actions/build-push-image@main
with:
image_name: public/pmomusic
image_tags: latest
no_cache: true
version_file: version.txt
check_uuid: 82a30d23-b3bd-4199-9237-776965831d20

29
.gitignore vendored
View File

@@ -1,5 +1,3 @@
.ollama
.ollamacode
/bin/
/pkg/
/vendor/
@@ -8,24 +6,14 @@
**/*.o
**/*.o.d
**/*.a
**/*.flac
**/*.aif
**/*.aiff
**/*.wav
**/*.opus
**/*.mp4
**/*.mp3
**/*.ogg
xxx
/dcai/
**/.pmomusic.yml
**/.pmomusic_covers/**
**/.pmomusic_audio/**
/.pmomusic
.DS_Store
target
/.pmomusic_covers
/.pmomusic_audio/**
**/.DS_Strore/**
**/.DS_Strore
/target/
.pmomusic_covers
C/src/soxr-0.1.3/Release/tests
**/Release/
**/Debug/
@@ -34,12 +22,3 @@ xxx
xx
all.txt
pmo_src.txt
upmpdcli/
/*.xml
test_upnp*.cargo/
.cargo/
setup-env.sh
cache
gupnp-tools
pmo*_[0_9]*.txt
webapp_[0_9]*.txt

9
.pmomusic.yml Normal file
View File

@@ -0,0 +1,9 @@
devices:
mediarenderer:
fakerenderer:
udn: d7eaad15-7d21-4411-926a-bc1eea0713db
mediaserver:
qobuz:
udn: 28963b75-4c5f-4da7-b10e-ffafd
host:
http_port: '8080'

View File

@@ -1,28 +0,0 @@
host:
http_port: '8080'
cover_cache:
directory: ./.pmomusic_covers
size: 2000
audio_cache:
directory: ./.pmomusic_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: TRACE
mediarenderer:
mpd_renderer: null
mediaserver:
qobuz:
udn: uuid:28963b75-4c5f-4da7-b10e-ffafd
accounts:
qobuz:
username: your-email@example.com
password: 'YOUR_PASSWORD_HERE'
devices:
mediarenderer:
pmo_mediarenderer:
udn: 15a13316-daac-47f0-b64e-47e56f5e3b51
mediaserver:
pmo_mediaserver:
udn: 23df0bfa-cfef-4724-b731-00f66fadf176

42
.vscode/settings.json vendored
View File

@@ -1,37 +1,7 @@
{
"makefile.configureOnOpen": false,
"git.enabled": false,
"claude-code.environmentVariables": [],
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
"files.exclude": {
"target": true,
"**/target": true,
"node_modules": true,
"tests/huge_benchmarks": true,
"examples": true,
"docs": true
},
// Rust Analyzer settings
"rust-analyzer.rustupPath": "/Users/coissac/.cargo/bin/rustup",
"rust-analyzer.cargoPath": "/Users/coissac/.cargo/bin/cargo",
"rust-analyzer.rustcSource": "discover",
"rust-analyzer.procMacro.enable": true,
"rust-analyzer.numThreads": 4,
"rust-analyzer.cargo.loadOutDirsFromCheck": false,
"rust-analyzer.checkOnSave.enable": true,
"rust-analyzer.checkOnSave.command": "check",
"rust-analyzer.checkOnSave.extraArgs": ["--all-features"],
"rust-analyzer.exclude": [
"target",
"tests/huge_benchmarks",
"examples",
"docs"
],
"rust-analyzer.server.extraEnv": {
"RA_LARGE_PROJECT": "1"
},
"rust-analyzer.runnables.command": null,
"rust-analyzer.server.path": null,
"rust-analyzer.cargo.allTargets": true,
}
"makefile.configureOnOpen": false,
"git.enabled": false,
"claude-code.environmentVariables": [
]
}

View File

@@ -1,121 +0,0 @@
# BrowseMetadata Fix for Radio Paradise - PMO Music
**Date:** 2025-11-27
**Issue:** gupnp-av-cp failed to get metadata for live streams and history containers
## Problem
UPnP clients (like gupnp-av-cp) were unable to get metadata for:
- Live stream items (e.g., `radio-paradise:channel:mellow:live`)
- History containers (e.g., `radio-paradise:channel:mellow:history`)
Error:
```
Failed to get metadata for 'radio-paradise:channel:mellow:live'
Failed to get metadata for 'radio-paradise:channel:mellow:history'
```
## Root Cause
The UPnP ContentDirectory service has two browse modes:
- **BrowseMetadata**: Get metadata for a specific object (item or container)
- **BrowseDirectChildren**: Get the children of a container
The `ContentHandler::browse_metadata()` was calling `source.browse()` for all objects, but:
1. The `MusicSource::browse()` trait method is designed to return children, not object metadata
2. For leaf items (LiveStream, HistoryTrack), `RadioParadiseSource::browse()` was rejecting them as "cannot be browsed"
3. The trait doesn't provide a way to distinguish between BrowseMetadata and BrowseDirectChildren requests
## Solution
### 1. Modified ContentHandler ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
- `browse_metadata()` now tries `get_item()` first for leaf items before falling back to `browse()`
- This allows proper metadata retrieval for items (LiveStream, HistoryTrack)
### 2. Modified RadioParadiseSource ([pmoparadise/src/source.rs](pmoparadise/src/source.rs))
**For LiveStream items:**
- `browse()` now returns `BrowseResult::Items([live_item])` with the item's metadata
- This supports both BrowseMetadata (via ContentHandler) and direct browse calls
**For HistoryTrack items:**
- `browse()` now returns `BrowseResult::Items([track])` using `get_item()` internally
- Properly retrieves track metadata from the history playlist
**For History containers:**
- `browse()` now returns `BrowseResult::Mixed { containers: [history_container], items: [tracks] }`
- Provides both container metadata and its children in one result
### 3. Added Container Filtering ([pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs))
- `browse_result_to_didl()` now filters out containers that match the browsed `object_id`
- Prevents containers from appearing as children of themselves
- For History: BrowseDirectChildren returns only tracks, not the container
## Files Modified
1. ✅ [pmomediaserver/src/content_handler.rs](pmomediaserver/src/content_handler.rs)
- Lines 120-135: Try get_item() first in browse_metadata()
- Lines 290-310: Added object_id parameter and container filtering in browse_result_to_didl()
- Lines 212, 286: Updated callers to pass object_id
2. ✅ [pmoparadise/src/source.rs](pmoparadise/src/source.rs)
- Lines 345-369: Modified History browse to return Mixed (container + items)
- Lines 371-377: Modified LiveStream browse to return item metadata
- Lines 379-383: Modified HistoryTrack browse to return track metadata
## Validation
### Live Stream Metadata ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:live
```
Returns:
```xml
<item id="radio-paradise:channel:mellow:live" parentID="radio-paradise:channel:mellow">
<dc:title>Unknown Title</dc:title>
<upnp:class>object.item.audioItem.audioBroadcast</upnp:class>
<res protocolInfo="http-get:*:audio/flac:*">http://.../radioparadise/stream/mellow/flac</res>
</item>
```
### History Container Metadata ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseMetadata radio-paradise:channel:mellow:history
```
Returns:
```xml
<container id="radio-paradise:channel:mellow:history" parentID="radio-paradise:channel:mellow">
<dc:title>Mellow Mix - History</dc:title>
<upnp:class>object.container.playlistContainer</upnp:class>
</container>
```
### History Children ✅
```bash
curl -X POST -H "SOAPAction: ..." BrowseDirectChildren radio-paradise:channel:mellow:history
```
Returns only track items (not the container itself)
## Design Notes
This solution works around a fundamental limitation in the `MusicSource` trait:
- The `browse()` method doesn't receive the `browse_flag` parameter
- It can't distinguish between BrowseMetadata and BrowseDirectChildren
- We use `get_item()` for items and `browse()` for containers
- Container filtering ensures correct BrowseDirectChildren behavior
## Testing Checklist
- [x] BrowseMetadata works for LiveStream items
- [x] BrowseMetadata works for History containers
- [x] BrowseDirectChildren works for History (returns only tracks)
- [x] Container filtering prevents self-reference
- [ ] Test with BubbleUPnP (user to verify)
- [ ] Test with gupnp-av-cp (user to verify)
## References
- Original issue report: [UPNP_FIX_SUMMARY.md](UPNP_FIX_SUMMARY.md)
- UPnP AV Architecture: https://openconnectivity.org/developer/specifications/upnp-resources/upnp/

View File

@@ -1,921 +0,0 @@
# Guide d'implémentation d'une nouvelle MusicSource
Ce document décrit comment implémenter une nouvelle source musicale dans l'écosystème PMOMusic en suivant le trait `MusicSource` défini dans le crate `pmosource`.
## Table des matières
1. [Vue d'ensemble](#vue-densemble)
2. [Structure d'une MusicSource](#structure-dune-musicsource)
3. [Implémentation du trait MusicSource](#implémentation-du-trait-musicsource)
4. [Patterns d'implémentation](#patterns-dimplémentation)
5. [Intégration avec l'écosystème PMOMusic](#intégration-avec-lécosystème-pmomusic)
6. [Checklist de mise en œuvre](#checklist-de-mise-en-œuvre)
7. [Exemples de référence](#exemples-de-référence)
## Vue d'ensemble
Une `MusicSource` est une abstraction qui représente une source de contenu musical dans PMOMusic. Elle peut être :
- **Dynamique (FIFO)** : Radio Paradise, streaming radio, playlists live
- **Statique** : Albums Qobuz, bibliothèque locale, playlists fixes
Le trait `MusicSource` définit une interface unifiée pour :
- La navigation UPnP ContentDirectory (browse)
- La résolution d'URI audio (avec cache)
- La gestion de playlists FIFO (pour les sources dynamiques)
- Le suivi des changements (update_id, last_change)
## Structure d'une MusicSource
### Organisation du code
```
pmo<votre-source>/
├── src/
│ ├── lib.rs # Exports publics
│ ├── source.rs # Implémentation MusicSource
│ ├── client.rs # Client API (optionnel)
│ ├── models.rs # Structures de données
│ ├── config.rs # Configuration
│ └── didl.rs # Conversion DIDL-Lite (optionnel)
├── assets/
│ └── default.webp # Logo 300x300px
├── Cargo.toml
└── README.md
```
### Dépendances principales
```toml
[dependencies]
pmosource = { path = "../pmosource" }
pmodidl = { path = "../pmodidl" }
pmoplaylist = { path = "../pmoplaylist", optional = true } # Si FIFO
pmoaudiocache = { path = "../pmoaudiocache", optional = true } # Si cache
pmocovers = { path = "../pmocovers", optional = true } # Si cache
async-trait = "0.1"
tokio = { version = "1", features = ["sync"] }
serde = { version = "1", features = ["derive"] }
[features]
default = ["cache"]
cache = ["pmoaudiocache", "pmocovers"]
playlist = ["pmoplaylist"]
```
## Implémentation du trait MusicSource
### 1. Informations de base
Chaque source doit fournir :
```rust
use pmosource::{async_trait, MusicSource};
#[derive(Clone, Debug)]
pub struct MyMusicSource {
// Champs internes
}
#[async_trait]
impl MusicSource for MyMusicSource {
fn name(&self) -> &str {
"Ma Source Musicale" // Nom affiché dans l'UI
}
fn id(&self) -> &str {
"my-music-source" // ID unique (format: lowercase-kebab-case)
}
fn default_image(&self) -> &[u8] {
// Logo WebP 300x300px inclus dans le binaire
include_bytes!("../assets/default.webp")
}
fn default_image_mime_type(&self) -> &str {
"image/webp" // Toujours WebP
}
}
```
**Règles :**
- `id()` doit être unique parmi toutes les sources
- `id()` doit être en lowercase-kebab-case
- `default_image()` doit être un WebP 300x300px
### 2. Navigation ContentDirectory
#### 2.1 Container racine
```rust
async fn root_container(&self) -> Result<Container> {
Ok(Container {
id: self.id().to_string(), // "my-music-source"
parent_id: "0".to_string(), // Toujours "0" pour la racine
restricted: Some("1".to_string()),
child_count: None, // Optionnel
searchable: Some("1".to_string()),
title: self.name().to_string(),
class: "object.container".to_string(),
artist: None,
album_art: None,
containers: vec![],
items: vec![],
})
}
```
#### 2.2 Browse
La méthode `browse()` est le cœur de la navigation :
```rust
async fn browse(&self, object_id: &str) -> Result<BrowseResult> {
match self.parse_object_id(object_id) {
ObjectIdType::Root => {
// Retourner les sous-containers principaux
let containers = vec![
self.build_albums_container(),
self.build_playlists_container(),
self.build_favorites_container(),
];
Ok(BrowseResult::Containers(containers))
}
ObjectIdType::Album { album_id } => {
// Retourner le container + ses tracks
let album_container = self.build_album_container(&album_id);
let tracks = self.get_album_tracks(&album_id).await?;
Ok(BrowseResult::Mixed {
containers: vec![album_container],
items: tracks,
})
}
ObjectIdType::Track { track_id } => {
// Retourner les détails d'un track
let track = self.get_track_item(&track_id).await?;
Ok(BrowseResult::Items(vec![track]))
}
_ => Err(MusicSourceError::ObjectNotFound(
format!("Unknown object: {}", object_id)
))
}
}
```
**Schema d'Object ID recommandé :**
```
<source-id> # Racine
<source-id>:albums # Container albums
<source-id>:album:<album_id> # Album spécifique
<source-id>:track:<track_id> # Track spécifique
<source-id>:playlist:<playlist_id> # Playlist spécifique
```
**Types de BrowseResult :**
- `Containers(Vec<Container>)` : Liste de containers (navigation)
- `Items(Vec<Item>)` : Liste de tracks (lecture)
- `Mixed { containers, items }` : Les deux (album avec tracks)
#### 2.3 Résolution d'URI
```rust
async fn resolve_uri(&self, object_id: &str) -> Result<String> {
// Étape 1 : Vérifier le cache audio
if let Some(cached_pk) = self.get_cached_audio_pk(object_id).await {
return Ok(format!("{}/audio/flac/{}", self.base_url, cached_pk));
}
// Étape 2 : Retourner l'URI originale
match self.parse_object_id(object_id) {
ObjectIdType::Track { track_id } => {
let stream_url = self.get_stream_url(&track_id).await?;
Ok(stream_url)
}
_ => Err(MusicSourceError::UriResolutionError(
format!("Cannot resolve URI for: {}", object_id)
))
}
}
```
**Ordre de résolution :**
1. Cache audio local (si disponible)
2. URI originale (API streaming, fichier local, etc.)
### 3. Support FIFO (sources dynamiques)
Si votre source est dynamique (radio, streaming live) :
```rust
use pmoplaylist::PlaylistManager;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Clone)]
pub struct RadioSource {
playlist_id: String,
update_counter: Arc<RwLock<u32>>,
last_change: Arc<RwLock<SystemTime>>,
}
#[async_trait]
impl MusicSource for RadioSource {
fn supports_fifo(&self) -> bool {
true // Cette source utilise une FIFO
}
async fn append_track(&self, track: Item) -> Result<()> {
// Récupérer le gestionnaire de playlist
let manager = PlaylistManager();
let writer = manager
.get_persistent_write_handle(self.playlist_id.clone())
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// Extraire le PK depuis l'URI du track
let pk = self.extract_pk_from_item(&track)?;
// Ajouter à la playlist
writer
.push_lazy(pk)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// Incrémenter update_id
self.bump_update_counter().await;
Ok(())
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
let manager = PlaylistManager();
let reader = manager
.get_read_handle(&self.playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// Récupérer le plus ancien
let items = reader.to_items(1).await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
if let Some(item) = items.first() {
// Adapter l'item au schéma de la source
let adapted = self.adapt_item_to_schema(item.clone());
self.bump_update_counter().await;
Ok(Some(adapted))
} else {
Ok(None)
}
}
async fn update_id(&self) -> u32 {
*self.update_counter.read().await
}
async fn last_change(&self) -> Option<SystemTime> {
Some(*self.last_change.read().await)
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
let manager = PlaylistManager();
let reader = manager
.get_read_handle(&self.playlist_id)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// Récupérer les items
let items = reader
.to_items(count)
.await
.map_err(|e| MusicSourceError::PlaylistError(e.to_string()))?;
// Adapter au schéma de la source
let adapted = items.into_iter()
.map(|item| self.adapt_item_to_schema(item))
.collect();
Ok(adapted)
}
}
impl RadioSource {
async fn bump_update_counter(&self) {
let mut counter = self.update_counter.write().await;
*counter = counter.wrapping_add(1).max(1);
let mut last = self.last_change.write().await;
*last = SystemTime::now();
}
}
```
**Points clés :**
- Utiliser `pmoplaylist::PlaylistManager` singleton
- Incrémenter `update_id` à chaque modification
- Mettre à jour `last_change` à chaque modification
- Adapter les IDs des items au schéma de la source
### 4. Support statique (albums, bibliothèques)
Si votre source est statique (catalogue, albums) :
```rust
#[async_trait]
impl MusicSource for CatalogSource {
fn supports_fifo(&self) -> bool {
false // Pas de FIFO
}
async fn append_track(&self, _track: Item) -> Result<()> {
Err(MusicSourceError::NotSupported(
"This source is read-only".to_string()
))
}
async fn remove_oldest(&self) -> Result<Option<Item>> {
Ok(None) // Pas de suppression
}
async fn update_id(&self) -> u32 {
0 // Jamais de changement
}
async fn last_change(&self) -> Option<SystemTime> {
None // Pas de suivi des changements
}
async fn get_items(&self, offset: usize, count: usize) -> Result<Vec<Item>> {
// Retourner une liste paginée depuis le catalogue
self.get_catalog_items(offset, count).await
}
}
```
## Patterns d'implémentation
### Pattern 1 : Source dynamique avec FIFO (Radio Paradise)
**Caractéristiques :**
- Flux continu de tracks
- Capacité limitée (50-100 tracks)
- Suppression automatique des plus anciens
- `supports_fifo() = true`
**Structure :**
```rust
#[derive(Clone)]
pub struct RadioParadiseSource {
base_url: String,
update_counter: Arc<RwLock<u32>>,
last_change: Arc<RwLock<SystemTime>>,
callback_tokens: Arc<std::sync::Mutex<Vec<u64>>>,
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>,
}
impl RadioParadiseSource {
// Enregistrer des callbacks sur les playlists pour notifier les changements
pub fn attach_playlist_callbacks(self: &Arc<Self>) {
let playlist_ids = vec![
self.live_playlist_id(),
self.history_playlist_id(),
];
let manager = PlaylistManager();
let mut tokens = self.callback_tokens.lock().unwrap();
for pid in playlist_ids {
let weak = Arc::downgrade(self);
let pid_clone = pid.clone();
let token = manager.register_callback(move |event| {
if event.playlist_id == pid_clone {
if let Some(strong) = weak.upgrade() {
tokio::spawn(async move {
strong.bump_update_counter().await;
// Notifier ContentDirectory
if let Some(notifier) = strong.container_notifier.as_ref() {
notifier(&[format!("radio-paradise:history")]);
}
});
}
}
});
tokens.push(token);
}
}
}
```
**Points clés :**
- Callbacks sur `pmoplaylist` pour détecter les changements
- Notification du ContentDirectory via un notifier injecté
- `update_counter` partagé via `Arc<RwLock<u32>>`
### Pattern 2 : Source catalogue avec playlists lazy (Qobuz)
**Caractéristiques :**
- Catalogue vaste (millions de tracks)
- Playlists créées à la demande
- Cache lazy (cover eager, audio lazy)
- `supports_fifo() = false`
**Structure :**
```rust
#[derive(Clone)]
pub struct QobuzSource {
inner: Arc<QobuzSourceInner>,
}
struct QobuzSourceInner {
client: Arc<QobuzClient>,
cache_manager: SourceCacheManager,
base_url: String,
update_counter: tokio::sync::RwLock<u32>,
last_change: tokio::sync::RwLock<SystemTime>,
}
impl QobuzSource {
// Ajouter un track avec cache lazy
pub async fn add_track_lazy(&self, track: &Track) -> Result<(String, String)> {
let track_id = format!("qobuz://track/{}", track.id);
let lazy_pk = format!("QOBUZ:{}", track.id);
// 1. Cache cover EAGERLY (petit, UI en a besoin)
let cached_cover_pk = if let Some(ref image_url) = track.album.as_ref()
.and_then(|a| a.image.as_ref()) {
self.inner.cache_manager.cache_cover(image_url).await.ok()
} else {
None
};
// 2. Préparer metadata
let metadata = AudioMetadata {
title: Some(track.title.clone()),
artist: track.performer.as_ref().map(|p| p.name.clone()),
album: track.album.as_ref().map(|a| a.title.clone()),
duration_secs: Some(track.duration as u64),
// ... autres champs
};
// 3. Cache audio LAZILY (grand, téléchargé à la demande)
let cached_audio_pk = self
.inner
.cache_manager
.cache_audio_lazy_with_provider(
&lazy_pk,
Some(metadata.clone()),
cached_cover_pk.clone(),
)
.await?;
// 4. Stocker metadata
self.inner.cache_manager.update_metadata(
track_id.clone(),
pmosource::TrackMetadata {
original_uri: stream_url,
cached_audio_pk: Some(cached_audio_pk.clone()),
cached_cover_pk,
},
).await;
Ok((track_id, cached_audio_pk))
}
// Créer une playlist d'album avec TTL
async fn get_or_create_album_playlist_items(
&self,
album_id: &str,
limit: usize,
) -> Result<Vec<Item>> {
const ALBUM_PLAYLIST_TTL: Duration = Duration::from_secs(7 * 24 * 3600);
let playlist_id = format!("qobuz-album-{}", album_id);
let playlist_manager = PlaylistManager();
// Vérifier validité (existe ET non expirée ET non vide)
let is_valid = self.is_album_playlist_valid(&playlist_id).await?;
if is_valid {
// Récupérer depuis playlist existante
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
let items = reader.to_items(limit).await?;
return self.adapt_playlist_items_to_qobuz(items, album_id).await;
}
// Créer nouvelle playlist
let writer = playlist_manager
.create_persistent_playlist_with_role(
playlist_id.clone(),
pmoplaylist::PlaylistRole::Album,
)
.await?;
// Ajouter tracks avec cache lazy
self.add_album_to_playlist(&playlist_id, album_id).await?;
// Récupérer items
let reader = playlist_manager.get_read_handle(&playlist_id).await?;
let items = reader.to_items(limit).await?;
self.adapt_playlist_items_to_qobuz(items, album_id).await
}
}
```
**Points clés :**
- Cache lazy pour l'audio (téléchargé à la demande)
- Cache eager pour les covers (petit, UI en a besoin)
- Playlists avec TTL (7 jours)
- `LazyProvider` pour télécharger l'audio lors de la lecture
### Pattern 3 : Adaptation des IDs entre playlist et source
Lorsqu'une source utilise `pmoplaylist`, les items retournés ont des IDs génériques. Il faut les adapter au schéma de la source :
```rust
async fn adapt_playlist_items_to_source(
&self,
items: Vec<Item>,
parent_id: &str,
) -> Result<Vec<Item>> {
let mut adapted = Vec::with_capacity(items.len());
for mut item in items {
// Extraire cache_pk depuis l'URL du resource
let cache_pk = if let Some(resource) = item.resources.first() {
resource
.url
.strip_prefix("/audio/flac/")
.map(|s| s.to_string())
} else {
None
};
if let Some(pk) = cache_pk {
// Récupérer source_track_id depuis metadata
if let Ok(Some(track_id_value)) = self
.cache_manager
.get_audio_metadata(&pk, "source_track_id")
{
if let Some(track_id) = track_id_value.as_str() {
item.id = format!("my-source:track:{}", track_id);
}
}
// Convertir URL relative en absolue
if let Some(resource) = item.resources.first_mut() {
if resource.url.starts_with('/') {
resource.url = format!("{}{}", self.base_url, resource.url);
}
}
}
item.parent_id = parent_id.to_string();
// Normaliser album art
if let Some(art) = item.album_art.as_mut() {
if art.starts_with('/') {
*art = format!("{}{}", self.base_url, art);
}
} else {
item.album_art = Some(self.default_cover_url());
}
// Ajouter genre par défaut si absent (requis par certains clients)
if item.genre.is_none() {
item.genre = Some("Music".to_string());
}
adapted.push(item);
}
Ok(adapted)
}
```
**Points clés :**
- Stocker `source_track_id` dans les metadata du cache audio
- Reconstituer l'ID correct lors de la récupération depuis playlist
- Normaliser URLs (relatives → absolues)
- Ajouter champs requis par certains clients UPnP
## Intégration avec l'écosystème PMOMusic
### Avec pmoplaylist
Pour les sources dynamiques et les catalogues :
```rust
use pmoplaylist::{PlaylistManager, PlaylistRole};
// Créer une playlist persistante
let manager = PlaylistManager();
let writer = manager
.create_persistent_playlist_with_role(
"my-source-album-123".to_string(),
PlaylistRole::Album,
)
.await?;
// Configurer metadata
writer.set_title("Album Title".to_string()).await?;
writer.set_artist(Some("Artist Name".to_string())).await?;
writer.set_cover_pk(Some("cover-pk".to_string())).await?;
// Ajouter tracks avec cache lazy
writer.push_lazy_batch(vec!["pk1", "pk2", "pk3"]).await?;
// Activer mode lazy (lookahead 2 tracks)
manager.enable_lazy_mode("my-source-album-123", 2);
```
### Avec pmoaudiocache et pmocovers (via SourceCacheManager)
```rust
use pmosource::SourceCacheManager;
// Créer le manager centralisé
let cache_manager = SourceCacheManager::from_registry("my-source".to_string())?;
// Enregistrer un LazyProvider
cache_manager.register_lazy_provider(Arc::new(MyLazyProvider::new(client)));
// Cache eager (cover)
let cover_pk = cache_manager.cache_cover("https://example.com/cover.jpg").await?;
// Cache lazy (audio)
let audio_pk = cache_manager
.cache_audio_lazy_with_provider(
"MY-SOURCE:123", // Lazy PK
Some(metadata),
Some(cover_pk),
)
.await?;
// Récupérer metadata
let value = cache_manager.get_audio_metadata(&audio_pk, "key").await?;
```
**LazyProvider personnalisé :**
```rust
use pmoaudiocache::{LazyProvider, LazyProviderError};
pub struct MyLazyProvider {
client: Arc<MyClient>,
}
#[async_trait]
impl LazyProvider for MyLazyProvider {
async fn fetch_audio(&self, lazy_pk: &str) -> Result<Vec<u8>, LazyProviderError> {
// Extraire l'ID depuis le lazy_pk
let id = lazy_pk
.strip_prefix("MY-SOURCE:")
.ok_or_else(|| LazyProviderError::InvalidKey)?;
// Récupérer l'URL de streaming
let stream_url = self.client.get_stream_url(id).await
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
// Télécharger l'audio
let response = reqwest::get(&stream_url).await
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
let bytes = response.bytes().await
.map_err(|e| LazyProviderError::FetchFailed(e.to_string()))?;
Ok(bytes.to_vec())
}
}
```
### Avec pmodidl
Conversion de vos structures en DIDL-Lite :
```rust
use pmodidl::{Container, Item, Resource};
// Container
pub trait ToDIDLContainer {
fn to_didl_container(&self, parent_id: &str) -> Result<Container>;
}
impl ToDIDLContainer for MyAlbum {
fn to_didl_container(&self, parent_id: &str) -> Result<Container> {
Ok(Container {
id: format!("my-source:album:{}", self.id),
parent_id: parent_id.to_string(),
restricted: Some("1".to_string()),
child_count: self.tracks_count.map(|c| c.to_string()),
searchable: Some("1".to_string()),
title: self.title.clone(),
class: "object.container.album.musicAlbum".to_string(),
artist: Some(self.artist.name.clone()),
album_art: self.cover_url.clone(),
containers: vec![],
items: vec![],
})
}
}
// Item
pub trait ToDIDLItem {
fn to_didl_item(&self, parent_id: &str) -> Result<Item>;
}
impl ToDIDLItem for MyTrack {
fn to_didl_item(&self, parent_id: &str) -> Result<Item> {
Ok(Item {
id: format!("my-source:track:{}", self.id),
parent_id: parent_id.to_string(),
restricted: Some("1".to_string()),
title: self.title.clone(),
creator: self.artist.as_ref().map(|a| a.name.clone()),
class: "object.item.audioItem.musicTrack".to_string(),
artist: self.artist.as_ref().map(|a| a.name.clone()),
album: self.album.as_ref().map(|a| a.title.clone()),
genre: Some("Music".to_string()),
album_art: self.cover_url.clone(),
album_art_pk: self.cover_pk.clone(),
date: self.release_date.clone(),
original_track_number: Some(self.track_number),
resources: vec![Resource {
protocol_info: "http-get:*:audio/flac:*".to_string(),
bits_per_sample: self.bit_depth.map(|b| b.to_string()),
sample_frequency: self.sample_rate.map(|s| s.to_string()),
nr_audio_channels: Some("2".to_string()),
duration: self.duration_as_upnp_format(),
url: format!("/audio/flac/{}", self.cache_pk),
}],
descriptions: vec![],
})
}
}
```
## Checklist de mise en œuvre
### Phase 1 : Structure de base
- [ ] Créer le crate `pmo<votre-source>`
- [ ] Ajouter les dépendances dans `Cargo.toml`
- [ ] Créer le logo WebP 300x300px dans `assets/`
- [ ] Définir la structure principale
- [ ] Implémenter `name()`, `id()`, `default_image()`
### Phase 2 : Navigation ContentDirectory
- [ ] Définir le schéma d'Object ID
- [ ] Implémenter `root_container()`
- [ ] Implémenter `browse()` pour la racine
- [ ] Implémenter `browse()` pour les sous-containers
- [ ] Implémenter `browse()` pour les items
- [ ] Tester la navigation avec un client UPnP
### Phase 3 : Résolution d'URI
- [ ] Implémenter `resolve_uri()` avec fallback
- [ ] Intégrer avec `SourceCacheManager`
- [ ] Implémenter `LazyProvider` si cache lazy
- [ ] Tester la lecture audio
### Phase 4 : Support FIFO (si dynamique)
- [ ] Décider de la stratégie FIFO
- [ ] Implémenter `supports_fifo() = true`
- [ ] Implémenter `append_track()`
- [ ] Implémenter `remove_oldest()`
- [ ] Implémenter `update_id()` et `last_change()`
- [ ] Enregistrer callbacks sur playlists
- [ ] Tester ajout/suppression de tracks
### Phase 5 : Support statique (si catalogue)
- [ ] Implémenter `supports_fifo() = false`
- [ ] Implémenter `get_items()` avec pagination
- [ ] Implémenter `search()` si applicable
- [ ] Tester browsing du catalogue
### Phase 6 : Intégration avancée
- [ ] Implémenter `get_item()` pour metadata
- [ ] Implémenter `capabilities()`
- [ ] Implémenter `get_available_formats()`
- [ ] Ajouter gestion d'erreurs robuste
- [ ] Documenter le code
### Phase 7 : Tests et validation
- [ ] Écrire tests unitaires
- [ ] Écrire tests d'intégration
- [ ] Tester avec différents clients UPnP
- [ ] Valider les performances
- [ ] Documenter les limitations
## Exemples de référence
### Radio Paradise (source dynamique FIFO)
**Fichier :** `pmoparadise/src/source.rs`
**Points d'intérêt :**
- Structure avec `Arc<RwLock<>>` pour l'état partagé
- Callbacks sur playlists pour détecter les changements
- Notifier injecté pour ContentDirectory
- Adaptation des IDs playlist → Radio Paradise
- Support de 4 canaux avec sous-containers
**Schema d'Object ID :**
```
radio-paradise # Racine
radio-paradise:channel:{slug} # Canal (main, mellow, rock, eclectic)
radio-paradise:channel:{slug}:live # Stream live
radio-paradise:channel:{slug}:liveplaylist # Playlist live (queue)
radio-paradise:channel:{slug}:liveplaylist:track:{pk} # Track dans queue
radio-paradise:channel:{slug}:history # Historique
radio-paradise:channel:{slug}:history:track:{pk} # Track dans historique
```
### Qobuz (source catalogue avec playlists lazy)
**Fichier :** `pmoqobuz/src/source.rs`
**Points d'intérêt :**
- `SourceCacheManager` centralisé
- Cache lazy pour audio, eager pour covers
- `LazyProvider` personnalisé
- Playlists d'albums avec TTL (7 jours)
- Adaptation IDs playlist → Qobuz
- Navigation hiérarchique complexe (Discover, Genres, Favorites)
**Schema d'Object ID :**
```
qobuz # Racine
qobuz:discover # Discover Catalog
qobuz:discover:albums:ideal # Albums (Ideal Discography)
qobuz:discover:artists # Artistes Featured
qobuz:genres # Discover Genres
qobuz:genre:{id} # Genre spécifique
qobuz:genre:{id}:new-releases # Nouveautés du genre
qobuz:favorites # My Music
qobuz:favorites:albums # Albums favoris
qobuz:album:{id} # Album spécifique
qobuz:track:{id} # Track spécifique
qobuz:playlist:{id} # Playlist spécifique
qobuz:artist:{id} # Artiste spécifique
```
## Conseils d'implémentation
### Performance
1. **Cache agressif** : Utilisez `SourceCacheManager` pour tout
2. **Pagination** : Limitez le nombre d'items retournés (max 100)
3. **Lazy loading** : Ne chargez que ce qui est demandé
4. **Rate limiting** : Respectez les limites API de la source
5. **Arc<>** : Partagez les données coûteuses
### Compatibilité UPnP
1. **Genre obligatoire** : Certains clients (gupnp-av-cp) requièrent `<upnp:genre>`
2. **URLs absolues** : Toujours retourner des URLs complètes (pas de chemins relatifs)
3. **Protocol Info** : Utilisez `http-get:*:audio/flac:*` pour FLAC
4. **Duration** : Format `H:MM:SS` (ex: `0:03:45`)
5. **childCount** : Optionnel mais recommandé pour l'UI
### Gestion d'erreurs
1. **ObjectNotFound** : ID invalide
2. **BrowseError** : Erreur générique de navigation
3. **UriResolutionError** : Impossible de résoudre l'URI
4. **PlaylistError** : Erreur d'interaction avec pmoplaylist
5. **CacheError** : Erreur de cache
### Thread Safety
1. **Arc<RwLock<>>** : Pour l'état mutable partagé
2. **tokio::sync::RwLock** : Pour l'async
3. **Éviter Rc<>** : Pas thread-safe
4. **Clone** : Implémentez `Clone` pour `Arc<>`
## Conclusion
L'implémentation d'une nouvelle `MusicSource` suit ces étapes :
1. **Définir le schéma d'Object ID** : Hiérarchie claire et cohérente
2. **Implémenter la navigation** : `browse()` pour tous les niveaux
3. **Résoudre les URIs** : Cache local d'abord, puis original
4. **Gérer le cache** : `SourceCacheManager` + `LazyProvider`
5. **Adapter les IDs** : Playlist → Schema de la source
6. **Notifier les changements** : `update_id` + callbacks
Les exemples Radio Paradise et Qobuz couvrent les deux patterns principaux :
- **Dynamique FIFO** : Radio Paradise
- **Catalogue lazy** : Qobuz
En suivant ces patterns, vous obtiendrez une source musicale performante, compatible UPnP, et bien intégrée dans l'écosystème PMOMusic.

File diff suppressed because it is too large Load Diff

View File

@@ -1,870 +0,0 @@
# Pattern d'extension PMOServer (`pmoserver_ext`)
## Vue d'ensemble
Le pattern `pmoserver_ext` permet d'étendre les fonctionnalités du serveur HTTP `pmoserver` de manière modulaire et découplée. Chaque crate spécialisée peut ajouter ses propres routes HTTP sans que `pmoserver` ne dépende de ces crates.
**Principe** : Définir un trait d'extension que `pmoserver::Server` implémente via une feature Cargo.
## Anatomie d'une extension
### 1. Structure du module
Créer un module `pmoserver_ext.rs` dans la crate :
```rust
// pmoXXX/src/pmoserver_ext.rs
#[cfg(feature = "pmoserver")]
use crate::{/* types internes de la crate */};
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::get, Json, extract::{State, Path}};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
```
Déclarer le module dans `lib.rs` :
```rust
// pmoXXX/src/lib.rs
#[cfg(feature = "pmoserver")]
pub mod pmoserver_ext;
#[cfg(feature = "pmoserver")]
pub use pmoserver_ext::XXXExt;
```
Ajouter la feature dans `Cargo.toml` :
```toml
[features]
pmoserver = ["dep:axum", "dep:async-trait"]
[dependencies]
axum = { version = "0.8", optional = true }
async-trait = { version = "0.1", optional = true }
pmoserver = { path = "../pmoserver" }
```
### 2. Définir le trait d'extension
**Convention de nommage** : `{Domaine}Ext` avec méthodes préfixées `init_*`
```rust
/// Trait pour étendre pmoserver avec les fonctionnalités XXX
#[cfg(feature = "pmoserver")]
#[async_trait]
pub trait XXXExt {
/// Initialise l'extension XXX et enregistre les routes HTTP
///
/// # Arguments
/// * `param1` - Description du paramètre
///
/// # Returns
/// Instance partagée de la ressource créée
///
/// # Exemple
/// ```ignore
/// use pmoserver::ServerBuilder;
/// use pmoXXX::XXXExt;
///
/// let mut server = ServerBuilder::new(...).build();
/// let resource = server.init_xxx(param1).await?;
/// ```
async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>>;
}
```
### 3. Implémenter le trait
Implémenter le trait pour `pmoserver::Server` :
```rust
#[cfg(feature = "pmoserver")]
#[async_trait]
impl XXXExt for pmoserver::Server {
async fn init_xxx(&mut self, param1: String) -> anyhow::Result<Arc<Resource>> {
// 1. Créer la ressource interne
let resource = Arc::new(Resource::new(param1)?);
// 2. Créer l'état partagé pour les handlers
let state = XxxState::new(resource.clone());
// 3. Créer le router avec les routes
let router = create_xxx_router(state);
// 4. Enregistrer le router sur le serveur
self.add_router("/api/xxx", router).await;
// 5. Retourner la ressource pour usage ultérieur
Ok(resource)
}
}
```
### 4. État partagé (State)
Créer une structure d'état cloneable pour les handlers :
```rust
/// État partagé pour les handlers XXX
#[derive(Clone)]
pub struct XxxState {
resource: Arc<Resource>,
}
impl XxxState {
pub fn new(resource: Arc<Resource>) -> Self {
Self { resource }
}
}
```
### 5. Créer le router
Définir les routes et handlers :
```rust
/// Crée le router pour l'API XXX
fn create_xxx_router(state: XxxState) -> Router {
Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item).delete(delete_item))
.with_state(state)
}
// Handlers
async fn list_items(
State(state): State<XxxState>
) -> Json<Vec<ItemSummary>> {
let items = state.resource.list_items();
Json(items)
}
async fn get_item(
State(state): State<XxxState>,
Path(id): Path<String>,
) -> Result<Json<Item>, StatusCode> {
state.resource.get_item(&id)
.ok_or(StatusCode::NOT_FOUND)
.map(Json)
}
```
## Méthodes disponibles du serveur
`pmoserver::Server` expose ces méthodes pour enregistrer des routes :
| Méthode | Usage |
|---------|-------|
| `add_handler(path, handler)` | Ajoute un handler simple sans état |
| `add_handler_with_state(path, handler, state)` | Ajoute un handler avec état partagé |
| `add_router(path, router)` | Monte un sous-router Axum |
| `add_openapi(router, doc, tag)` | Enregistre une API avec documentation OpenAPI |
| `add_spa::<W>(path)` | Sert une Single Page Application (RustEmbed) |
| `base_url()` | Récupère l'URL de base du serveur |
## Documentation OpenAPI avec utoipa
La documentation OpenAPI est essentielle pour une extension `pmoserver`. Elle génère automatiquement une interface Swagger UI et documente les endpoints de l'API.
### Configuration de base
Ajouter `utoipa` dans `Cargo.toml` :
```toml
[dependencies]
utoipa = { version = "5", features = ["axum_extras"] }
serde = { version = "1", features = ["derive"] }
```
### 1. Définir les schémas de données
Annoter les structures de réponse/requête avec `#[derive(ToSchema)]` :
```rust
use serde::{Serialize, Deserialize};
use utoipa::ToSchema;
/// Information sur un item
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ItemInfo {
/// ID unique de l'item
#[schema(example = "item-123")]
pub id: String,
/// Nom de l'item
#[schema(example = "Mon Item")]
pub name: String,
/// Description optionnelle
#[schema(example = "Une description détaillée")]
pub description: Option<String>,
/// Timestamp de création (millisecondes)
#[schema(example = 1234567890)]
pub created_at: u64,
}
/// Liste d'items
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ItemList {
/// Nombre total d'items
pub total: usize,
/// Items de la page courante
pub items: Vec<ItemInfo>,
}
/// Requête de création d'item
#[derive(Debug, Clone, Deserialize, ToSchema)]
pub struct CreateItemRequest {
/// Nom de l'item à créer
#[schema(example = "Nouvel Item")]
pub name: String,
/// Description optionnelle
pub description: Option<String>,
}
/// Réponse d'erreur standard
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct ErrorResponse {
/// Message d'erreur
#[schema(example = "Item not found")]
pub error: String,
}
```
**Points clés** :
- `#[schema(example = "...")]` : Fournit des exemples pour la doc Swagger
- Documenter chaque champ avec `///` pour apparaître dans l'API
- Utiliser `Option<T>` pour les champs optionnels
### 2. Annoter les handlers
Utiliser `#[utoipa::path(...)]` pour documenter chaque endpoint :
```rust
/// GET /items - Liste tous les items
#[utoipa::path(
get,
path = "/items",
params(
("limit" = Option<u32>, Query, description = "Nombre max d'items à retourner"),
("offset" = Option<u32>, Query, description = "Offset pour la pagination")
),
responses(
(status = 200, description = "Liste des items", body = ItemList),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "items"
)]
async fn list_items(
State(state): State<XxxState>,
Query(params): Query<ListParams>,
) -> Result<Json<ItemList>, (StatusCode, Json<ErrorResponse>)> {
let items = state.resource.list_items(params.limit, params.offset)
.map_err(|e| (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e.to_string() })
))?;
Ok(Json(ItemList {
total: items.len(),
items,
}))
}
/// GET /items/{id} - Récupère un item spécifique
#[utoipa::path(
get,
path = "/items/{id}",
params(
("id" = String, Path, description = "ID unique de l'item")
),
responses(
(status = 200, description = "Item trouvé", body = ItemInfo),
(status = 404, description = "Item non trouvé", body = ErrorResponse),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "items"
)]
async fn get_item(
State(state): State<XxxState>,
Path(id): Path<String>,
) -> Result<Json<ItemInfo>, (StatusCode, Json<ErrorResponse>)> {
state.resource.get_item(&id)
.ok_or_else(|| (
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: format!("Item {} not found", id)
})
))
.map(Json)
}
/// POST /items - Crée un nouvel item
#[utoipa::path(
post,
path = "/items",
request_body = CreateItemRequest,
responses(
(status = 201, description = "Item créé", body = ItemInfo),
(status = 400, description = "Requête invalide", body = ErrorResponse),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "items"
)]
async fn create_item(
State(state): State<XxxState>,
Json(req): Json<CreateItemRequest>,
) -> Result<(StatusCode, Json<ItemInfo>), (StatusCode, Json<ErrorResponse>)> {
let item = state.resource.create_item(req.name, req.description)
.map_err(|e| (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e.to_string() })
))?;
Ok((StatusCode::CREATED, Json(item)))
}
/// DELETE /items/{id} - Supprime un item
#[utoipa::path(
delete,
path = "/items/{id}",
params(
("id" = String, Path, description = "ID unique de l'item")
),
responses(
(status = 204, description = "Item supprimé"),
(status = 404, description = "Item non trouvé", body = ErrorResponse),
(status = 500, description = "Erreur serveur", body = ErrorResponse)
),
tag = "items"
)]
async fn delete_item(
State(state): State<XxxState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
state.resource.delete_item(&id)
.map_err(|e| (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse { error: e.to_string() })
))?;
Ok(StatusCode::NO_CONTENT)
}
```
**Structure de `#[utoipa::path]`** :
- **Méthode HTTP** : `get`, `post`, `put`, `delete`, `patch`
- **`path`** : Chemin de l'endpoint (doit correspondre au router)
- **`params`** : Paramètres Path ou Query avec description
- **`request_body`** : Type du body pour POST/PUT
- **`responses`** : Liste des réponses possibles avec codes HTTP
- **`tag`** : Groupe d'endpoints dans Swagger UI
### 3. Créer la structure OpenAPI
Définir une structure avec `#[derive(OpenApi)]` :
```rust
use utoipa::OpenApi;
/// Documentation OpenAPI pour l'API XXX
#[derive(OpenApi)]
#[openapi(
info(
title = "XXX API",
version = "1.0.0",
description = r#"
# API REST pour XXX
Cette API permet de gérer les items XXX avec les fonctionnalités suivantes :
## Fonctionnalités
- **CRUD complet** : Création, lecture, mise à jour et suppression d'items
- **Pagination** : Support de limit/offset pour les listes
- **Filtrage** : Recherche par critères multiples
- **Validation** : Vérification automatique des données
## Exemples d'utilisation
### Lister les items
```
GET /api/xxx/items?limit=10&offset=0
```
### Créer un item
```
POST /api/xxx/items
Content-Type: application/json
{
"name": "Mon Item",
"description": "Description détaillée"
}
```
### Récupérer un item
```
GET /api/xxx/items/item-123
```
### Supprimer un item
```
DELETE /api/xxx/items/item-123
```
"#
),
paths(
list_items,
get_item,
create_item,
delete_item,
),
components(schemas(
ItemInfo,
ItemList,
CreateItemRequest,
ErrorResponse,
)),
tags(
(name = "items", description = "Opérations sur les items")
)
)]
pub struct ApiDoc;
```
**Sections importantes** :
- **`info`** : Titre, version et description Markdown de l'API
- **`paths`** : Liste des fonctions handler annotées
- **`components(schemas(...))`** : Liste des structures `ToSchema`
- **`tags`** : Organisation des endpoints en groupes
### 4. Enregistrer l'API avec OpenAPI
Dans l'implémentation du trait d'extension :
```rust
#[async_trait]
impl XxxExt for pmoserver::Server {
async fn init_xxx(&mut self) -> anyhow::Result<Arc<Resource>> {
let resource = Arc::new(Resource::new()?);
let state = XxxState { resource: resource.clone() };
// Créer le router avec les routes
let router = Router::new()
.route("/items", get(list_items).post(create_item))
.route("/items/{id}", get(get_item).delete(delete_item))
.with_state(state);
// Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx)
let openapi = ApiDoc::openapi();
self.add_openapi(router, openapi, "xxx").await;
Ok(resource)
}
}
```
**Ce que fait `add_openapi`** :
- Monte le router sur `/api/{tag}/`
- Génère la spec OpenAPI JSON sur `/api/{tag}/openapi.json`
- Crée une UI Swagger sur `/swagger-ui/{tag}/`
### 5. Exemple complet : Radio Paradise
**Extrait de** `pmoparadise/src/pmoserver_ext.rs:93-315`
```rust
/// Information sur un morceau
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct SongInfo {
/// Index dans le block
pub index: usize,
/// Artiste
pub artist: String,
/// Titre
pub title: String,
/// Album
pub album: String,
/// Année
pub year: Option<u32>,
/// Temps écoulé depuis le début du block (ms)
pub elapsed_ms: u64,
/// Durée du morceau (ms)
pub duration_ms: u64,
/// URL de la pochette
pub cover_url: Option<String>,
}
/// Réponse pour l'URL de streaming
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct StreamUrlResponse {
/// Event ID du block
#[schema(example = 1234567)]
pub event: u64,
/// URL de streaming FLAC
#[schema(example = "https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac")]
pub stream_url: String,
/// Durée totale (ms)
#[schema(example = 900000)]
pub length_ms: u64,
}
/// GET /stream-url/{event_id} - Récupère l'URL de streaming
#[utoipa::path(
get,
path = "/stream-url/{event_id}",
params(
("event_id" = u64, Path, description = "Event ID du block"),
("channel" = Option<u8>, Query, description = "Channel ID (0-3)")
),
responses(
(status = 200, description = "URL de streaming", body = StreamUrlResponse),
(status = 500, description = "Erreur serveur")
),
tag = "Radio Paradise"
)]
async fn get_stream_url(
State(state): State<RadioParadiseState>,
Path(event_id): Path<u64>,
Query(params): Query<ParadiseQuery>,
) -> Result<Json<StreamUrlResponse>, StatusCode> {
let client = state.client_for_params(&params).await?;
let block = client.get_block(Some(event_id)).await.map_err(|e| {
tracing::error!("Failed to fetch block {}: {}", event_id, e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(StreamUrlResponse {
event: block.event,
stream_url: block.url,
length_ms: block.length,
}))
}
#[derive(OpenApi)]
#[openapi(
info(
title = "Radio Paradise API",
version = "1.0.0",
description = "API REST pour accéder aux métadonnées Radio Paradise"
),
paths(
get_now_playing,
get_current_block,
get_stream_url,
),
components(schemas(
SongInfo,
StreamUrlResponse,
)),
tags(
(name = "Radio Paradise", description = "Endpoints Radio Paradise")
)
)]
pub struct RadioParadiseApiDoc;
```
### Résultat : Interface Swagger
Après avoir appelé `init_xxx()`, l'API est accessible :
- **API JSON** : `http://localhost:8080/api/xxx/`
- **Spec OpenAPI** : `http://localhost:8080/api/xxx/openapi.json`
- **Swagger UI** : `http://localhost:8080/swagger-ui/xxx/`
L'interface Swagger permet :
- Parcourir tous les endpoints avec leur documentation
- Tester les requêtes directement depuis le navigateur
- Voir les schémas de données avec exemples
- Consulter les codes de réponse HTTP possibles
## Patterns courants
### Pattern 1 : Extension simple avec router
**Exemple** : `pmoparadise` (pmoparadise/src/pmoserver_ext.rs:367-392)
```rust
#[async_trait]
impl RadioParadiseExt for pmoserver::Server {
async fn init_radioparadise(&mut self) -> anyhow::Result<State> {
let state = RadioParadiseState::new().await?;
// Créer le router API
let api_router = create_api_router(state.clone());
// Enregistrer avec OpenAPI
self.add_openapi(api_router, ApiDoc::openapi(), "radioparadise")
.await;
Ok(state)
}
}
```
### Pattern 2 : Extension avec cache et fichiers
**Exemple** : `pmoaudiocache` (pmoaudiocache/src/lib.rs:225-260)
```rust
#[async_trait]
impl AudioCacheExt for pmoserver::Server {
async fn init_audio_cache(
&mut self,
cache_dir: &str,
limit: usize,
) -> anyhow::Result<Arc<Cache>> {
let cache = Arc::new(new_cache(cache_dir, limit)?);
// Router pour servir les fichiers FLAC
let file_router = create_file_router(cache.clone(), "audio/flac");
self.add_router("/", file_router).await;
// API REST
let api_router = Router::new()
.route("/", get(list).post(add))
.route("/{pk}", get(get_info).delete(delete))
.with_state(cache.clone());
self.add_openapi(api_router, ApiDoc::openapi(), "audio").await;
Ok(cache)
}
}
```
### Pattern 3 : Extension avec routes dynamiques
**Exemple** : `pmomediaserver` (pmomediaserver/src/paradise_streaming.rs:70-148)
```rust
#[async_trait]
impl ParadiseStreamingExt for pmoserver::Server {
async fn init_paradise_streaming(&mut self) -> Result<Arc<Manager>> {
// 1. Récupérer/créer les ressources partagées
let audio_cache = get_or_init_audio_cache(self).await?;
let manager = Arc::new(Manager::new(audio_cache).await?);
// 2. Créer l'état partagé
let state = Arc::new(StreamingState { manager: manager.clone() });
// 3. Enregistrer les routes pour chaque canal
for descriptor in ALL_CHANNELS.iter() {
let slug = descriptor.slug;
// Route streaming FLAC
let path = format!("/stream/{}/flac", slug);
self.add_handler_with_state(
&path,
move |State(s): State<Arc<StreamingState>>| async move {
stream_flac(s.manager.clone(), descriptor.id).await
},
state.clone(),
).await;
// Route streaming OGG
let path = format!("/stream/{}/ogg", slug);
self.add_handler_with_state(
&path,
move |State(s): State<Arc<StreamingState>>| async move {
stream_ogg(s.manager.clone(), descriptor.id).await
},
state.clone(),
).await;
}
Ok(manager)
}
}
```
## Gestion des opérations longues
### Utiliser `spawn_blocking` pour le code synchrone
Pour éviter de bloquer le runtime Tokio avec du code synchrone :
```rust
async fn list_renderers(
State(state): State<ControlPointState>
) -> Json<Vec<Summary>> {
let control_point = state.control_point.clone();
let summaries = tokio::task::spawn_blocking(move || {
let renderers = control_point.list_music_renderers();
renderers.into_iter()
.map(|r| Summary::from(&r))
.collect()
})
.await
.unwrap_or_default();
Json(summaries)
}
```
### Ajouter des timeouts pour les opérations réseau
```rust
const COMMAND_TIMEOUT: Duration = Duration::from_secs(5);
async fn play_renderer(
State(state): State<ControlPointState>,
Path(id): Path<String>,
) -> Result<Json<Response>, (StatusCode, Json<Error>)> {
let renderer = state.get_renderer(&id)
.ok_or((StatusCode::NOT_FOUND, Json(Error::not_found())))?;
let play_task = tokio::task::spawn_blocking(move || renderer.play());
time::timeout(COMMAND_TIMEOUT, play_task)
.await
.map_err(|_| (
StatusCode::GATEWAY_TIMEOUT,
Json(Error::timeout())
))?
.map_err(|e| (
StatusCode::INTERNAL_SERVER_ERROR,
Json(Error::internal(e))
))??;
Ok(Json(Response::success()))
}
```
### Utiliser `spawn` pour les tâches en arrière-plan
Pour les opérations qui ne nécessitent pas d'attendre le résultat :
```rust
async fn trigger_action(
State(state): State<XxxState>,
Json(req): Json<Request>,
) -> Json<Response> {
// Valider la requête
state.validate(&req)?;
// Lancer l'action en arrière-plan
let state_clone = state.clone();
tokio::task::spawn(async move {
match state_clone.perform_action(req).await {
Ok(_) => debug!("Action completed"),
Err(e) => warn!("Action failed: {}", e),
}
});
// Retourner immédiatement
Json(Response::accepted())
}
```
## Checklist d'implémentation
### Configuration de base
- [ ] Créer le module `pmoserver_ext.rs` avec `#[cfg(feature = "pmoserver")]`
- [ ] Ajouter la feature `pmoserver` dans `Cargo.toml` avec dépendances optionnelles
- [ ] Re-exporter le trait dans `lib.rs`
### Définition du trait
- [ ] Définir le trait `{Domaine}Ext` avec méthode `init_*`
- [ ] Créer la structure `{Domaine}State` avec `#[derive(Clone)]`
- [ ] Implémenter le trait pour `pmoserver::Server`
### Documentation OpenAPI
- [ ] Ajouter `utoipa` dans les dépendances
- [ ] Définir les schémas de réponse/requête avec `#[derive(ToSchema)]`
- [ ] Ajouter des exemples avec `#[schema(example = "...")]`
- [ ] Annoter chaque handler avec `#[utoipa::path(...)]`
- [ ] Créer la structure `#[derive(OpenApi)]` avec documentation complète
- [ ] Lister tous les paths et schemas dans `#[openapi(...)]`
### Handlers et routes
- [ ] Créer les handlers avec les extracteurs Axum appropriés
- [ ] Gérer les erreurs avec des codes HTTP sémantiques
- [ ] Créer le router et l'enregistrer avec `add_openapi()`
- [ ] Ajouter des logs (debug, info, warn, error)
### Performance et robustesse
- [ ] Utiliser `spawn_blocking` pour le code synchrone
- [ ] Ajouter des timeouts pour les opérations réseau
- [ ] Utiliser `spawn` pour les tâches en arrière-plan si nécessaire
## Exemple complet minimal
```rust
// pmoexample/src/pmoserver_ext.rs
#[cfg(feature = "pmoserver")]
use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{Router, routing::get, Json, extract::State};
#[cfg(feature = "pmoserver")]
use std::sync::Arc;
#[cfg(feature = "pmoserver")]
use crate::ExampleResource;
#[cfg(feature = "pmoserver")]
#[derive(Clone)]
pub struct ExampleState {
resource: Arc<ExampleResource>,
}
#[cfg(feature = "pmoserver")]
#[async_trait]
pub trait ExampleExt {
async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>>;
}
#[cfg(feature = "pmoserver")]
#[async_trait]
impl ExampleExt for pmoserver::Server {
async fn init_example(&mut self) -> anyhow::Result<Arc<ExampleResource>> {
let resource = Arc::new(ExampleResource::new());
let state = ExampleState { resource: resource.clone() };
let router = Router::new()
.route("/items", get(list_items))
.with_state(state);
self.add_router("/api/example", router).await;
Ok(resource)
}
}
#[cfg(feature = "pmoserver")]
async fn list_items(State(state): State<ExampleState>) -> Json<Vec<String>> {
let items = state.resource.list();
Json(items)
}
```
## Références
### Exemples dans le codebase
| Crate | Fichier | Pattern |
|-------|---------|---------|
| `pmoparadise` | `src/pmoserver_ext.rs:367-392` | Extension simple avec OpenAPI |
| `pmoaudiocache` | `src/lib.rs:225-260` | Extension avec cache et fichiers |
| `pmomediaserver` | `src/paradise_streaming.rs:70-148` | Extension avec routes dynamiques |
| `pmocontrol` | `src/pmoserver_ext.rs:68-92` | Handlers avec `spawn_blocking` |
| `pmoapp` | `src/lib.rs:145-165` | Extension SPA avec RustEmbed |
### Dépendances communes
- `axum` : Framework HTTP (Router, handlers, extracteurs)
- `async-trait` : Support des traits async
- `tokio` : Runtime async (spawn, spawn_blocking, timeout)
- `anyhow` : Gestion d'erreurs pour init
- `tracing` : Logging structuré
- `utoipa` : Documentation OpenAPI/Swagger
- `serde` : Sérialisation JSON

View File

@@ -1,905 +0,0 @@
# Rapport Final : Items Épinglables et TTL dans PMOcache
## Objectif de la tâche
Étendre le système de cache PMOcache pour permettre un contrôle plus fin des règles de suppression des items. L'objectif était double :
1. **Phase 1** : Implémenter un système d'items épinglables (pinned) protégés de l'éviction LRU, avec support du TTL (Time To Live) pour l'expiration automatique
2. **Phase 2** : Exposer ces fonctionnalités via une API REST complète avec documentation OpenAPI
## Contexte
La crate PMOcache implémente un système de cache avec :
- Capacité maximale configurable
- Politique d'éviction LRU (Least Recently Used)
- TTL optionnel pour les items
La nouvelle fonctionnalité permet de :
- **Épingler** des items critiques pour les rendre permanents
- **Exclure** les items épinglés du comptage de la limite du cache
- **Définir un TTL** pour supprimer automatiquement les items temporaires
- **Garantir l'incompatibilité** entre pinning et TTL (règle métier)
## Architecture de la solution
### 1. Modifications de la base de données
#### Schéma SQL étendu
```sql
CREATE TABLE IF NOT EXISTS asset (
pk TEXT PRIMARY KEY,
collection TEXT,
id TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT,
lazy_pk TEXT,
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
ttl_expires_at TEXT
)
```
Deux nouvelles colonnes :
- **`pinned`** : Booléen (0/1) indiquant si l'item est protégé
- **`ttl_expires_at`** : Date RFC3339 d'expiration (optionnel)
#### Structure `CacheEntry` enrichie
```rust
pub struct CacheEntry {
pub pk: String,
pub lazy_pk: Option<String>,
pub id: Option<String>,
pub collection: Option<String>,
pub hits: i32,
pub last_used: Option<String>,
pub pinned: bool, // Nouveau
pub ttl_expires_at: Option<String>, // Nouveau
pub metadata: Option<Value>,
}
```
### 2. API de base de données (db.rs)
#### Nouvelles méthodes implémentées
##### Gestion du comptage
- **`count_unpinned()`** : Compte uniquement les items non épinglés
- Les items épinglés sont exclus de la limite du cache
##### Gestion du pinning
- **`pin(pk)`** : Épingle un item
- Vérifie qu'aucun TTL n'est défini (règle métier)
- Retourne erreur si TTL présent
- **`unpin(pk)`** : Désépingle un item
- **`is_pinned(pk)`** : Vérifie le statut de pinning
##### Gestion du TTL
- **`set_ttl(pk, expires_at)`** : Définit la date d'expiration
- Vérifie que l'item n'est pas épinglé (règle métier)
- Retourne erreur si épinglé
- **`clear_ttl(pk)`** : Supprime le TTL
- **`get_expired()`** : Récupère tous les items expirés
##### Modification de `get_oldest()`
Exclusion automatique des items épinglés :
```sql
SELECT ... FROM asset
WHERE pinned = 0
ORDER BY last_used ASC, hits ASC
LIMIT ?1
```
### 3. Logique du cache (cache.rs)
#### Méthodes publiques exposées
```rust
pub async fn pin(&self, pk: &str) -> Result<()>
pub async fn unpin(&self, pk: &str) -> Result<()>
pub async fn is_pinned(&self, pk: &str) -> Result<bool>
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
pub async fn clear_ttl(&self, pk: &str) -> Result<()>
```
#### Politique d'éviction améliorée
La méthode `enforce_limit()` a été complètement repensée :
```rust
pub async fn enforce_limit(&self) -> Result<usize> {
// 1. Supprimer d'abord les items expirés (TTL dépassé)
let expired_entries = self.db.get_expired()?;
for entry in expired_entries {
// Suppression fichiers + DB
}
// 2. Compter UNIQUEMENT les items non épinglés
let count = self.db.count_unpinned()?;
// 3. Si limite dépassée, supprimer les plus vieux (non épinglés)
if count > self.limit {
let to_remove = count - self.limit;
let old_entries = self.db.get_oldest(to_remove)?;
// Suppression...
}
}
```
**Ordre de priorité** :
1. Items expirés (TTL) → suppression immédiate
2. Items non épinglés les plus vieux (LRU) → suppression si limite dépassée
3. Items épinglés → **jamais supprimés automatiquement**
### 4. API REST (api.rs)
#### Nouvelles structures de données
```rust
#[derive(Serialize, Deserialize, ToSchema)]
pub struct PinStatus {
pub pk: String,
pub pinned: bool,
pub ttl_expires_at: Option<String>,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct PinResponse {
pub pk: String,
pub message: String,
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct SetTtlRequest {
pub expires_at: String, // RFC3339
}
```
#### Handlers HTTP implémentés
##### `get_pin_status(pk)` - GET /{pk}/pin
Récupère le statut actuel de pinning et TTL d'un item.
**Réponse 200 OK** :
```json
{
"pk": "1a2b3c4d5e6f7a8b",
"pinned": false,
"ttl_expires_at": null
}
```
##### `pin_item(pk)` - POST /{pk}/pin
Épingle un item pour le protéger de l'éviction.
**Réponse 200 OK** :
```json
{
"pk": "1a2b3c4d5e6f7a8b",
"message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
}
```
**Réponse 409 CONFLICT** (si TTL défini) :
```json
{
"error": "CONFLICT",
"message": "Cannot pin an item with TTL set. Clear TTL first."
}
```
##### `unpin_item(pk)` - DELETE /{pk}/pin
Désépingle un item.
##### `set_item_ttl(pk, request)` - POST /{pk}/ttl
Définit le TTL d'un item.
**Requête** :
```json
{
"expires_at": "2025-01-20T10:30:00Z"
}
```
**Réponse 409 CONFLICT** (si épinglé) :
```json
{
"error": "CONFLICT",
"message": "Cannot set TTL on a pinned item. Unpin first."
}
```
**Réponse 400 BAD REQUEST** (format invalide) :
```json
{
"error": "INVALID_DATE",
"message": "Invalid RFC3339 date format"
}
```
##### `clear_item_ttl(pk)` - DELETE /{pk}/ttl
Supprime le TTL d'un item.
### 5. Routes HTTP (pmoserver_ext.rs)
Routes ajoutées au router API :
```rust
Router::new()
// ... routes existantes ...
.route(
"/{pk}/pin",
get(api::get_pin_status::<C>)
.post(api::pin_item::<C>)
.delete(api::unpin_item::<C>),
)
.route(
"/{pk}/ttl",
post(api::set_item_ttl::<C>)
.delete(api::clear_item_ttl::<C>),
)
```
**URLs complètes** (exemple pour cache audio) :
- `GET /api/audio/{pk}/pin`
- `POST /api/audio/{pk}/pin`
- `DELETE /api/audio/{pk}/pin`
- `POST /api/audio/{pk}/ttl`
- `DELETE /api/audio/{pk}/ttl`
### 6. Documentation OpenAPI (openapi.rs)
La macro `create_cache_openapi!` a été enrichie pour inclure automatiquement :
```rust
#[openapi(
paths(
// ... paths existants ...
$crate::api::get_pin_status::<Self>,
$crate::api::pin_item::<Self>,
$crate::api::unpin_item::<Self>,
$crate::api::set_item_ttl::<Self>,
$crate::api::clear_item_ttl::<Self>,
),
components(
schemas(
// ... schemas existants ...
$crate::api::PinStatus,
$crate::api::PinResponse,
$crate::api::SetTtlRequest,
)
),
)]
```
**Accès Swagger UI** : `/swagger-ui/{cache_name}`
## Règles métier implémentées
### 1. Incompatibilité stricte : Pinned ↔ TTL
Un item ne peut **jamais** être à la fois épinglé ET avoir un TTL :
| État actuel | Action | Résultat |
|-------------|--------|----------|
| Aucun TTL | `pin()` | ✅ Succès |
| TTL défini | `pin()` | ❌ Erreur 409 |
| Non épinglé | `set_ttl()` | ✅ Succès |
| Épinglé | `set_ttl()` | ❌ Erreur 409 |
**Rationale** :
- **Épinglé** = permanent, ne doit jamais être supprimé automatiquement
- **TTL** = temporaire, sera supprimé à expiration
- Ces deux concepts sont sémantiquement contradictoires
### 2. Exclusion du comptage
Les items épinglés ne comptent **pas** dans la limite du cache :
```rust
// Cache avec limite de 100 items
let unpinned_count = cache.db.count_unpinned()?; // 100
let total_count = cache.db.count()?; // 150
// Le cache peut contenir :
// - 100 items non épinglés (limite respectée)
// - 50 items épinglés (hors limite)
```
### 3. Protection absolue contre l'éviction
Les items épinglés sont **jamais** retournés par `get_oldest()` :
```sql
-- Requête LRU exclut automatiquement les épinglés
SELECT ... FROM asset
WHERE pinned = 0 -- ← Filtre explicite
ORDER BY last_used ASC
```
## Tests et validation
### Suite de tests dédiée (test_pinnable.rs)
9 tests couvrant tous les cas d'usage :
1. **`test_pin_unpin`** : Épinglage/désépinglage basique
2. **`test_pinned_excluded_from_lru`** : Items épinglés protégés de l'éviction
3. **`test_pinned_count_separately`** : Comptage séparé des items
4. **`test_cannot_pin_with_ttl`** : Règle métier TTL → pas de pin
5. **`test_cannot_set_ttl_when_pinned`** : Règle métier pin → pas de TTL
6. **`test_ttl_expiration`** : Suppression automatique des items expirés
7. **`test_clear_ttl`** : Suppression du TTL
8. **`test_get_expired`** : Récupération des items expirés
9. **`test_cache_entry_fields`** : Vérification des champs dans les entrées
**Résultat** : ✅ 9/9 tests passent
### Tests de non-régression
Tous les tests existants de `test_cache.rs` passent sans modification :
- Test de création de cache
- Test d'ajout de fichiers
- Test de déduplication
- Test de collections
- Test de suppression
- Test d'éviction LRU
- Test de purge
- Test de consolidation
**Résultat** : ✅ Aucune régression détectée
### Compilation
```bash
cargo build -p pmocache
```
**Résultat** : ✅ Compilation sans erreur ni warning
## Compatibilité et migration
### Rétrocompatibilité de la base de données
**Aucune migration manuelle requise**. Les colonnes ont des valeurs par défaut :
```sql
pinned INTEGER DEFAULT 0 -- Non épinglé par défaut
ttl_expires_at TEXT -- NULL par défaut
```
Les bases existantes sont automatiquement compatibles :
- Tous les items existants sont non épinglés
- Aucun TTL défini par défaut
- Le comportement LRU standard reste identique
### Rétrocompatibilité du code
Toutes les méthodes existantes continuent de fonctionner :
- `add_from_url()`, `add_from_file()`, `get()`, etc.
- Pas de changement de signature
- Comportement LRU identique pour les items non épinglés
## Documentation API REST
### Tableau récapitulatif des endpoints
| Méthode | Route | Description | Codes retour |
|---------|-------|-------------|--------------|
| `GET` | `/{pk}/pin` | Récupère le statut de pinning | 200, 404 |
| `POST` | `/{pk}/pin` | Épingle un item | 200, 404, 409 |
| `DELETE` | `/{pk}/pin` | Désépingle un item | 200, 404 |
| `POST` | `/{pk}/ttl` | Définit le TTL | 200, 400, 404, 409 |
| `DELETE` | `/{pk}/ttl` | Supprime le TTL | 200, 404 |
### Codes de statut HTTP
| Code | Signification | Quand ? |
|------|--------------|---------|
| `200` | Succès | Opération réussie |
| `400` | Requête invalide | Format de date TTL incorrect |
| `404` | Non trouvé | PK inexistant dans le cache |
| `409` | Conflit | Violation de règle métier (pin+TTL) |
| `500` | Erreur serveur | Erreur de base de données |
### Structure des erreurs
Format cohérent pour toutes les erreurs :
```json
{
"error": "CODE_ERREUR",
"message": "Description lisible pour l'utilisateur"
}
```
Exemples :
- `"CONFLICT"` : Violation de règle métier
- `"NOT_FOUND"` : Item inexistant
- `"INVALID_DATE"` : Format de date RFC3339 invalide
- `"PIN_ERROR"` / `"TTL_ERROR"` : Erreur technique
## Exemples d'utilisation
### Utilisation programmatique (Rust)
```rust
use pmocache::{Cache, CacheConfig};
use chrono::{Duration, Utc};
// Créer un cache
let cache = Cache::<MyConfig>::new("./cache", 100)?;
// Ajouter un fichier
let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
// ═══════════════════════════════════════
// Scénario 1 : Item permanent (épinglé)
// ═══════════════════════════════════════
cache.pin(&pk).await?;
// Vérifier le statut
assert!(cache.is_pinned(&pk).await?);
// L'item ne sera JAMAIS supprimé automatiquement
// même si le cache est plein
// ═══════════════════════════════════════
// Scénario 2 : Item temporaire (TTL)
// ═══════════════════════════════════════
let pk2 = cache.add_from_url("https://example.com/temp.dat", None).await?;
// Définir une expiration dans 24h
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
cache.set_ttl(&pk2, &expires_at).await?;
// L'item sera automatiquement supprimé après 24h
// lors du prochain appel à enforce_limit()
// ═══════════════════════════════════════
// Scénario 3 : Conversion épinglé → TTL
// ═══════════════════════════════════════
cache.unpin(&pk).await?; // Désépingler d'abord
cache.set_ttl(&pk, &expires_at).await?; // OK maintenant
```
### Utilisation via API REST
#### Workflow complet : Épingler un fichier important
```bash
# 1. Ajouter un fichier au cache
curl -X POST http://localhost:8080/api/audio/ \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/important.flac"}'
# Réponse :
# {
# "pk": "abc123def456",
# "url": "https://example.com/important.flac",
# "message": "Item added successfully"
# }
# 2. Vérifier le statut actuel
curl http://localhost:8080/api/audio/abc123def456/pin
# Réponse :
# {
# "pk": "abc123def456",
# "pinned": false,
# "ttl_expires_at": null
# }
# 3. Épingler le fichier
curl -X POST http://localhost:8080/api/audio/abc123def456/pin
# Réponse :
# {
# "pk": "abc123def456",
# "message": "Item 'abc123def456' pinned successfully"
# }
# 4. Vérifier qu'il est épinglé
curl http://localhost:8080/api/audio/abc123def456/pin
# Réponse :
# {
# "pk": "abc123def456",
# "pinned": true,
# "ttl_expires_at": null
# }
```
#### Workflow : Fichier temporaire avec TTL
```bash
# 1. Ajouter un fichier
curl -X POST http://localhost:8080/api/audio/ \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/preview.flac"}'
# Réponse : {"pk": "xyz789abc123", ...}
# 2. Définir un TTL de 1 heure
curl -X POST http://localhost:8080/api/audio/xyz789abc123/ttl \
-H "Content-Type: application/json" \
-d '{"expires_at": "2025-01-15T11:30:00Z"}'
# Réponse :
# {
# "pk": "xyz789abc123",
# "message": "TTL set successfully for item 'xyz789abc123'"
# }
# 3. Le fichier sera automatiquement supprimé après expiration
```
#### Gestion d'erreur : Conflit de règle métier
```bash
# 1. Épingler un item
curl -X POST http://localhost:8080/api/audio/abc123/pin
# OK
# 2. Essayer de définir un TTL (interdit)
curl -X POST http://localhost:8080/api/audio/abc123/ttl \
-H "Content-Type: application/json" \
-d '{"expires_at": "2025-01-15T12:00:00Z"}'
# Réponse 409 CONFLICT :
# {
# "error": "CONFLICT",
# "message": "Cannot set TTL on a pinned item. Unpin first."
# }
# 3. Solution : désépingler puis définir TTL
curl -X DELETE http://localhost:8080/api/audio/abc123/pin
curl -X POST http://localhost:8080/api/audio/abc123/ttl \
-H "Content-Type: application/json" \
-d '{"expires_at": "2025-01-15T12:00:00Z"}'
# OK
```
## Fichiers modifiés
### Phase 1 : Implémentation de base
1. **`pmocache/src/db.rs`** (380 lignes ajoutées)
- Modification du schéma SQL (colonnes `pinned`, `ttl_expires_at`)
- Ajout de champs dans `CacheEntry`
- 8 nouvelles méthodes : `count_unpinned()`, `pin()`, `unpin()`, `is_pinned()`, `set_ttl()`, `clear_ttl()`, `get_expired()`
- Modification de `get_oldest()` pour exclure les items épinglés
- Mise à jour de toutes les requêtes SELECT
2. **`pmocache/src/cache.rs`** (135 lignes ajoutées)
- 5 nouvelles méthodes publiques : `pin()`, `unpin()`, `is_pinned()`, `set_ttl()`, `clear_ttl()`
- Refonte complète de `enforce_limit()` :
- Suppression prioritaire des items expirés
- Utilisation de `count_unpinned()`
- Protection des items épinglés
3. **`pmocache/tests/test_pinnable.rs`** (280 lignes, nouveau fichier)
- 9 tests exhaustifs
- Couverture complète des cas d'usage
- Validation des règles métier
### Phase 2 : Enrichissement API REST
4. **`pmocache/src/api.rs`** (230 lignes ajoutées)
- 3 nouvelles structures : `SetTtlRequest`, `PinResponse`, `PinStatus`
- 5 nouveaux handlers HTTP avec gestion d'erreurs complète
- Validation des règles métier au niveau HTTP
- Codes de statut appropriés (200, 400, 404, 409, 500)
5. **`pmocache/src/pmoserver_ext.rs`** (15 lignes modifiées)
- 2 nouvelles routes dans `create_api_router()` :
- `/{pk}/pin` (GET, POST, DELETE)
- `/{pk}/ttl` (POST, DELETE)
- Documentation des routes mise à jour
6. **`pmocache/src/openapi.rs`** (10 lignes modifiées)
- Macro `create_cache_openapi!` enrichie
- 5 nouveaux endpoints documentés
- 3 nouveaux schémas de données
7. **`pmocache/src/lib.rs`** (5 lignes modifiées)
- Export des structures publiques pour l'API
**Total** : 7 fichiers modifiés, ~1055 lignes de code ajoutées
## Avantages de la solution
### 1. Architecture propre et extensible
- **Séparation des responsabilités** :
- `db.rs` : logique de base de données
- `cache.rs` : logique métier
- `api.rs` : interface HTTP
- **Réutilisabilité** :
- Traits existants conservés
- Pas de duplication de code
- Pattern cohérent avec l'architecture PMOcache
### 2. Sécurité et fiabilité
- **Règles métier strictes** :
- Incompatibilité TTL ↔ Pinned appliquée à tous les niveaux
- Validation au niveau DB, cache ET API
- **Gestion d'erreurs robuste** :
- Codes HTTP sémantiques
- Messages explicites
- Pas d'état incohérent possible
### 3. Performance
- **Requêtes SQL optimisées** :
- Index sur `pinned` pour requêtes rapides
- `WHERE pinned = 0` évite le scan complet
- **Comptage efficace** :
- `count_unpinned()` utilise un index
- Pas de post-filtrage en mémoire
### 4. Expérience développeur
- **API intuitive** :
- Méthodes async cohérentes avec l'existant
- Nommage clair (`pin()`, `unpin()`, `set_ttl()`)
- **Documentation complète** :
- OpenAPI générée automatiquement
- Swagger UI interactive
- Exemples d'utilisation
### 5. Compatibilité
- **Migration transparente** :
- Aucune intervention manuelle
- Valeurs par défaut appropriées
- **Pas de breaking change** :
- API existante inchangée
- Nouveaux champs optionnels dans `CacheEntry`
## Cas d'usage concrets
### 1. Cache de couvertures d'albums
```rust
// Épingler les couvertures des albums favoris
for album in user.favorite_albums {
let cover_pk = covers_cache.get_cover_pk(&album.id).await?;
covers_cache.pin(&cover_pk).await?;
}
// → Les couvertures favorites restent toujours en cache
// → Même si le cache se remplit de nouvelles couvertures
```
### 2. Cache audio avec previews temporaires
```rust
// Pistes complètes : épinglées si dans la playlist courante
for track in current_playlist.tracks {
audio_cache.pin(&track.pk).await?;
}
// Previews de 30 secondes : TTL de 1 heure
let preview_pk = audio_cache.add_preview(&track_url).await?;
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
audio_cache.set_ttl(&preview_pk, &expires_at).await?;
// → Pistes courantes toujours disponibles
// → Previews nettoyées automatiquement
```
### 3. Cache de métadonnées avec rafraîchissement
```rust
// Métadonnées d'album : TTL de 24h pour forcer le rafraîchissement
let metadata_pk = metadata_cache.add_metadata(&album).await?;
let tomorrow = (Utc::now() + Duration::days(1)).to_rfc3339();
metadata_cache.set_ttl(&metadata_pk, &tomorrow).await?;
// → Métadonnées rafraîchies quotidiennement
// → Pas de données obsolètes
```
## Limitations et considérations
### 1. Pas de limite sur les items épinglés
Les items épinglés peuvent s'accumuler indéfiniment. Recommandations :
```rust
// Surveiller le nombre d'items épinglés
let pinned_count = cache.db.count()? - cache.db.count_unpinned()?;
if pinned_count > MAX_PINNED_ITEMS {
warn!("Too many pinned items: {}", pinned_count);
}
```
### 2. TTL vérifié uniquement lors de `enforce_limit()`
Les items expirés ne sont pas supprimés immédiatement. Solutions possibles :
```rust
// Option 1 : Appel périodique
tokio::spawn(async move {
loop {
tokio::time::sleep(Duration::from_secs(3600)).await;
cache.enforce_limit().await?;
}
});
// Option 2 : Vérification à l'accès
if let Ok(entry) = cache.db.get(&pk, false) {
if let Some(ttl) = entry.ttl_expires_at {
if Utc::now() > DateTime::parse_from_rfc3339(&ttl)? {
cache.delete_item(&pk).await?;
}
}
}
```
### 3. Format de date RFC3339 strict
L'API exige le format RFC3339. Exemples valides :
```
2025-01-15T10:30:00Z ✅ UTC
2025-01-15T10:30:00+01:00 ✅ Avec timezone
2025-01-15T10:30:00.123Z ✅ Avec millisecondes
2025-01-15 10:30:00 ❌ Format invalide
```
## Évolutions futures possibles
### 1. Gestion automatique du TTL
Implémenter un worker en arrière-plan :
```rust
pub async fn start_ttl_worker(&self) {
tokio::spawn(async move {
loop {
self.enforce_limit().await;
tokio::time::sleep(Duration::from_secs(60)).await;
}
});
}
```
### 2. Pinning conditionnel
Épingler automatiquement selon des critères :
```rust
pub async fn pin_if<F>(&self, predicate: F) -> Result<Vec<String>>
where
F: Fn(&CacheEntry) -> bool,
{
let entries = self.db.get_all(false)?;
let mut pinned = Vec::new();
for entry in entries {
if predicate(&entry) && !entry.pinned {
self.pin(&entry.pk).await?;
pinned.push(entry.pk);
}
}
Ok(pinned)
}
// Utilisation
cache.pin_if(|e| e.hits > 100).await?; // Épingler les plus utilisés
```
### 3. TTL relatif
Faciliter la définition de TTL :
```rust
pub async fn set_ttl_relative(&self, pk: &str, duration: Duration) -> Result<()> {
let expires_at = (Utc::now() + duration).to_rfc3339();
self.set_ttl(pk, &expires_at).await
}
// Utilisation
cache.set_ttl_relative(&pk, Duration::hours(24)).await?;
```
### 4. Statistiques de pinning
```rust
pub async fn get_pinning_stats(&self) -> Result<PinningStats> {
Ok(PinningStats {
total_items: self.db.count()?,
pinned_items: self.db.count()? - self.db.count_unpinned()?,
items_with_ttl: self.db.count_with_ttl()?,
expired_items: self.db.get_expired()?.len(),
})
}
```
## Résultats et métriques
### Tests
| Catégorie | Tests | Passés | Taux |
|-----------|-------|--------|------|
| Nouveaux tests | 9 | 9 | 100% |
| Tests existants | 15 | 15 | 100% |
| **Total** | **24** | **24** | **100%** |
### Code
| Métrique | Valeur |
|----------|--------|
| Fichiers modifiés | 7 |
| Lignes ajoutées | ~1055 |
| Nouvelles méthodes DB | 8 |
| Nouvelles méthodes Cache | 5 |
| Nouveaux endpoints API | 5 |
| Nouvelles structures | 3 |
### Compilation
- ✅ Aucune erreur
- ✅ Aucun warning
- ✅ Toutes les features compilent
## Conclusion
L'implémentation des items épinglables et du TTL dans PMOcache est **complète et production-ready**. La solution répond à tous les objectifs initiaux :
### ✅ Objectifs atteints
1. **Items épinglables fonctionnels** :
- Protection absolue contre l'éviction LRU
- Exclusion du comptage de la limite du cache
2. **Système de TTL robuste** :
- Expiration automatique des items temporaires
- Suppression prioritaire lors de l'éviction
3. **Règle métier stricte** :
- Incompatibilité TTL ↔ Pinned garantie à tous les niveaux
- Validation DB, cache et API
4. **API REST complète** :
- 5 nouveaux endpoints documentés
- Gestion d'erreurs cohérente
- Documentation OpenAPI automatique
5. **Compatibilité préservée** :
- Migration transparente des bases existantes
- Aucun breaking change dans l'API
- Tous les tests existants passent
### Points forts
- **Architecture propre** : Séparation claire des responsabilités
- **Code maintenable** : Bien documenté, testé exhaustivement
- **Extensible** : Facile d'ajouter de nouvelles fonctionnalités
- **Performant** : Requêtes SQL optimisées avec index
- **Sécurisé** : Règles métier appliquées strictement
### Prêt pour la production
La fonctionnalité peut être déployée immédiatement :
- Tous les tests passent
- Documentation complète
- API stable et documentée
- Pas de régression sur l'existant
Cette implémentation renforce significativement PMOcache en le rendant adapté à une gamme plus large de cas d'usage, tout en maintenant sa simplicité et sa robustesse.

View File

@@ -1,152 +0,0 @@
# Rapport : Suppression de la logique de débouncing SSE
**Date**: 2026-01-12
**Tâche**: WeabApp_debouncingSSE.md
## Objectif
Supprimer la logique de débouncing inutile sur le canal SSE de l'application web PMOControl, puisque le serveur contrôle déjà le flux des événements.
## Analyse préalable
J'ai identifié trois endroits avec des mécanismes de temporisation dans l'application web :
### 1. MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)
- **Débouncing**: 200ms après invalidation du cache
- **Cooldown**: 2 secondes entre les rechargements
- **Justification originale**: "dédupliquer les événements SSE dans le même batch (polling 500ms)"
- **Problème**: Cette logique est redondante puisque le serveur contrôle déjà le flux SSE
### 2. useRenderers.ts - Smart fetching (À CONSERVER ✓)
- **Mécanisme**: Comparaison des timestamps `lastEventAt` vs `lastSnapshotAt`
- **But**: Éviter de refetch un snapshot déjà à jour
- **Justification**: Ce n'est PAS du débouncing, c'est une optimisation intelligente qui évite des appels API inutiles
### 3. VolumeControl.vue - UI debouncing (À CONSERVER ✓)
- **Débouncing**: 300ms sur les changements de volume
- **But**: Réduire les appels API pendant que l'utilisateur fait glisser le curseur
- **Justification**: Débouncing légitime pour l'interface utilisateur
## Modifications effectuées
### Fichier modifié: `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
#### 1. Suppression des variables de débouncing (ligne ~27)
**Avant**:
```typescript
// Flags pour gérer le rechargement automatique avec debounce et cooldown
const isRefreshing = ref(false);
const refreshTimeoutId = ref<number | null>(null);
const lastRefreshTime = ref<number>(0);
const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
```
**Après**:
```typescript
// Flag pour gérer le rechargement automatique
const isRefreshing = ref(false);
```
#### 2. Simplification du watcher de cache (ligne ~53)
**Avant**:
```typescript
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
// et un cooldown de 5 secondes pour éviter les rechargements successifs
watch(
() => browseData.value,
(data) => {
if (!data && props.containerId && !loading.value) {
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
console.log(
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
);
return;
}
// Annuler tout timeout en cours
if (refreshTimeoutId.value !== null) {
clearTimeout(refreshTimeoutId.value);
}
// Planifier le rechargement après 200ms
refreshTimeoutId.value = window.setTimeout(async () => {
if (!isRefreshing.value) {
console.log(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
);
isRefreshing.value = true;
await browseContainer(
props.serverId,
props.containerId,
false,
);
lastRefreshTime.value = Date.now();
isRefreshing.value = false;
refreshTimeoutId.value = null;
}
}, 200);
}
},
);
```
**Après**:
```typescript
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
watch(
() => browseData.value,
async (data) => {
// Si browseData devient undefined alors que containerId est présent,
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
if (!data && props.containerId && !loading.value && !isRefreshing.value) {
console.log(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
);
isRefreshing.value = true;
await browseContainer(props.serverId, props.containerId, false);
isRefreshing.value = false;
}
},
);
```
## Résultats
### Changements de comportement
- **Avant**: Délai de 200ms + cooldown de 2s entre les rechargements de cache
- **Après**: Rechargement immédiat dès l'invalidation du cache
- **Impact**: Réactivité améliorée de l'interface, les mises à jour apparaissent immédiatement
### Réduction de complexité
- **3 variables supprimées**: `refreshTimeoutId`, `lastRefreshTime`, `REFRESH_COOLDOWN_MS`
- **Logique simplifiée**: De ~40 lignes à ~10 lignes dans le watcher
- **Code plus lisible**: Intention claire sans mécanismes de temporisation complexes
### Tests
- ✓ Le projet compile sans erreurs TypeScript
- ✓ Le flag `isRefreshing` empêche toujours les rechargements concurrents
- ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) conservent leurs optimisations légitimes
## Conclusion
La suppression du débouncing et du cooldown dans MediaBrowser.vue simplifie le code tout en améliorant la réactivité de l'interface. Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client étaient redondants et ajoutaient une latence artificielle.
Le code est maintenant plus simple, plus réactif, et fait confiance au serveur pour contrôler la fréquence des événements SSE.
## Fichiers modifiés
- `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
## Lignes de code
- **Supprimées**: ~35 lignes (logique de débouncing/cooldown)
- **Ajoutées**: ~5 lignes (logique simplifiée)
- **Net**: -30 lignes

View File

@@ -1,138 +0,0 @@
# Rapport Final : Implémentation du Shuffle de la Queue de Lecture
## Résumé
Implémentation complète de la fonctionnalité de shuffle (mélange aléatoire) de la queue de lecture pour les Music Renderers dans PMOMusic. Cette fonctionnalité permet de randomiser l'ordre des morceaux dans la queue et de redémarrer la lecture depuis le premier morceau.
Au-delà de la fonctionnalité shuffle, ce travail a permis d'améliorer l'architecture en centralisant l'émission des événements SSE dans le `MusicRenderer` plutôt que dans le `ControlPoint`.
## Travail effectué
### Étape 1 : Implémentation de la méthode shuffle dans MusicRenderer
**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs`
Ajout de la méthode `shuffle_queue()` qui implémente la stratégie suivante :
1. Détache la queue de lecture d'une playlist si celle-ci est attachée
2. Arrête la lecture en cours
3. Prend un snapshot de la queue actuelle
4. Randomise l'ordre des morceaux avec `rand::seq::SliceRandom`
5. Remplace la queue avec les items mélangés
6. Redémarre la lecture au premier morceau
**Dépendances ajoutées** :
- `rand = "0.9"` dans `Cargo.toml` (workspace)
- `rand = { workspace = true }` dans `pmocontrol/Cargo.toml`
### Étape 2 : API REST et documentation OpenAPI
**Fichiers modifiés** :
- `pmocontrol/src/pmoserver_ext.rs` : Ajout du handler `shuffle_queue`
- `pmocontrol/src/openapi.rs` : Ajout du path dans la documentation OpenAPI
**Endpoint créé** :
```
POST /api/control/renderers/{renderer_id}/queue/shuffle
```
**Réponses** :
- `200` : Queue mélangée et lecture démarrée
- `400` : Queue vide
- `404` : Renderer non trouvé
- `504` : Timeout de la commande
- `500` : Erreur interne
### Étape 3 : Interface Vue.js
**Fichiers créés** :
- `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` : Nouveau composant bouton shuffle
**Fichiers modifiés** :
- `pmoapp/webapp/src/services/pmocontrol/api.ts` : Ajout de la méthode `shuffleQueue()`
- `pmoapp/webapp/src/components/unified/BottomTabBar.vue` : Intégration du bouton shuffle à côté du timer
**Design** :
- Bouton circulaire avec icône Shuffle (lucide-vue-next)
- Style cohérent avec le bouton Timer existant
- Animation de chargement pendant l'exécution
- Responsive (taille réduite sur mobile)
### Étape 4 : Émission automatique des événements SSE (Round 3)
**Problème identifié** : L'interface utilisateur ne se mettait pas à jour après un shuffle car aucun événement `QueueUpdated` n'était émis.
**Solution implémentée** : Le `MusicRenderer` stocke maintenant une référence optionnelle au `RendererEventBus` et émet automatiquement un événement `QueueUpdated` après chaque modification de la queue.
**Fichiers modifiés** :
| Fichier | Modification |
|---------|--------------|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout du champ `event_bus: Option<RendererEventBus>`, constructeur `from_renderer_info_with_bus()`, méthode helper `emit_queue_updated()`, implémentation manuelle de `Debug` |
| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers via `from_renderer_info_with_bus()` |
**Méthodes qui émettent désormais `QueueUpdated`** :
- `enqueue_items()` - Ajout d'items à la queue
- `sync_queue()` - Synchronisation de la queue
- `clear_queue()` - Vidage de la queue
- `replace_queue()` - Remplacement complet de la queue (utilisé par `shuffle_queue()`)
- `play_next_from_queue()` - Passage au morceau suivant
- `play_from_index()` - Lecture à un index spécifique
### Étape 5 : Refactoring des émissions d'événements (Round 4)
**Objectif** : Centraliser les émissions d'événements dans le `MusicRenderer` et supprimer les émissions redondantes du `ControlPoint`.
**Principe** : Puisque le `MusicRenderer` a maintenant accès au `RendererEventBus`, il est plus cohérent et maintenable que les événements soient émis au niveau du renderer plutôt que dispersés dans le `ControlPoint`.
#### Événements `QueueUpdated`
**Modifications dans `ControlPoint`** - Suppression des émissions redondantes dans :
- `clear_queue()`
- `enqueue_items_with_mode()`
- `shuffle_queue()`
- `play_next_from_queue()`
#### Événements `BindingChanged`
**Modifications dans `MusicRenderer`** :
- `set_playlist_binding()` : Émet `BindingChanged` uniquement si le binding change réellement
- `clear_playlist_binding()` : Émet `BindingChanged` uniquement s'il y avait un binding à supprimer
- Ajout de la méthode helper `emit_binding_changed()`
**Modifications dans `ControlPoint`** :
- `attach_queue_to_playlist_internal()` : Suppression de l'émission manuelle de `BindingChanged`
- `detach_playlist_binding()` : Suppression de l'émission manuelle, utilisation de `clear_playlist_binding()` au lieu de `set_playlist_binding(None)`
## Liste complète des fichiers modifiés
| Fichier | Type de modification |
|---------|---------------------|
| `Cargo.toml` (workspace) | Ajout dépendance `rand` |
| `pmocontrol/Cargo.toml` | Ajout dépendance `rand` |
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `shuffle_queue()`, `event_bus`, émission d'événements automatique |
| `pmocontrol/src/control_point.rs` | Suppression des émissions d'événements redondantes |
| `pmocontrol/src/registry.rs` | Passage du `RendererEventBus` lors de la création des renderers |
| `pmocontrol/src/pmoserver_ext.rs` | Ajout handler REST `shuffle_queue` |
| `pmocontrol/src/openapi.rs` | Ajout documentation OpenAPI |
| `pmoapp/webapp/src/services/pmocontrol/api.ts` | Ajout méthode API `shuffleQueue()` |
| `pmoapp/webapp/src/components/unified/BottomTabBar.vue` | Intégration du bouton shuffle |
## Fichiers créés
| Fichier | Description |
|---------|-------------|
| `pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue` | Composant Vue.js du bouton shuffle |
## Notes techniques
- La méthode `shuffle_queue` détache automatiquement la playlist liée pour éviter que la queue soit écrasée par une mise à jour de la playlist
- Le shuffle utilise `rand::thread_rng()` pour une génération aléatoire de qualité
- L'endpoint REST utilise le même pattern async que les autres commandes de transport (spawn_blocking + timeout)
- Le timeout utilisé est `QUEUE_COMMAND_TIMEOUT` (10 secondes)
- L'émission des événements SSE est automatique via le `RendererEventBus` intégré au `MusicRenderer`
- L'implémentation manuelle de `Debug` pour `MusicRenderer` est nécessaire car `RendererEventBus` n'implémente pas `Debug`
- Les événements ne sont émis que lorsqu'il y a un changement effectif (pas d'événement `BindingChanged` si le binding était déjà `None`)
## Améliorations architecturales
Ce travail a posé les bases d'une meilleure architecture où le `MusicRenderer` est responsable de l'émission de ses propres événements. Une tâche de suivi a été créée (`Blackboard/Todo/stateful_music_renderer.md`) pour aller plus loin et rendre le `MusicRenderer` complètement stateful avec son propre thread de surveillance.

View File

@@ -1,114 +0,0 @@
# Tâche terminée : Rendre MusicRenderer complètement stateful
## Objectif
Refactoriser l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé.
## Motivation
1. **Encapsulation** - Tout l'état et le comportement d'un renderer au même endroit
2. **Cohérence** - Les événements sont émis là où l'état change
3. **Adaptabilité par backend** - Chaque backend peut avoir sa propre stratégie de surveillance (polling vs push)
4. **Auto-advance spécifique** - La logique d'auto-advance peut être adaptée par backend
5. **Simplicité du ControlPoint** - Il devient un simple registry/coordinateur
---
## Résumé de l'implémentation
### Fichiers créés
| Fichier | Description |
|---------|-------------|
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec `WatchStrategy`, `WatchedState` et fonctions helper |
### Fichiers modifiés
| Fichier | Modification |
|---------|--------------|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Champs watcher, méthodes `start/stop_watching()`, logique auto-advance, gestion automatique dans constructeur et `DeviceOnline` |
| `pmocontrol/src/music_renderer/mod.rs` | Export du module `watcher` |
| `pmocontrol/src/registry.rs` | Simplifié : plus d'appels manuels watcher |
| `pmocontrol/src/control_point.rs` | Suppression polling central (~140 lignes), `RendererRuntimeSnapshot`, `handle_renderer_event()` |
---
## Architecture finale
### WatchStrategy
```rust
pub enum WatchStrategy {
Polling { interval_ms: u64 }, // UPnP, LinkPlay, Arylic (500ms)
Push, // Futur : notifications push
Hybrid { polling_interval_ms: u64 }, // OpenHome, Chromecast
}
```
### Gestion automatique du watcher
```
┌─────────────────────────────────────────────────────────────────┐
│ GESTION AUTOMATIQUE DU WATCHER │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Création MusicRenderer ──► constructeur ──► start_watching() │
│ │
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
│ │
│ mark_as_offline() ──► stop_watching() ──► online = false │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Flux offline/online
```
┌─────────────────────────────────────────────────────────────────┐
│ FLUX ONLINE │
├─────────────────────────────────────────────────────────────────┤
│ SSDP Discovery ──► push_renderer() ──► constructeur │
│ ──► start_watching() │
│ │
│ SSDP Alive (offline→online) ──► has_been_seen_now() │
│ ──► start_watching() │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ FLUX OFFLINE │
├─────────────────────────────────────────────────────────────────┤
│ SSDP ByeBye / Timeout ──► mark_as_offline() │
│ ──► stop_watching() │
└─────────────────────────────────────────────────────────────────┘
```
---
## Points techniques clés
- **Thread safety** : `AtomicBool` avec `Ordering::SeqCst` pour le signal d'arrêt
- **Idempotence** : `start_watching()` et `stop_watching()` sont idempotents
- **Nommage** : Thread nommé `watcher-{friendly_name}` pour debug
- **Polling** : 500ms pour position/état, 1s pour volume/mute
- **Auto-advance** : Géré dans `handle_state_change()` du MusicRenderer
- **Compensation bugs** : `compute_logical_playback_state()` corrige les comportements Arylic/LinkPlay
---
## Rounds de vérification
| Round | Objectif | Résultat |
|-------|----------|----------|
| 1 | Implémentation initiale | OK |
| 2 | Vérifier transition offline→online | Bug trouvé et corrigé dans `refresh_device_presence()` |
| 3 | Audit complet des chemins offline/online | Tous les chemins vérifiés OK |
| 4 | Centralisation dans `MusicRenderer` | Gestion automatique dans constructeur et `DeviceOnline` |
---
## Conclusion
L'architecture est maintenant plus robuste :
- Impossible d'oublier de démarrer/arrêter le watcher
- Le `registry.rs` est simplifié
- Préparation pour le support futur des notifications push (OpenHome, Chromecast)

View File

@@ -1,390 +0,0 @@
# Rapport : Implémentation des items épinglables dans PMOcache
## Résumé
Implémentation réussie de la fonctionnalité d'items épinglables dans la crate PMOcache, permettant de protéger certains items de l'éviction automatique par la politique LRU. Cette fonctionnalité inclut également un système de TTL (Time To Live) avec une règle métier empêchant qu'un item soit à la fois épinglé et avec un TTL.
## Modifications apportées
### 1. Structure de la base de données (`pmocache/src/db.rs`)
#### Modification du schéma de la table `asset`
Ajout de deux nouvelles colonnes :
```sql
CREATE TABLE IF NOT EXISTS asset (
pk TEXT PRIMARY KEY,
collection TEXT,
id TEXT,
hits INTEGER DEFAULT 0,
last_used TEXT,
lazy_pk TEXT,
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
ttl_expires_at TEXT
)
```
- **`pinned`** : Booléen (0 ou 1) indiquant si l'item est épinglé
- **`ttl_expires_at`** : Date/heure d'expiration au format RFC3339 (optionnel)
#### Mise à jour de la structure `CacheEntry`
Ajout des champs correspondants :
```rust
pub struct CacheEntry {
// ... champs existants ...
pub pinned: bool,
pub ttl_expires_at: Option<String>,
// ...
}
```
#### Nouvelles méthodes dans `DB`
##### Gestion du comptage
- **`count_unpinned()`** : Compte uniquement les items non épinglés
- Les items épinglés ne comptent pas dans la limite du cache
##### Gestion du pinning
- **`pin(pk: &str)`** : Épingle un item
- Vérifie que l'item n'a pas de TTL défini (règle métier)
- Retourne une erreur si le TTL est déjà défini
- **`unpin(pk: &str)`** : Désépingle un item
- **`is_pinned(pk: &str)`** : Vérifie si un item est épinglé
##### Gestion du TTL
- **`set_ttl(pk: &str, expires_at: &str)`** : Définit le TTL d'un item
- Vérifie que l'item n'est pas épinglé (règle métier)
- Retourne une erreur si l'item est épinglé
- **`clear_ttl(pk: &str)`** : Supprime le TTL d'un item
- **`get_expired()`** : Récupère tous les items dont le TTL est dépassé
##### Modification de `get_oldest()`
La requête SQL exclut maintenant les items épinglés :
```sql
SELECT ... FROM asset
WHERE pinned = 0
ORDER BY last_used ASC, hits ASC
LIMIT ?1
```
### 2. Logique du cache (`pmocache/src/cache.rs`)
#### Méthodes publiques ajoutées
```rust
pub async fn pin(&self, pk: &str) -> Result<()>
pub async fn unpin(&self, pk: &str) -> Result<()>
pub async fn is_pinned(&self, pk: &str) -> Result<bool>
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()>
pub async fn clear_ttl(&self, pk: &str) -> Result<()>
```
#### Modification de `enforce_limit()`
La politique d'éviction a été améliorée :
1. **Suppression prioritaire des items expirés** : Les items dont le TTL est dépassé sont supprimés en premier
2. **Comptage des items non épinglés** : Utilise `count_unpinned()` au lieu de `count()`
3. **Protection des items épinglés** : Ils ne peuvent pas être évincés par LRU
4. **Logging amélioré** : Messages distincts pour les items expirés et l'éviction LRU
### 3. Tests (`pmocache/tests/test_pinnable.rs`)
Création d'une suite complète de tests (9 tests, tous passants) :
1. **`test_pin_unpin`** : Vérifie l'épinglage et le désépinglage basiques
2. **`test_pinned_excluded_from_lru`** : Vérifie que les items épinglés ne sont pas évincés
3. **`test_pinned_count_separately`** : Vérifie le comptage séparé des items épinglés
4. **`test_cannot_pin_with_ttl`** : Vérifie la règle métier TTL → pas de pinning
5. **`test_cannot_set_ttl_when_pinned`** : Vérifie la règle métier pinned → pas de TTL
6. **`test_ttl_expiration`** : Vérifie la suppression automatique des items expirés
7. **`test_clear_ttl`** : Vérifie la suppression du TTL
8. **`test_get_expired`** : Vérifie la récupération des items expirés
9. **`test_cache_entry_fields`** : Vérifie les valeurs des champs dans `CacheEntry`
## Règles métier implémentées
### Incompatibilité TTL ↔ Pinned
Un item ne peut pas être à la fois épinglé ET avoir un TTL :
- **Si TTL défini** : `pin()` retourne une erreur
- **Si épinglé** : `set_ttl()` retourne une erreur
Cette règle garantit une sémantique claire :
- **Épinglé** = permanent, protégé de l'éviction
- **TTL** = temporaire, sera supprimé à expiration
### Comptage des items
Les items épinglés sont **exclus** du comptage de la limite du cache :
- Un cache de limite 100 peut contenir 100 items non épinglés + N items épinglés
- Seuls les items non épinglés sont pris en compte pour l'éviction LRU
### Ordre de suppression lors de `enforce_limit()`
1. **Items expirés (TTL dépassé)** : supprimés en priorité
2. **Items LRU** : si la limite est toujours dépassée, suppression des plus vieux items **non épinglés**
## Compatibilité
### Migration de base de données
**Aucune migration nécessaire** : Les colonnes `pinned` et `ttl_expires_at` ont des valeurs par défaut :
- `pinned = 0` (non épinglé)
- `ttl_expires_at = NULL` (pas de TTL)
Les bases existantes seront automatiquement mises à jour au prochain démarrage via le `CREATE TABLE IF NOT EXISTS` avec les nouvelles colonnes.
### Rétrocompatibilité du code
Toutes les méthodes existantes continuent de fonctionner sans modification :
- Les items existants ne sont pas épinglés par défaut
- Le comportement LRU standard reste identique pour les items non épinglés
## Exemples d'utilisation
### Utilisation programmatique (Rust)
```rust
use pmocache::{Cache, CacheConfig};
use chrono::{Duration, Utc};
// Créer un cache
let cache = Cache::<MyConfig>::new("./cache", 100).unwrap();
// Ajouter un fichier
let pk = cache.add_from_url("https://example.com/file.dat", None).await?;
// Épingler pour protéger de l'éviction
cache.pin(&pk).await?;
// Ou définir un TTL de 24 heures
let expires_at = (Utc::now() + Duration::hours(24)).to_rfc3339();
cache.set_ttl(&pk2, &expires_at).await?;
// Vérifier le statut
if cache.is_pinned(&pk).await? {
println!("Fichier protégé");
}
```
### Utilisation via l'API REST
#### Récupérer le statut de pinning
```bash
GET /api/cache/{pk}/pin
Response 200 OK:
{
"pk": "1a2b3c4d5e6f7a8b",
"pinned": false,
"ttl_expires_at": null
}
```
#### Épingler un item
```bash
POST /api/cache/{pk}/pin
Response 200 OK:
{
"pk": "1a2b3c4d5e6f7a8b",
"message": "Item '1a2b3c4d5e6f7a8b' pinned successfully"
}
Response 409 CONFLICT (si TTL défini):
{
"error": "CONFLICT",
"message": "Cannot pin an item with TTL set. Clear TTL first."
}
```
#### Désépingler un item
```bash
DELETE /api/cache/{pk}/pin
Response 200 OK:
{
"pk": "1a2b3c4d5e6f7a8b",
"message": "Item '1a2b3c4d5e6f7a8b' unpinned successfully"
}
```
#### Définir un TTL
```bash
POST /api/cache/{pk}/ttl
Content-Type: application/json
{
"expires_at": "2025-01-20T10:30:00Z"
}
Response 200 OK:
{
"pk": "1a2b3c4d5e6f7a8b",
"message": "TTL set successfully for item '1a2b3c4d5e6f7a8b'"
}
Response 409 CONFLICT (si épinglé):
{
"error": "CONFLICT",
"message": "Cannot set TTL on a pinned item. Unpin first."
}
Response 400 BAD REQUEST (format invalide):
{
"error": "INVALID_DATE",
"message": "Invalid RFC3339 date format"
}
```
#### Supprimer un TTL
```bash
DELETE /api/cache/{pk}/ttl
Response 200 OK:
{
"pk": "1a2b3c4d5e6f7a8b",
"message": "TTL cleared successfully for item '1a2b3c4d5e6f7a8b'"
}
```
## Fichiers modifiés
### Phase 1 : Implémentation de base
1. **`pmocache/src/db.rs`** :
- Modification du schéma SQL
- Ajout de champs dans `CacheEntry`
- Ajout de 8 nouvelles méthodes
- Modification de `get_oldest()`, `get()`, `get_from_id()`, `get_all()`, `get_by_collection()`
2. **`pmocache/src/cache.rs`** :
- Ajout de 5 méthodes publiques
- Modification de `enforce_limit()`
3. **`pmocache/tests/test_pinnable.rs`** :
- Nouveau fichier de tests (9 tests)
### Phase 2 : Enrichissement de l'API REST
4. **`pmocache/src/api.rs`** :
- Ajout de 3 nouvelles structures de données : `SetTtlRequest`, `PinResponse`, `PinStatus`
- Ajout de 5 nouveaux handlers d'API :
- `get_pin_status()` : Récupération du statut de pinning
- `pin_item()` : Épinglage d'un item
- `unpin_item()` : Désépinglage d'un item
- `set_item_ttl()` : Définition du TTL
- `clear_item_ttl()` : Suppression du TTL
5. **`pmocache/src/pmoserver_ext.rs`** :
- Ajout de 4 nouvelles routes dans `create_api_router()` :
- `GET /{pk}/pin` : Statut de pinning
- `POST /{pk}/pin` : Épingler
- `DELETE /{pk}/pin` : Désépingler
- `POST /{pk}/ttl` : Définir TTL
- `DELETE /{pk}/ttl` : Supprimer TTL
6. **`pmocache/src/openapi.rs`** :
- Mise à jour de la macro `create_cache_openapi!` pour inclure :
- Les 5 nouveaux endpoints dans la documentation
- Les 3 nouvelles structures dans les schémas OpenAPI
7. **`pmocache/src/lib.rs`** :
- Export des nouvelles structures publiques pour l'API
## API REST et Documentation OpenAPI
### Routes disponibles
Toutes les routes sont préfixées par `/api/{cache_name}/` (ex: `/api/covers/`, `/api/audio/`).
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/{pk}/pin` | Récupère le statut de pinning d'un item |
| `POST` | `/{pk}/pin` | Épingle un item (le protège de l'éviction LRU) |
| `DELETE` | `/{pk}/pin` | Désépingle un item |
| `POST` | `/{pk}/ttl` | Définit le TTL d'un item (expiration automatique) |
| `DELETE` | `/{pk}/ttl` | Supprime le TTL d'un item |
### Codes de statut HTTP
| Code | Signification | Cas d'usage |
|------|--------------|-------------|
| `200 OK` | Opération réussie | Tous les cas de succès |
| `400 BAD REQUEST` | Requête invalide | Format de date TTL invalide |
| `404 NOT FOUND` | Item non trouvé | PK inexistant dans le cache |
| `409 CONFLICT` | Conflit de règle métier | Tentative de pin avec TTL ou vice-versa |
| `500 INTERNAL SERVER ERROR` | Erreur serveur | Erreur de base de données |
### Documentation OpenAPI/Swagger
La documentation OpenAPI est automatiquement générée et inclut :
- **Schémas de données** :
- `PinStatus` : Statut de pinning (pinned, ttl_expires_at)
- `PinResponse` : Réponse d'opération de pinning
- `SetTtlRequest` : Requête de définition de TTL
- `CacheEntry` : Mis à jour avec les champs `pinned` et `ttl_expires_at`
- **Endpoints documentés** :
- Description détaillée de chaque route
- Exemples de requêtes et réponses
- Codes d'erreur possibles
- **Interface Swagger UI** :
- Accessible à `/swagger-ui/{cache_name}`
- Permet de tester l'API directement depuis le navigateur
### Gestion des erreurs
L'API suit une structure d'erreur cohérente :
```json
{
"error": "CODE_ERREUR",
"message": "Description lisible de l'erreur"
}
```
Les règles métier sont appliquées strictement :
- **409 CONFLICT** si tentative de pin avec TTL défini
- **409 CONFLICT** si tentative de set TTL sur item épinglé
- Messages d'erreur explicites guidant l'utilisateur
## Tests
- **Suite de tests dédiée** : 9 tests, tous passants
- **Tests existants** : Tous les tests de `test_cache.rs` passent toujours
- **Couverture** : Toutes les nouvelles fonctionnalités sont testées
- **Compilation** : Aucune erreur, tous les modules compilent correctement
## Résultat
**Implémentation complète et fonctionnelle** des items épinglables avec TTL
**Règle métier** TTL ↔ Pinned correctement implémentée
**Tests exhaustifs** validant tous les cas d'usage
**Compatibilité** avec les bases de données existantes
**Pas de régression** sur les tests existants
**API REST complète** avec 5 nouveaux endpoints
**Documentation OpenAPI** automatiquement générée
**Gestion d'erreurs cohérente** avec codes HTTP appropriés

View File

@@ -1,152 +0,0 @@
# Rapport : Suppression de la logique de débouncing SSE
**Date**: 2026-01-12
**Tâche**: WeabApp_debouncingSSE.md
## Objectif
Supprimer la logique de débouncing inutile sur le canal SSE de l'application web PMOControl, puisque le serveur contrôle déjà le flux des événements.
## Analyse préalable
J'ai identifié trois endroits avec des mécanismes de temporisation dans l'application web :
### 1. MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)
- **Débouncing**: 200ms après invalidation du cache
- **Cooldown**: 2 secondes entre les rechargements
- **Justification originale**: "dédupliquer les événements SSE dans le même batch (polling 500ms)"
- **Problème**: Cette logique est redondante puisque le serveur contrôle déjà le flux SSE
### 2. useRenderers.ts - Smart fetching (À CONSERVER ✓)
- **Mécanisme**: Comparaison des timestamps `lastEventAt` vs `lastSnapshotAt`
- **But**: Éviter de refetch un snapshot déjà à jour
- **Justification**: Ce n'est PAS du débouncing, c'est une optimisation intelligente qui évite des appels API inutiles
### 3. VolumeControl.vue - UI debouncing (À CONSERVER ✓)
- **Débouncing**: 300ms sur les changements de volume
- **But**: Réduire les appels API pendant que l'utilisateur fait glisser le curseur
- **Justification**: Débouncing légitime pour l'interface utilisateur
## Modifications effectuées
### Fichier modifié: `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
#### 1. Suppression des variables de débouncing (ligne ~27)
**Avant**:
```typescript
// Flags pour gérer le rechargement automatique avec debounce et cooldown
const isRefreshing = ref(false);
const refreshTimeoutId = ref<number | null>(null);
const lastRefreshTime = ref<number>(0);
const REFRESH_COOLDOWN_MS = 2000; // Ne pas recharger plus d'une fois toutes les 2 secondes
```
**Après**:
```typescript
// Flag pour gérer le rechargement automatique
const isRefreshing = ref(false);
```
#### 2. Simplification du watcher de cache (ligne ~53)
**Avant**:
```typescript
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations
// et un cooldown de 5 secondes pour éviter les rechargements successifs
watch(
() => browseData.value,
(data) => {
if (!data && props.containerId && !loading.value) {
// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes
const timeSinceLastRefresh = Date.now() - lastRefreshTime.value;
if (timeSinceLastRefresh < REFRESH_COOLDOWN_MS) {
console.log(
`[MediaBrowser] Cache invalidé mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`,
);
return;
}
// Annuler tout timeout en cours
if (refreshTimeoutId.value !== null) {
clearTimeout(refreshTimeoutId.value);
}
// Planifier le rechargement après 200ms
refreshTimeoutId.value = window.setTimeout(async () => {
if (!isRefreshing.value) {
console.log(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement après debounce...`,
);
isRefreshing.value = true;
await browseContainer(
props.serverId,
props.containerId,
false,
);
lastRefreshTime.value = Date.now();
isRefreshing.value = false;
refreshTimeoutId.value = null;
}
}, 200);
}
},
);
```
**Après**:
```typescript
// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)
// Cela se produit notamment quand on clique sur "Lire maintenant" sur une playlist,
// ce qui déclenche un événement ContainersUpdated qui invalide le cache
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
watch(
() => browseData.value,
async (data) => {
// Si browseData devient undefined alors que containerId est présent,
// et qu'on n'est pas déjà en train de charger, recharger immédiatement
if (!data && props.containerId && !loading.value && !isRefreshing.value) {
console.log(
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
);
isRefreshing.value = true;
await browseContainer(props.serverId, props.containerId, false);
isRefreshing.value = false;
}
},
);
```
## Résultats
### Changements de comportement
- **Avant**: Délai de 200ms + cooldown de 2s entre les rechargements de cache
- **Après**: Rechargement immédiat dès l'invalidation du cache
- **Impact**: Réactivité améliorée de l'interface, les mises à jour apparaissent immédiatement
### Réduction de complexité
- **3 variables supprimées**: `refreshTimeoutId`, `lastRefreshTime`, `REFRESH_COOLDOWN_MS`
- **Logique simplifiée**: De ~40 lignes à ~10 lignes dans le watcher
- **Code plus lisible**: Intention claire sans mécanismes de temporisation complexes
### Tests
- ✓ Le projet compile sans erreurs TypeScript
- ✓ Le flag `isRefreshing` empêche toujours les rechargements concurrents
- ✓ Les autres composants (useRenderers.ts, VolumeControl.vue) conservent leurs optimisations légitimes
## Conclusion
La suppression du débouncing et du cooldown dans MediaBrowser.vue simplifie le code tout en améliorant la réactivité de l'interface. Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client étaient redondants et ajoutaient une latence artificielle.
Le code est maintenant plus simple, plus réactif, et fait confiance au serveur pour contrôler la fréquence des événements SSE.
## Fichiers modifiés
- `pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue`
## Lignes de code
- **Supprimées**: ~35 lignes (logique de débouncing/cooldown)
- **Ajoutées**: ~5 lignes (logique simplifiée)
- **Net**: -30 lignes

View File

@@ -1,96 +0,0 @@
# Rapport : Documentation du pattern d'extension pmoconfig
## Objectif de la tâche
Créer une fiche descriptive documentant le pattern d'implémentation des traits d'extension de `pmoconfig::Config` en analysant les implémentations existantes dans les différents crates du projet.
## Travail réalisé
### 1. Analyse des fichiers source
Les fichiers suivants ont été analysés :
- `pmocovers/src/config_ext.rs` - Pattern cache avec conversion WebP
- `pmoaudiocache/src/config_ext.rs` - Pattern cache avec conversion FLAC
- `pmoqobuz/src/config_ext.rs` - Pattern authentification et rate limiting
- `pmocache/src/config_ext.rs` - Trait générique de cache et macro
- `pmoconfig/PASSWORD_ENCRYPTION.md` - Documentation du chiffrement
- `pmoupnp/src/config_ext.rs` - Pattern configuration UPnP
- `pmoparadise/src/config_ext.rs` - Pattern configuration minimale
### 2. Patterns identifiés
#### Pattern de base
Tous les traits d'extension suivent la même structure :
- Trait public avec méthodes getter/setter
- Implémentation pour `pmoconfig::Config`
- Utilisation de `get_value`/`set_value` génériques
- Constantes pour valeurs par défaut
#### Patterns spécialisés
- **Cache** : Utilisation de `CacheConfigExt` et factory methods
- **Authentification** : Getters combinés, helpers de validation, déchiffrement automatique
- **Rate limiting** : Configuration des limites avec valeurs par défaut
- **Configuration minimale** : Auto-persistence des valeurs par défaut
- **UPnP** : Configuration des identifiants devices
### 3. Structure de la documentation
La documentation créée couvre :
1. **Vue d'ensemble** : Objectif et principe du pattern
2. **Architecture** : Structure et flux de données
3. **Implémentation** : Guide détaillé avec patterns de code
4. **Patterns spécialisés** : Exemples pour chaque cas d'usage
5. **Bonnes pratiques** : Nommage, erreurs, documentation
6. **Exemples complets** : 3 implémentations complètes commentées
7. **Checklist** : Liste de vérification pour nouveaux traits
8. **Philosophie** : Principes directeurs et avantages
### 4. Contenu clé
#### Patterns de getters
- Getter simple avec valeur par défaut
- Getter avec auto-persistence
- Getter optionnel
- Getter avec déchiffrement
- Getter avec parsing et fallback
#### Patterns de setters
- Setter simple
- Setter avec transformation
- Setter multiple (transaction)
- Setter de nettoyage
#### Helpers
- Factory methods
- Getters combinés
- Helpers de validation
### 5. Hiérarchie de configuration YAML
Documentation des chemins standards :
- `host.*` : Configuration hôte/système
- `accounts.*` : Comptes et services
- `sources.*` : Sources de médias
## Résultat
Le document `Blackboard/Architecture/pmoconfig_ext.md` a été créé avec :
- 800+ lignes de documentation complète
- 3 exemples d'implémentation complète
- Patterns pour tous les cas d'usage identifiés
- Bonnes pratiques et anti-patterns
- Checklist d'implémentation
## Fichiers créés ou modifiés
- **Créé** : `Blackboard/Architecture/pmoconfig_ext.md` - Documentation complète du pattern
- **Créé** : `Blackboard/Report/config_ext.md` - Ce rapport
## Conformité avec Rules.md
- Documentation placée dans `Blackboard/Architecture/` comme demandé
- Rapport créé dans `Blackboard/Report/` avec le même nom de fichier
- Analyse focalisée sur l'objectif principal
- Documentation prête pour classification (Done/ToDiscuss) par l'humain

View File

@@ -1,227 +0,0 @@
# Rapport : Documentation d'implémentation d'une nouvelle MusicSource
## Objectif
Créer une documentation complète et pratique pour guider l'implémentation d'une nouvelle source musicale dans l'écosystème PMOMusic.
## Travail réalisé
### 1. Analyse des sources existantes
J'ai analysé deux implémentations de référence :
- **pmoparadise/src/source.rs** : Source dynamique avec FIFO (radio streaming)
- **pmoqobuz/src/source.rs** : Source catalogue avec playlists lazy
Ainsi que la documentation du trait :
- **pmosource/README.md** : Vue d'ensemble du trait MusicSource
- **pmosource/ARCHITECTURE.md** : Architecture et design decisions
### 2. Identification des patterns principaux
Deux patterns majeurs ont été identifiés :
#### Pattern 1 : Source dynamique FIFO (Radio Paradise)
**Caractéristiques :**
- Flux continu de tracks avec capacité limitée
- Suppression automatique des plus anciens
- Callbacks sur playlists pour détecter les changements
- Notification du ContentDirectory via notifier injecté
- Adaptation des IDs playlist → schema source
**Éléments clés :**
```rust
update_counter: Arc<RwLock<u32>>
last_change: Arc<RwLock<SystemTime>>
callback_tokens: Arc<Mutex<Vec<u64>>>
container_notifier: Option<Arc<dyn Fn(&[String]) + Send + Sync>>
```
#### Pattern 2 : Source catalogue lazy (Qobuz)
**Caractéristiques :**
- Catalogue vaste avec navigation hiérarchique
- Cache lazy pour audio, eager pour covers
- Playlists créées à la demande avec TTL
- LazyProvider pour télécharger l'audio à la lecture
- Métadonnées riches stockées dans le cache
**Éléments clés :**
```rust
SourceCacheManager centralisé
QobuzLazyProvider implémentant LazyProvider
Playlists avec rôle Album et TTL de 7 jours
Adaptation IDs avec metadata source_track_id
```
### 3. Structure du document créé
Le document `Blackboard/Architecture/music_source.md` contient :
#### Table des matières
1. Vue d'ensemble
2. Structure d'une MusicSource
3. Implémentation du trait MusicSource
4. Patterns d'implémentation
5. Intégration avec l'écosystème PMOMusic
6. Checklist de mise en œuvre
7. Exemples de référence
#### Sections détaillées
**Section 1 : Vue d'ensemble**
- Définition d'une MusicSource
- Types de sources (dynamique vs statique)
- Capacités du trait
**Section 2 : Structure**
- Organisation du code
- Dépendances recommandées
- Features Cargo
**Section 3 : Implémentation du trait**
- Informations de base (name, id, default_image)
- Navigation ContentDirectory (root_container, browse, resolve_uri)
- Support FIFO (append_track, remove_oldest, update_id)
- Support statique (get_items, search)
**Section 4 : Patterns**
- Pattern 1 : Source dynamique avec FIFO (code complet)
- Pattern 2 : Source catalogue avec playlists lazy (code complet)
- Pattern 3 : Adaptation des IDs entre playlist et source
**Section 5 : Intégration écosystème**
- pmoplaylist : création et gestion de playlists
- pmoaudiocache/pmocovers via SourceCacheManager
- pmodidl : conversion vers DIDL-Lite
- LazyProvider personnalisé
**Section 6 : Checklist**
- Phase 1 : Structure de base
- Phase 2 : Navigation ContentDirectory
- Phase 3 : Résolution d'URI
- Phase 4 : Support FIFO (si dynamique)
- Phase 5 : Support statique (si catalogue)
- Phase 6 : Intégration avancée
- Phase 7 : Tests et validation
**Section 7 : Exemples de référence**
- Radio Paradise (source dynamique FIFO)
- Qobuz (source catalogue lazy)
- Schemas d'Object ID détaillés
### 4. Points techniques importants documentés
#### Schema d'Object ID
Format recommandé hiérarchique :
```
<source-id>
<source-id>:albums
<source-id>:album:<album_id>
<source-id>:track:<track_id>
<source-id>:playlist:<playlist_id>
```
Exemples concrets de Radio Paradise et Qobuz fournis.
#### Adaptation des IDs
Code complet pour adapter les items de playlist au schema de la source :
- Extraction du cache_pk depuis l'URL
- Récupération du source_track_id depuis metadata
- Reconstruction de l'ID correct
- Normalisation des URLs (relatives → absolues)
- Ajout de champs requis (genre)
#### Cache lazy vs eager
Stratégie claire :
- **Covers** : Cache eager (petit, UI en a besoin immédiatement)
- **Audio** : Cache lazy (grand, téléchargé à la demande)
#### Thread Safety
Règles explicites :
- `Arc<RwLock<>>` pour état mutable partagé
- `tokio::sync::RwLock` pour async
- Éviter `Rc<>`, `RefCell` (non thread-safe)
- Implémenter `Clone` via `Arc<>`
#### Compatibilité UPnP
Points de vigilance :
- Genre obligatoire pour certains clients (gupnp-av-cp)
- URLs absolues uniquement
- Protocol Info correct pour FLAC
- Duration au format `H:MM:SS`
- childCount optionnel mais recommandé
### 5. Code d'exemple complet
Le document contient des exemples de code complets et fonctionnels pour :
1. **Structure de base** : définition de la struct et implémentation basique
2. **Navigation** : root_container et browse avec pattern matching
3. **Résolution URI** : avec fallback cache → original
4. **FIFO** : append_track, remove_oldest, callbacks
5. **Adaptation IDs** : fonction complète d'adaptation
6. **LazyProvider** : implémentation personnalisée
7. **Conversion DIDL** : traits ToDIDLContainer et ToDIDLItem
## Couverture des besoins
### Sources couvertes
- ✅ Radio Paradise : source dynamique FIFO
- ✅ Qobuz : source catalogue lazy
- ✅ Patterns génériques applicables à d'autres sources
### Cas d'usage couverts
- ✅ Source radio/streaming live
- ✅ Source catalogue de streaming (Spotify, Deezer, etc.)
- ✅ Source bibliothèque locale
- ✅ Source playlists fixes
- ✅ Source avec authentification (via client)
### Intégrations couvertes
- ✅ pmoplaylist (FIFO et persistant)
- ✅ pmoaudiocache (cache audio)
- ✅ pmocovers (cache covers)
- ✅ SourceCacheManager (centralisé)
- ✅ LazyProvider (téléchargement lazy)
- ✅ pmodidl (DIDL-Lite)
## Limitations et améliorations futures
### Limitations actuelles
1. **Search** : Pas d'exemple détaillé de search (optionnel dans le trait)
2. **Authentification** : Mentionné mais pas d'exemple complet
3. **Multi-format** : Pas d'exemple de source supportant plusieurs formats
4. **Offline** : Pas de pattern pour source offline/synchronisation
### Améliorations possibles
1. Ajouter un exemple complet de search avec filtres
2. Documenter l'intégration avec un système d'auth OAuth
3. Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)
4. Documenter la gestion offline avec synchronisation
## Fichiers créés
- `Blackboard/Architecture/music_source.md` : Documentation complète (15 sections, ~800 lignes)
## Conclusion
Le document créé fournit un guide complet et pratique pour implémenter une nouvelle MusicSource. Il combine :
- **Théorie** : Architecture, design patterns, principes
- **Pratique** : Code complet, exemples réels, checklist
- **Référence** : Schemas d'Object ID, intégrations, compatibilité
Un développeur peut suivre ce guide étape par étape pour créer une nouvelle source musicale compatible avec l'écosystème PMOMusic, en s'inspirant des patterns éprouvés de Radio Paradise et Qobuz.

View File

@@ -1,77 +0,0 @@
# Rapport : Documentation du pattern pmoserver_ext
## Contexte
Documentation du pattern d'extension du PMOServer à travers plusieurs itérations basées sur les retours utilisateur.
## Travail réalisé
### Analyse des fichiers sources
Les fichiers suivants ont été analysés pour extraire le pattern :
- `pmoapp/src/lib.rs` : Pattern SPA avec RustEmbed
- `pmocontrol/src/pmoserver_ext.rs` : API REST avec Control Point (1506+ lignes)
- `pmoparadise/src/pmoserver_ext.rs` : API REST simple avec client externe
- `pmoaudiocache/src/lib.rs` : Extension avec cache et fichiers
- `pmomediaserver/src/paradise_streaming.rs` : Extension complexe avec streaming
### Round 1 : Document initial
Premier jet documentant exhaustivement tous les aspects des extensions (~850 lignes).
### Round 2 : Recentrage sur le pattern
**Annotation** : "se recentrer sur le sujet principal"
**Actions** :
- Réduction de ~850 à ~400 lignes
- Suppression des digressions (OpenAPI détaillé, handlers spécifiques)
- Focus sur l'anatomie du pattern en 5 étapes
- Ajout d'une checklist et d'un exemple minimal
**Résultat** : Document focalisé sur l'implémentation du pattern uniquement.
### Round 3 : Réintégration OpenAPI
**Annotation** : "Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le."
**Actions** :
- Ajout d'une section complète "Documentation OpenAPI avec utoipa" (~260 lignes)
- 5 sous-sections détaillées :
1. Configuration de base (dépendances Cargo)
2. Définition des schémas avec `#[derive(ToSchema)]`
3. Annotation des handlers avec `#[utoipa::path]`
4. Création de la structure `#[derive(OpenApi)]`
5. Exemple complet extrait de Radio Paradise
- Mise à jour de la checklist avec section "Documentation OpenAPI"
- Ajout des dépendances `utoipa` et `serde` dans la section références
**Positionnement** : Section insérée après "Méthodes disponibles du serveur" et avant "Patterns courants", car elle fait partie intégrante de l'implémentation.
## Structure finale du document
1. **Vue d'ensemble** : Principe du pattern
2. **Anatomie d'une extension** : 5 étapes détaillées
3. **Méthodes disponibles du serveur** : API de `pmoserver::Server`
4. **Documentation OpenAPI avec utoipa** : Guide complet en 5 étapes ⭐ *Ajouté au Round 3*
5. **Patterns courants** : 3 exemples concrets
6. **Gestion des opérations longues** : spawn_blocking, timeouts, background tasks
7. **Checklist d'implémentation** : Organisée par catégories
8. **Exemple complet minimal** : Code fonctionnel
9. **Références** : Fichiers sources et dépendances
## Résultat final
Le document est maintenant :
- **Complet** : Couvre tous les aspects essentiels incluant OpenAPI
- **Structuré** : Progression logique de la configuration à l'implémentation
- **Pratique** : Exemples de code concrets extraits du codebase
- **Actionnable** : Checklist détaillée en 4 catégories
Taille finale : ~660 lignes (avec section OpenAPI complète)
## Fichiers modifiés
- `Blackboard/Architecture/pmoserver_ext.md` : Document complet avec OpenAPI (660 lignes)

View File

@@ -1,229 +0,0 @@
# Rapport : Rendre MusicRenderer complètement stateful
## Résumé
Refactorisation de l'architecture pour que chaque `MusicRenderer` gère son propre thread de surveillance (watcher), au lieu de déléguer le polling au `ControlPoint` centralisé. Cette modification améliore l'encapsulation, la cohérence des événements et prépare le terrain pour le support futur des notifications push (OpenHome, Chromecast).
## Travail effectué
### Phase 1 : Création du module watcher.rs
**Fichier créé** : `pmocontrol/src/music_renderer/watcher.rs`
Nouveau module contenant :
- `WatchStrategy` enum avec trois variantes :
- `Polling { interval_ms: u64 }` - pour UPnP, LinkPlay, Arylic (500ms)
- `Push` - pour support futur des notifications push
- `Hybrid { polling_interval_ms: u64 }` - pour OpenHome et Chromecast
- `WatchedState` struct pour le cache de détection des changements
- Fonctions helper déplacées depuis `control_point.rs` :
- `playback_state_equal()`
- `playback_position_equal()`
- `compute_logical_playback_state()`
- `extract_track_metadata()`
- `parse_hms_to_secs()`
- Tests unitaires pour les fonctions helper
### Phase 2 : Extension de MusicRenderer
**Fichier modifié** : `pmocontrol/src/music_renderer/musicrenderer.rs`
Nouveaux champs ajoutés à la struct `MusicRenderer` :
- `watched_state: Arc<Mutex<WatchedState>>` - cache pour détection des changements
- `watcher_stop_flag: Arc<AtomicBool>` - signal d'arrêt du thread
- `watcher_handle: Arc<Mutex<Option<JoinHandle<()>>>>` - handle du thread watcher
Nouvelles méthodes publiques :
- `start_watching()` - démarre le thread de surveillance (idempotent)
- `stop_watching()` - arrête le thread gracieusement (idempotent)
- `is_watching()` - retourne l'état du watcher
Nouvelles méthodes internes :
- `spawn_watcher_thread()` - crée le thread avec la stratégie appropriée
- `watcher_loop()` - boucle principale de polling
- `poll_and_emit_changes()` - poll le backend et émet les événements
- `handle_state_change()` - logique d'auto-advance (déplacée depuis ControlPoint)
- `emit_event()` - helper pour émettre un événement via le bus
### Phase 3 : Modification du Registry
**Fichier modifié** : `pmocontrol/src/registry.rs`
Ajout des appels `start_watching()` / `stop_watching()` :
- `push_renderer()` : appelle `start_watching()` quand un renderer arrive en ligne ou est créé
- `device_says_byebye()` : appelle `stop_watching()` avant de marquer offline
- `check_timeouts()` : appelle `stop_watching()` avant de marquer offline sur timeout
### Phase 4 : Simplification du ControlPoint
**Fichier modifié** : `pmocontrol/src/control_point.rs`
Suppressions :
- Thread de polling central (~140 lignes)
- Struct `RendererRuntimeSnapshot`
- Méthodes `emit_renderer_event()` et `handle_renderer_event()`
- Fonctions helper déplacées vers `watcher.rs`
### Phase 5 : Mise à jour du module
**Fichier modifié** : `pmocontrol/src/music_renderer/mod.rs`
Ajout de `pub mod watcher;` pour exposer le nouveau module.
## Liste des fichiers
### Fichiers créés
| Fichier | Description |
|---------|-------------|
| `pmocontrol/src/music_renderer/watcher.rs` | Module watcher avec WatchStrategy, WatchedState et fonctions helper |
### Fichiers modifiés
| Fichier | Modification |
|---------|--------------|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout champs watcher, méthodes start/stop_watching, logique auto-advance |
| `pmocontrol/src/music_renderer/mod.rs` | Ajout `pub mod watcher;` |
| `pmocontrol/src/registry.rs` | Appels start/stop_watching dans push_renderer, device_says_byebye, check_timeouts |
| `pmocontrol/src/control_point.rs` | Suppression polling central, RendererRuntimeSnapshot, handle_renderer_event, fonctions helper |
## Notes techniques
- Le signal d'arrêt utilise `AtomicBool` avec `Ordering::SeqCst` pour garantir la visibilité entre threads
- Les méthodes `start_watching()` et `stop_watching()` sont idempotentes
- Le thread watcher est nommé `watcher-{friendly_name}` pour faciliter le debug
- L'intervalle de polling est de 500ms (volume/mute toutes les 2 ticks = 1s)
- La logique `compute_logical_playback_state()` compense les bugs des devices Arylic/LinkPlay
- L'auto-advance est maintenant géré directement dans le watcher du MusicRenderer
## Round 2 : Vérification transition offline → online
### Problème identifié
La méthode `refresh_device_presence()` dans `registry.rs` n'appelait pas `start_watching()` quand un renderer passait de offline à online. Cette méthode est appelée lors de la réception de messages SSDP Alive.
### Correction appliquée
**Fichier modifié** : `pmocontrol/src/registry.rs`
Ajout de l'appel `renderer.start_watching()` dans `refresh_device_presence()` quand `was_online == false`.
### Points de démarrage du watcher vérifiés
| Méthode | Situation | `start_watching()` appelé |
|---------|-----------|---------------------------|
| `push_renderer()` | Nouveau renderer | Oui |
| `push_renderer()` | Renderer existant, était offline | Oui |
| `refresh_device_presence()` | Renderer existant, était offline | Oui (corrigé) |
### Points d'arrêt du watcher vérifiés
| Méthode | Situation | `stop_watching()` appelé |
|---------|-----------|--------------------------|
| `device_says_byebye()` | SSDP ByeBye reçu | Oui |
| `check_timeouts()` | Timeout dépassé | Oui |
## Round 3 : Audit complet de la logique offline/online
Suite à la découverte du manque dans le Round 2, un audit complet de tous les chemins offline/online a été effectué.
### Chemins qui appellent `start_watching()`
| Chemin | Fonction | Ligne | Condition | Status |
|--------|----------|-------|-----------|--------|
| Nouveau renderer découvert | `push_renderer()` | 180, 194 | Création nouvelle entry | ✅ OK |
| Renderer existant, ajout renderer à entry | `push_renderer()` | 169 | Entry existe sans renderer | ✅ OK |
| Renderer existant revient online | `push_renderer()` | 160 | `!was_online` | ✅ OK |
| SSDP Alive pour device connu | `refresh_device_presence()` | 269 | `!was_online` | ✅ OK (corrigé Round 2) |
### Chemins qui appellent `stop_watching()`
| Chemin | Fonction | Ligne | Condition | Status |
|--------|----------|-------|-----------|--------|
| SSDP ByeBye reçu | `device_says_byebye()` | 289 | Renderer présent | ✅ OK |
| Timeout dépassé | `check_timeouts()` | 308 | `elapsed > max_age` | ✅ OK |
### Analyse des flux
```
┌─────────────────────────────────────────────────────────────────┐
│ FLUX ONLINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SSDP Discovery ──► push_renderer() ──► start_watching() ✅ │
│ │
│ SSDP Alive (nouveau UDN) ──► push_renderer() ──► start_watching() ✅ │
│ │
│ SSDP Alive (UDN connu, online) ──► refresh_device_presence() │
│ (pas de start car déjà en marche) │
│ │
│ SSDP Alive (UDN connu, offline) ──► refresh_device_presence() │
│ ──► start_watching() ✅ │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ FLUX OFFLINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ SSDP ByeBye ──► device_says_byebye() ──► stop_watching() ✅ │
│ │
│ Timeout ──► check_timeouts() ──► stop_watching() ✅ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Conclusion
**Tous les chemins sont correctement gérés.** Chaque transition offline→online appelle `start_watching()` et chaque transition online→offline appelle `stop_watching()`.
L'idempotence des méthodes `start_watching()` et `stop_watching()` garantit qu'aucun problème ne survient en cas d'appels multiples.
## Round 4 : Centralisation de la gestion du watcher
### Problème identifié
Les appels à `start_watching()` et `stop_watching()` étaient dispersés dans `registry.rs` (6 emplacements), augmentant le risque d'oubli (comme découvert en Round 2).
### Solution implémentée
Centralisation de la gestion du watcher dans `MusicRenderer` lui-même :
1. **Constructeur** (`from_renderer_info_with_bus()`) : appelle automatiquement `start_watching()` à la fin, car le renderer est créé avec `online = true`
2. **`has_been_seen_now()`** : appelle automatiquement `start_watching()` si transition offline→online
3. **`mark_as_offline()`** : appelle automatiquement `stop_watching()` avant de passer offline
### Fichiers modifiés
| Fichier | Modification |
|---------|--------------|
| `pmocontrol/src/music_renderer/musicrenderer.rs` | Ajout `start_watching()` dans constructeur, dans `has_been_seen_now()` et `stop_watching()` dans `mark_as_offline()` |
| `pmocontrol/src/registry.rs` | Suppression de tous les appels manuels à `start_watching()` et `stop_watching()` |
### Avantages
- **Encapsulation** : la logique watcher est entièrement gérée par `MusicRenderer`
- **Impossible d'oublier** : les transitions sont automatiquement gérées
- **Code simplifié** : `registry.rs` ne contient plus de logique watcher
- **Idempotence** : les appels multiples sont sans effet grâce aux guards existants
### Nouvelle architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ GESTION AUTOMATIQUE DU WATCHER │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Création MusicRenderer ──► constructeur ──► start_watching() │
│ │
│ has_been_seen_now() ──► si !was_online ──► start_watching() │
│ │
│ mark_as_offline() ──► stop_watching() ──► online = false │
│ │
└─────────────────────────────────────────────────────────────────┘
```
## Compilation
Le projet compile sans erreur.

View File

@@ -1,177 +0,0 @@
# Règles de développement PMOMusic
## Contexte projet
**PMOMusic** : Système audio HiFi basé sur UPnP/DLNA (LossLess/Bit-Perfect uniquement).
**Technologies** :
- Backend : Rust
- Frontend : Vue.js (TypeScript/JavaScript)
**Composants** : Media Server, Control Point, Media Renderer
**Développement** : Collaboration humain-LLM (Claude/ChatGPT/Ollama)
---
## Règles Rust (Cargo workspace)
### Gestion des dépendances
**⚠️ OBLIGATOIRE** : Les dépendances doivent être ajoutées au niveau **workspace** autant que possible.
1. Ajouter la dépendance dans `Cargo.toml` racine (section `[workspace.dependencies]`)
2. Référencer avec `{ workspace = true }` dans le `Cargo.toml` de la crate
**Exemple** :
```toml
# Cargo.toml (racine workspace)
[workspace.dependencies]
rand = "0.9"
# pmocontrol/Cargo.toml
[dependencies]
rand = { workspace = true }
```
**Exceptions** : Dépendances spécifiques à une seule crate avec version très particulière.
---
## Prérequis des tâches
### Spécification des crates cibles
**⚠️ CRITIQUE** : Le LLM doit **REFUSER** d'exécuter une tâche si la ou les crates concernées ne sont pas explicitement spécifiées dans le fichier `Todo/{nom}.md`.
**Informations requises** :
- Nom de la ou des crates à modifier
- Chemin relatif si nécessaire (ex: `pmocontrol/src/...`)
**En cas d'absence** :
- Le LLM demande clarification à l'humain
- Ne pas deviner ou supposer les crates concernées
---
## Workflow Blackboard
### Structure
```
Blackboard/
├── ToThinkAbout/ # Réflexion, idées, architecture
├── Architecture/ # Documentation d'architecture validée
├── Todo/ # Tâches à réaliser
├── Report/ # Rapports de tâches réalisées
├── ToDiscuss/ # Tâches incomplètes nécessitant discussion
├── Done/ # Tâches terminées (synthèses)
└── Rules.md # Ce fichier
```
### Cycle de vie d'une tâche
```mermaid
flowchart LR
THINK[ToThinkAbout] -->|Spécification| TODO[Todo]
TODO -->|Implémentation| REPORT[Report]
REPORT -->|Humain décide| DONE[Done]
REPORT -->|Humain décide| DISCUSS[ToDiscuss]
DISCUSS -->|Reprise travail| REPORT
```
### Règles strictes
#### 1. Phase de réflexion (ToThinkAbout)
- **Collaboration** : Humain et LLM peuvent modifier
- **But** : Explorer idées, définir architecture
- **Sortie** : Documents de spécification → `Todo/`
#### 2. Phase de réalisation (Todo → Report)
- **Input** : Fichier `Todo/{nom}.md`
- **Action** : LLM implémente la tâche
- **Output** : Fichier `Report/{nom}.md` (même nom obligatoire)
- **Contenu du rapport** :
- Résumé du travail effectué
- Liste des fichiers créés/modifiés
- **INTERDIT** : Rapport détaillé dans la discussion (uniquement dans `Report/`)
#### 3. Décision humaine (Report → Done ou ToDiscuss)
**⚠️ CRITIQUE** : Seul l'humain décide de la destination. Le LLM ne doit JAMAIS déplacer ou classer une tâche.
**Cas 1 : Tâche complète**`Done/`
- Humain déplace `Todo/{nom}.md``Done/{nom}.md`
- LLM crée une synthèse (tâche originale + rapport)
- Contenu final dans `Done/{nom}.md`
**Cas 2 : Tâche incomplète**`ToDiscuss/`
- Humain déplace `Todo/{nom}.md``ToDiscuss/{nom}.md`
- Humain ajoute annotations/remarques dans `ToDiscuss/{nom}.md`
- Lors de la reprise :
- LLM lit les annotations
- Complète `Report/{nom}.md` avec les modifications
- Nouveau cycle de validation
#### 4. Documentation architecture (Architecture/)
- Contient les documents d'architecture validés et stables
- Référence pour patterns de code (ex: `pmoconfig_ext.md`, `pmoserver_ext.md`)
- Ne pas modifier sans validation explicite
---
## Versioning (Jujutsu)
**Système** : [Jujutsu (jj)](https://github.com/jj-vcs/jj)
**Repository** : `https://gargoton.petite-maison-orange.fr/eric/pmomusic.git`
### Commandes Makefile
| Commande | Action | Description |
|----------|--------|-------------|
| `make jjnew` | Nouveau commit | Documente le commit actuel (`jj auto-describe`) puis `jj new` |
| `make jjpush` | Push vers Git | Documente le commit puis `jj git push --change @`<br/>→ Crée branche + PR sur le serveur |
| `make jjfetch` | Récupération | `jj git fetch` puis `jj new main@origin`<br/>→ Après validation du PR |
### Gestion version
- **Source de vérité** : `PMOMusic/Cargo.toml`
- **Sync** : `version.txt` (généré par Makefile)
- **Incrémentation** : `make bump-version` (avant `jjpush`)
---
## Checklist LLM
### Avant de commencer une tâche
- [ ] Lire `Todo/{nom}.md`
- [ ] Vérifier les références à `Architecture/` si mentionnées
- [ ] Comprendre les contraintes (HiFi, LossLess, UPnP/DLNA)
### Pendant la réalisation
- [ ] Suivre les patterns d'architecture existants
- [ ] Utiliser Rust (backend) ou Vue.js/TypeScript (frontend)
- [ ] Tester le code si applicable
### Après la réalisation
- [ ] Créer `Report/{nom}.md` (même nom que la tâche)
- [ ] Lister fichiers créés/modifiés
- [ ] **NE PAS** déplacer la tâche
- [ ] **NE PAS** écrire de rapport détaillé dans la discussion
- [ ] Attendre la décision humaine
### Si tâche en ToDiscuss
- [ ] Lire annotations ajoutées par l'humain
- [ ] Expliquer dans `Report/{nom}.md` comment les remarques sont prises en compte
- [ ] Reprise du cycle de validation
---
## Diagrammes Mermaid
Tous les diagrammes d'architecture doivent utiliser Mermaid. La commande `make blackboard-html` génère une version HTML consultable avec rendu des diagrammes.
**Syntaxe stricte** :
- Labels de subgraph : `subgraph Name[Label]` (pas de guillemets doubles)
- Balises HTML : `Node["Text<br/>Multi"]` (guillemets doubles)
- Formes spéciales : `DB[("database")]`, `Decision{"Question?"}` (guillemets)

View File

@@ -1,8 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
La crâte PMOcache, implémente un system de cache qui pourrait être étendu pour permettre une utilisation plus large. L'idée est de modifier les règles de déletion des items. Actuellement le cache a une capacité maximale. Et les items ont des TTL, qui peuvent être non définies. Lorsque le cash est plein, les plus vieux items en termes d'utilisation ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de rajouter une fonctionnalité qui permet d'épingler certains items pour les rendre non destructibles. Ils pourraient aussi sortir du comptage général des items pour savoir si le cache est plein.
Il faudra modifier la structure de la base de données. Ajouter une colonne indiquant cette propriété. Mettre une règle métier en disant qu'on ne peut pas être à la fois épinglés et avec un TTL.
On se moque de maintenir la compatibilité avec la base de données actuelle, il n'y a pas à prévoir de phase de transition. Nous sommes en période de développement.

View File

@@ -1,21 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
Partir des fichiers suivants:
- pmoapp/src/lib.rs
- pmocontrol/src/pmoserver_ext.rs
- pmoparadise/src/pmoserver_ext.rs
- pmoaudiocache/src/lib.rs
- pmomediaserver/src/paradise_streaming.rs
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension du PMO serveur.
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoserver_ext.md`
## Round 2
J'ai regardé ton document généré et je trouve que tu t'élargis du sujet central documenter lecture d'une extension PMOserver. Peux-tu te recentrer sur le sujet principal.
## Round 3
Je trouve que le fait de devoir déclarer et documenter les URL dans OpenAPI / utopia était quelque chose d'important. Remets le.

View File

@@ -1,515 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
# MusicBoxSource : Bibliothèque musicale universelle
Créer une **"boîte à musique"** personnelle : un catalogue unifié de morceaux provenant de n'importe quelle source (Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de tags et playlists intelligentes.
---
## 🎯 Vision
### Concept
**MusicBoxSource** est une bibliothèque musicale curatoriale qui permet de :
- **Collecter** : Ajouter des morceaux depuis n'importe quelle source PMOMusic ou URL
- **Organiser** : Classifier avec une taxonomie de tags extensible
- **Requêter** : Créer des playlists statiques et smart playlists (requêtes dynamiques)
- **Exposer** : Servir via UPnP/DIDL-Lite avec navigation multi-axes
### Différence avec `pmoplaylist`
- **`pmoplaylist`** : Playlists FIFO **éphémères** pour sources live (Radio Paradise)
- **`pmomusicbox`** : Bibliothèque **persistante** cross-sources avec métadonnées enrichies
---
## 🏛️ Architecture globale
```mermaid
flowchart TB
subgraph Sources[Sources PMOMusic]
QOBUZ[pmoqobuz]
PARADISE[pmoparadise]
LOCAL[pmolocal - à créer]
URL[URLs directes]
end
subgraph Import[Import Layer]
IMPORTER[MusicBox Importer]
JSPF[pmojspf - Parser playlists]
META[pmometadata - Extraction]
end
subgraph Core[pmomusicbox Core]
DB[(SQLite Database)]
TAXONOMY[Taxonomie Tags]
QUERY[Smart Query Engine]
end
subgraph Cache[Cache Layer]
AUDIO[pmoaudiocache]
COVERS[pmocovers]
end
subgraph Export[Export UPnP]
SOURCE[MusicSource Trait]
DIDL[DIDL-Lite Generator]
BROWSE[Multi-Axis Browser]
end
Sources --> IMPORTER
URL --> IMPORTER
JSPF --> IMPORTER
META --> IMPORTER
IMPORTER --> DB
DB --> TAXONOMY
DB --> QUERY
DB <--> AUDIO
DB <--> COVERS
DB --> SOURCE
TAXONOMY --> BROWSE
QUERY --> BROWSE
SOURCE --> DIDL
BROWSE --> DIDL
```
---
## 🗄️ Modèle de données (SQLite)
### Tables principales
```mermaid
erDiagram
TAG_CATEGORIES ||--o{ TAGS : contient
TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent
TAGS ||--o{ ITEM_TAGS : associe
MUSIC_ITEMS ||--o{ ITEM_TAGS : a
MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans
PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient
TAG_CATEGORIES {
text id PK "Ex: mood, genre"
text name "Nom affiché"
text parent_id FK "Hiérarchie"
text color "Hex color"
text icon "Emoji/icon"
int display_order
}
TAGS {
text id PK "Ex: mood:energetic"
text category_id FK
text name "energetic, chill"
text description
text color "Override"
}
MUSIC_ITEMS {
text id PK "UUID"
text source_type "qobuz, url, local"
text source_id "ID source"
text original_uri "URI source"
text cache_audio_pk FK "pmoaudiocache"
text cache_cover_pk FK "pmocovers"
text title
text artist
text album
int year
int rating "1-5 étoiles"
int play_count
}
ITEM_TAGS {
text item_id PK,FK
text tag_id PK,FK
int added_at
text source "user, auto"
}
PLAYLISTS {
text id PK
text name
bool is_smart
text smart_query "JSON"
}
PLAYLIST_ITEMS {
text playlist_id PK,FK
text item_id FK
int position PK
}
```
### Tables d'association
- **`item_tags`** : Liens items ↔ tags (N:M)
- **`playlist_items`** : Items dans playlists statiques (position, ordre)
- **`tag_synonyms`** : Synonymes pour recherche (ex: "jazz" → "swing")
### Index & Recherche
- **Indexes B-tree** : artist, album, genre, year, rating, play_count
- **FTS5 (Full-Text Search)** : title, artist, album, comment
- **Triggers** : Maintien des tables FTS en sync avec `music_items`
---
## 🎨 Taxonomie par défaut
Catégories préchargées à l'initialisation :
| Catégorie | Description | Exemples de tags |
|-------------|----------------------------------|--------------------------------------------|
| **Mood** | État d'esprit, émotion | energetic, chill, melancholic, happy |
| **Genre** | Style musical | rock, jazz, classical, electronic, metal |
| **Era** | Période, décennie | 60s, 70s, 80s, 90s, contemporary |
| **Occasion**| Contexte d'écoute | workout, focus, party, driving, sleep |
| **Tempo** | Vitesse | slow, medium, fast |
| **Instrument** | Instrument dominant | piano, guitar, vocal, synthesizer |
| **Quality** | Qualité audio | lossless, high-res, remastered, live |
| **Origin** | Origine géographique | usa, uk, france, japan, latin, africa |
**Extensibilité** : L'utilisateur peut créer ses propres catégories et tags.
---
## 📦 Crates architecture
### 1. **`pmojspf`** - Parser de playlists (utilitaire)
**But** : Parser/écrire différents formats de playlists vers/depuis un format pivot JSPF (JSON).
```
pmojspf/
├── model.rs # Structures JSPF (Playlist, Track, Meta)
├── reader/
│ ├── jspf.rs # JSON natif
│ ├── xspf.rs # XML (via quick-xml ou crate xspf)
│ ├── m3u.rs # M3U/M3U8 (parsing ligne par ligne)
│ └── pls.rs # PLS (format INI-like)
└── writer.rs # Export JSPF
```
**Dépendances** : `serde`, `serde_json`, `quick-xml` (ou `xspf` crate)
**Usage** : Réutilisé par `pmomusicbox` pour import/export
---
### 2. **`pmomusicbox`** - Bibliothèque musicale core
**Responsabilités** :
- Gestion base SQLite (CRUD items, tags, playlists)
- Import depuis sources PMO (Qobuz, Paradise, Local, URLs)
- Smart playlists (query builder + exécution SQL)
- Implémentation `MusicSource` trait (exposition UPnP)
- Intégration caches audio/covers
```
pmomusicbox/
├── db/
│ ├── schema.rs # DDL SQLite + migrations
│ ├── items.rs # CRUD music_items
│ ├── tags.rs # CRUD tags + taxonomie
│ ├── playlists.rs # CRUD playlists statiques
│ ├── smart.rs # Smart playlists
│ └── search.rs # Full-text search (FTS5)
├── import/
│ ├── url.rs # Import URL directe
│ ├── source.rs # Import depuis MusicSource
│ ├── local.rs # Import fichiers locaux (via pmometadata)
│ └── playlist.rs # Import JSPF/M3U8 (via pmojspf)
├── export/
│ └── playlist.rs # Export playlists (JSPF, M3U8)
├── query/
│ ├── builder.rs # SmartPlaylistQuery (DSL)
│ └── executor.rs # Génération + exécution SQL
├── didl/
│ └── generator.rs # Conversion items → DIDL-Lite
├── source.rs # Impl MusicSource trait
├── taxonomy.rs # Taxonomie par défaut + CRUD
└── config_ext.rs # Extension pmoconfig
```
**Dépendances** :
- `pmosource`, `pmoaudiocache`, `pmocovers`, `pmodidl`, `pmometadata`
- `pmojspf` (import/export playlists)
- `rusqlite` (features: `bundled`, `serde_json`)
- `uuid`, `serde`, `tokio`, `async-trait`
---
### 3. **`pmolocal`** - Source fichiers locaux (à créer)
**But** : Scanner des répertoires locaux et exposer les fichiers audio via `MusicSource`.
```
pmolocal/
├── scanner.rs # Scan récursif de répertoires
├── watcher.rs # Hot reload (notify)
├── source.rs # Impl MusicSource
└── config_ext.rs # Extension pmoconfig
```
**Workflow** :
1. `pmolocal` scanne `/home/user/Music`
2. `pmomusicbox` importe les items découverts
3. Tags automatiques basés sur métadonnées (genre, année)
---
## 🔄 Flux d'import
### Import depuis une source PMO (ex: Qobuz)
```mermaid
sequenceDiagram
participant QS as Qobuz Source
participant MB as MusicBox Importer
participant DB as SQLite DB
participant AC as pmoaudiocache
participant CC as pmocovers
QS->>MB: get_item(object_id)
MB->>QS: resolve_uri(object_id)
Note over MB: 1. Extraire métadonnées DIDL-Lite<br/>2. Générer UUID
MB->>DB: INSERT INTO music_items
opt Auto-cache activé
MB->>AC: Cache audio
MB->>CC: Cache cover
AC-->>DB: Retourner cache_audio_pk
CC-->>DB: Retourner cache_cover_pk
end
MB-->>QS: item_id (UUID)
```
### Import URL directe
```mermaid
flowchart LR
URL[URL simple] --> META["pmometadata<br/>Extraction"]
META --> UUID[Générer UUID]
UUID --> DB[("music_items")]
DB --> CACHE{"Auto-cache?"}
CACHE -->|Oui| AC[pmoaudiocache]
CACHE -->|Non| END[Fin]
AC --> END
```
### Import playlist JSPF/M3U8
```mermaid
flowchart LR
FILE[Fichier playlist] --> JSPF["pmojspf<br/>Parser"]
JSPF --> STRUCT[Structure JSPF]
STRUCT --> LOOP{"Pour chaque track"}
LOOP --> IMPORT[Import comme URL]
IMPORT --> DB[("music_items")]
DB --> PLAYLIST[Créer playlist statique]
PLAYLIST --> LINK[Lier tracks à playlist]
```
---
## 🔍 Smart Playlists (Query DSL)
### Concept
Les smart playlists sont des **requêtes sauvegardées** qui génèrent dynamiquement une liste de tracks.
### Structure de requête (JSON)
```json
{
"include_all_tags": ["mood:energetic", "genre:rock"],
"exclude_tags": ["mood:melancholic"],
"year_min": 1980,
"year_max": 1989,
"min_rating": 4,
"lossless_only": true,
"order_by": "play_count",
"order": "desc",
"limit": 50
}
```
### Traduction SQL
```sql
SELECT * FROM music_items
WHERE id IN (
SELECT item_id FROM item_tags WHERE tag_id IN ('mood:energetic', 'genre:rock')
GROUP BY item_id HAVING COUNT(DISTINCT tag_id) = 2 -- ALL tags
)
AND id NOT IN (
SELECT item_id FROM item_tags WHERE tag_id = 'mood:melancholic'
)
AND year BETWEEN 1980 AND 1989
AND rating >= 4
AND codec IN ('flac', 'alac')
ORDER BY play_count DESC
LIMIT 50;
```
---
## 🎭 Exposition UPnP (MusicSource)
### Structure de navigation
```mermaid
graph TB
ROOT[musicbox/] --> ARTIST[by-artist/]
ROOT --> ALBUM[by-album/]
ROOT --> GENRE[by-genre/]
ROOT --> TAG[by-tag/]
ROOT --> PLAYLISTS[playlists/]
ROOT --> SMART[smart-playlists/]
ROOT --> FAV[favorites/]
ROOT --> RECENT[recent/]
ARTIST --> PF[Pink Floyd/]
ARTIST --> Q[Queen/]
PF --> WALL[The Wall/]
PF --> WYWH[Wish You Were Here/]
WALL --> ITEM1[Another Brick... 🎵]
TAG --> MOOD[mood/]
TAG --> OCC[occasion/]
TAG --> ERA[era/]
MOOD --> ENRG[energetic/]
MOOD --> CHILL[chill/]
ENRG --> ITEMS1[items taggués 🎵]
OCC --> WORK[workout/]
OCC --> FOCUS[focus/]
ERA --> E80[80s/]
ERA --> E90[90s/]
PLAYLISTS --> PL1[My Favorites/]
PLAYLISTS --> PL2[Summer 2024/]
SMART --> SP1[80s Rock Workout/]
SMART --> SP2[Jazz Dinner/]
style ITEM1 fill:#e1f5ff
style ITEMS1 fill:#e1f5ff
```
### Object IDs
```
musicbox:by-artist:{artist_name}
musicbox:by-album:{album_id}
musicbox:by-tag:{category}:{tag_name}
musicbox:playlist:{playlist_id}
musicbox:smart:{smart_playlist_id}
musicbox:item:{item_id}
```
---
## 🔌 Intégration avec l'écosystème PMOMusic
### Avec pmoaudiocache
- Import → Déclencher cache automatique (si `auto_cache: true`)
- `resolve_uri()` → Retourner URI cachée si disponible
### Avec pmocovers
- Import → Télécharger cover art
- Browse → Inclure `album_art` dans DIDL-Lite
### Avec pmoserver (feature `server`)
- API REST pour manipulation (CRUD items, tags, playlists)
- SSE pour notifications de changements
- Endpoints OpenAPI (utoipa)
---
## 📝 Plan d'implémentation (Phases)
### Phase 1 : Fondations
- Schéma SQLite complet
- Crate `pmojspf` (parser playlists)
- CRUD basique dans `pmomusicbox` (items, tags)
- Taxonomie par défaut
- Import URL simple
- Extension pmoconfig
### Phase 2 : Import cross-sources
- Import depuis MusicSource (Qobuz, Paradise)
- Import playlists (JSPF/M3U8)
- Intégration caches (audio, covers)
- Crate `pmolocal` (fichiers locaux)
### Phase 3 : Smart Playlists
- Query builder (DSL)
- Exécuteur SQL
- CRUD smart playlists
- Export JSPF
### Phase 4 : MusicSource UPnP
- Implémentation trait `MusicSource`
- Génération DIDL-Lite
- Browse multi-axes (artist, album, tag)
- Recherche full-text (FTS5)
### Phase 5 : Fonctionnalités avancées
- Statistiques d'écoute (play_count, last_played)
- Auto-tagging (genre depuis métadonnées)
- API REST (feature `server`)
- Recommandations (items similaires)
---
## 🎯 Cas d'usage
### Workflow typique
1. **Découverte** : Écouter Radio Paradise, tomber sur un morceau génial
2. **Ajout** : `musicbox.import_from_source(&paradise, "track-123")`
3. **Organisation** : Ajouter tags `mood:chill`, `occasion:focus`
4. **Playlist** : Smart playlist "Focus Music" avec requête `mood:chill + occasion:focus`
5. **Écoute** : Naviguer dans UPnP → `musicbox/smart-playlists/Focus Music/`
### Scénario : Bibliothèque mixte
- Albums Qobuz haute résolution
- Playlists M3U8 importées depuis iTunes
- Fichiers FLAC locaux scannés
- URLs de SoundCloud
- Tracks Radio Paradise capturés
**Tout unifié dans MusicBox, accessible via UPnP, organisé par tags.**
---
## 📚 Références
### Standards
- [JSPF Spec](https://www.xspf.org/jspf)
- [XSPF Spec](https://www.xspf.org/spec)
- [SQLite FTS5](https://www.sqlite.org/fts5.html)
### Inspirations
- [Beets](https://beets.io/) - Music library manager
- [Navidrome](https://www.navidrome.org/) - Music server
- [MusicBrainz Picard](https://picard.musicbrainz.org/) - Tagger

View File

@@ -1,570 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
# PlaylistSource : MusicSource pour playlists
Implémenter une source PMOMusic capable de servir un catalogue de playlists hiérarchisé via UPnP.
---
## 📋 Décisions de conception
### Format pivot : JSPF (JSON)
**Choix** : JSPF comme format interne central
- Métadonnées riches (title, creator, album, annotation, image, duration, etc.)
- JSON natif avec serde (Rust-friendly)
- Standard ouvert (Xiph.Org)
- Extensible via champ `meta`
**Formats supportés** :
-**JSPF** (.jspf) - JSON, format natif
-**XSPF** (.xspf) - XML, conversion vers JSPF
-**M3U8** (.m3u8) - Texte, métadonnées limitées
-**PLS** (.pls) - INI-like, très basique
**Architecture** : 1 Writer (JSPF) + 4 Readers (JSPF, XSPF, M3U8, PLS) → Structure JSPF centrale
```mermaid
flowchart LR
JSPF[JSPF JSON] --> JR[JspfReader]
XSPF[XSPF XML] --> XR[XspfReader]
M3U8[M3U8 Text] --> MR[M3uReader]
PLS[PLS INI] --> PR[PlsReader]
JR --> CORE[JSPF Structure]
XR --> CORE
MR --> CORE
PR --> CORE
CORE --> W[JspfWriter]
W --> OUT[.jspf]
```
---
## 🗂️ Structure du répertoire
```
playlists/
├── metadata.json # Métadonnées du conteneur racine
├── Jazz/
│ ├── metadata.json # Métadonnées catégorie Jazz
│ ├── standards.jspf
│ ├── bebop.jspf
│ └── covers/
│ └── standards.webp
├── Classical/
│ ├── metadata.json
│ ├── baroque.jspf
│ └── romantic.jspf
└── Rock/
├── metadata.json
└── 70s.jspf
```
### Fichier `metadata.json` (conteneur)
```json
{
"container": {
"title": "Collection Jazz",
"description": "Mes playlists jazz favorites",
"creator": "John Doe",
"image": "covers/jazz-collection.webp",
"date": "2026-01-15",
"meta": [
{"rel": "genre", "content": "Jazz"},
{"rel": "mood", "content": "Relaxing"}
]
}
}
```
---
## 🏗️ Composants à implémenter
### 1. Crate `pmojspf` (parsing playlists)
**Responsabilité** : Parser différents formats de playlist vers structure JSPF unifiée
#### Structure
```
pmojspf/
├── Cargo.toml
├── src/
│ ├── lib.rs # API publique
│ ├── model.rs # Structures JSPF
│ ├── writer.rs # JspfWriter
│ ├── reader/
│ │ ├── mod.rs # Trait PlaylistReader
│ │ ├── jspf.rs # Reader JSON natif (serde_json)
│ │ ├── xspf.rs # Reader XML (xml-rs)
│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne)
│ │ └── pls.rs # Reader PLS (format INI-like)
│ └── error.rs
└── tests/
└── fixtures/
```
#### Modèle de données
**Inspiré de la crate [xspf](https://crates.io/crates/xspf) v0.4.2**
```rust
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Jspf {
pub playlist: JspfPlaylist,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JspfPlaylist {
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub identifier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub attribution: Vec<JspfAttribution>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub meta: Vec<JspfMeta>,
#[serde(default)]
pub track: Vec<JspfTrack>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct JspfTrack {
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub location: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub identifier: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub creator: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub annotation: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub info: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub album: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub track_num: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration: Option<u64>, // millisecondes
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub meta: Vec<JspfMeta>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JspfAttribution {
Location { location: String },
Identifier { identifier: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JspfMeta {
pub rel: String,
pub content: String,
}
```
#### Trait PlaylistReader
```rust
use std::io::Read;
pub trait PlaylistReader {
fn read<R: Read>(reader: R) -> Result<Jspf>;
fn from_str(s: &str) -> Result<Jspf>;
fn from_file<P: AsRef<Path>>(path: P) -> Result<Jspf>;
}
```
#### Implémentations des Readers
##### JspfReader (✅ Simple - serde_json)
```rust
pub struct JspfReader;
impl PlaylistReader for JspfReader {
fn read<R: Read>(reader: R) -> Result<Jspf> {
serde_json::from_reader(reader)
.map_err(|e| Error::ParseError(format!("JSON: {}", e)))
}
}
```
**Dépendances** : `serde_json`
##### XspfReader (⚠️ Complexe - xml-rs)
**Approche** : Machine à états XML pour parser `<playlist>`, `<track>`, etc.
**Alternative** : Utiliser la crate `xspf` existante puis convertir → JSPF
```rust
pub struct XspfReader;
impl PlaylistReader for XspfReader {
fn read<R: Read>(reader: R) -> Result<Jspf> {
// Parser XML avec EventReader
// État : in_playlist, in_track, current_element
// Mapping: <title> → playlist.title, <track> → JspfTrack
}
}
```
**Dépendances** : `xml-rs` ou réutiliser `xspf` crate
##### M3uReader (⚙️ Modéré - ligne par ligne)
**Format** :
```m3u
#EXTM3U
#PLAYLIST:Ma Playlist Jazz
#EXTINF:284,John Coltrane - Giant Steps
#EXTART:John Coltrane
#EXTALB:Giant Steps
file:///music/coltrane.flac
```
```rust
pub struct M3uReader;
impl PlaylistReader for M3uReader {
fn read<R: Read>(reader: R) -> Result<Jspf> {
// BufReader ligne par ligne
// Parser #EXTINF:duration,artist - title
// Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)
}
}
```
**Dépendances** : stdlib uniquement
**Limitations** : Métadonnées pauvres, beaucoup de champs `None`
##### PlsReader (⚙️ Modéré - format INI)
**Format** :
```ini
[playlist]
NumberOfEntries=2
File1=file:///music/coltrane.flac
Title1=John Coltrane - Giant Steps
Length1=284
```
```rust
pub struct PlsReader;
impl PlaylistReader for PlsReader {
fn read<R: Read>(reader: R) -> Result<Jspf> {
// HashMap<index, (file, title, duration)>
// Parser FileN=..., TitleN=..., LengthN=...
// Trier par index et convertir en JspfTrack
}
}
```
**Dépendances** : stdlib uniquement
**Limitations** : File, Title, Length seulement
#### JspfWriter
```rust
pub struct JspfWriter;
impl JspfWriter {
pub fn write<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
pub fn write_pretty<W: Write>(jspf: &Jspf, writer: W) -> Result<()>;
pub fn to_string(jspf: &Jspf) -> Result<String>;
pub fn to_string_pretty(jspf: &Jspf) -> Result<String>;
}
```
#### API publique
```rust
pub use model::{Jspf, JspfPlaylist, JspfTrack, JspfMeta, JspfAttribution};
pub use reader::{PlaylistReader, JspfReader, XspfReader, M3uReader, PlsReader};
pub use writer::JspfWriter;
pub enum PlaylistFormat {
Jspf,
Xspf,
M3u8,
Pls,
}
impl PlaylistFormat {
pub fn from_extension(ext: &str) -> Option<Self>;
}
pub fn read_playlist<R: Read>(reader: R, format: PlaylistFormat) -> Result<Jspf>;
```
---
### 2. Crate `pmoplaylists` (PlaylistSource)
**Responsabilité** : Implémenter `MusicSource` pour servir playlists via UPnP
#### Structures principales
```rust
pub struct PlaylistSource {
root_path: PathBuf,
playlists: Arc<RwLock<HashMap<String, ParsedPlaylist>>>,
containers: Arc<RwLock<HashMap<PathBuf, ContainerMetadata>>>,
watcher: Option<notify::RecommendedWatcher>,
base_url: String,
update_counter: Arc<RwLock<u32>>,
last_change: Arc<RwLock<SystemTime>>,
}
pub struct ParsedPlaylist {
pub metadata: PlaylistMetadata,
pub tracks: Vec<PlaylistTrack>,
pub source_path: PathBuf,
pub format: PlaylistFormat,
}
pub struct ContainerMetadata {
pub title: Option<String>,
pub description: Option<String>,
pub creator: Option<String>,
pub image: Option<String>,
pub date: Option<String>,
pub meta: Vec<MetaEntry>,
}
pub struct ContainerMetadataFile {
pub container: ContainerMetadata,
}
```
#### Fonctionnalités
1. **Scan hiérarchique** : Parser récursivement dossiers + `metadata.json` + playlists
2. **Cache** : Éviter re-parsing (playlists + conteneurs)
3. **Hot reload** : `notify` pour détecter changements
4. **Browse UPnP** : Générer DIDL-Lite avec métadonnées conteneurs
5. **Content resolution** : Résoudre URIs via `SourceCacheManager`
6. **Cover art** : Servir images playlists, tracks, conteneurs
#### Object IDs
```
playlists # Racine
playlists:category:{path} # Catégorie (dossier)
playlists:playlist:{id} # Playlist
playlists:playlist:{id}:track:{index} # Track dans playlist
```
#### Gestion `metadata.json`
```rust
fn load_container_metadata(&self, dir_path: &Path) -> Result<ContainerMetadata> {
let metadata_path = dir_path.join("metadata.json");
if metadata_path.exists() {
let content = fs::read_to_string(&metadata_path)?;
let file: ContainerMetadataFile = serde_json::from_str(&content)?;
Ok(file.container)
} else {
// Fallback : nom du répertoire
Ok(ContainerMetadata {
title: Some(dir_path.file_name()?.to_str()?.to_string()),
..Default::default()
})
}
}
```
---
### 3. Extension pmoconfig
**Fichier** : `pmoplaylists/src/config_ext.rs`
**Pattern** : [pmoconfig_ext.md](../Architecture/pmoconfig_ext.md)
```rust
use pmoconfig::Config;
use std::path::{Path, PathBuf};
const DEFAULT_PLAYLISTS_DIR: &str = "playlists";
pub trait PlaylistSourceConfigExt {
fn get_playlists_dir(&self) -> PathBuf;
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()>;
fn get_playlists_enabled(&self) -> bool;
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()>;
fn get_playlists_supported_formats(&self) -> Vec<String>;
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()>;
}
impl PlaylistSourceConfigExt for Config {
fn get_playlists_dir(&self) -> PathBuf {
self.get_managed_dir("sources.playlists.directory", DEFAULT_PLAYLISTS_DIR)
.expect("Failed to get playlists directory")
}
fn set_playlists_dir<P: AsRef<Path>>(&self, path: P) -> anyhow::Result<()> {
self.set_managed_dir("sources.playlists.directory", path)
}
fn get_playlists_enabled(&self) -> bool {
self.get_value("sources.playlists.enabled")
.unwrap_or_else(|_| {
let _ = self.set_value("sources.playlists.enabled", true);
true
})
}
fn set_playlists_enabled(&self, enabled: bool) -> anyhow::Result<()> {
self.set_value("sources.playlists.enabled", enabled)
}
fn get_playlists_supported_formats(&self) -> Vec<String> {
self.get_value("sources.playlists.formats")
.unwrap_or_else(|_| {
let default = vec!["jspf".into(), "xspf".into(), "m3u8".into(), "pls".into()];
let _ = self.set_value("sources.playlists.formats", &default);
default
})
}
fn set_playlists_supported_formats(&self, formats: Vec<String>) -> anyhow::Result<()> {
self.set_value("sources.playlists.formats", formats)
}
}
```
**Config YAML** :
```yaml
sources:
playlists:
enabled: true
directory: "playlists"
formats:
- jspf
- xspf
- m3u8
- pls
```
**Utilisation** :
```rust
use pmoconfig::Config;
use pmoplaylists::config_ext::PlaylistSourceConfigExt;
let config = Config::load()?;
if config.get_playlists_enabled() {
let playlists_dir = config.get_playlists_dir();
let playlist_source = PlaylistSource::new(playlists_dir, config.clone())?;
}
```
---
## 🔌 Intégration MusicBrainz (optionnelle - Phase 2)
### Crate recommandée : `musicbrainz_rs`
[musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) v0.5+
- Client async/blocking
- Rate limiting automatique (1 req/sec)
- Support CoverArt Archive
- MSRV: Rust 1.71.1
### Cas d'usage
1. **Résolution d'identifiants** :
```json
{"identifier": ["musicbrainz://recording/abc123"], "title": null}
```
→ Récupérer métadonnées depuis MusicBrainz
2. **Enrichissement playlists pauvres** : M3U8/PLS → MusicBrainz → métadonnées complètes
3. **Cover art** : CoverArt Archive
### Configuration
```yaml
sources:
playlists:
musicbrainz:
enabled: false
enrich_metadata: false
rate_limit_per_sec: 1
```
**Stratégie** :
- **Phase 1 (MVP)** : Ne pas implémenter, stocker identifiants tel quel
- **Phase 2** : Dépendance optionnelle, service asynchrone, configurable
---
## 📝 Prochaines étapes
1. ✅ Choix format : JSPF central
2. ✅ Modèle données : Structures JSPF
3. ✅ Extension pmoconfig : Trait défini
4. ⏳ **Implémenter `pmojspf`** :
- `JspfReader` (serde_json)
- `XspfReader` (xml-rs ou crate xspf)
- `M3uReader` (parsing ligne par ligne)
- `PlsReader` (format INI)
- `JspfWriter` (serde_json)
5. ⏳ **Implémenter `pmoplaylists`** :
- `PlaylistSource` (trait `MusicSource`)
- Scan hiérarchique + cache
- Hot reload (notify)
- Browse UPnP (DIDL-Lite)
- Gestion `metadata.json`
6. ⏳ Tests avec clients UPnP
---
## 📚 Sources
### Spécifications
- [XSPF Spec](https://www.xspf.org/spec)
- [JSPF Spec](https://www.xspf.org/jspf)
- [M3U - Wikipedia](https://en.wikipedia.org/wiki/M3U)
- [PLS - Wikipedia](https://en.wikipedia.org/wiki/PLS_(file_format))
### Crates Rust
- [xspf](https://crates.io/crates/xspf) - Parser XML XSPF
- [musicbrainz_rs](https://crates.io/crates/musicbrainz_rs) - API MusicBrainz
- [MusicBrainz API Docs](https://musicbrainz.org/doc/MusicBrainz_API)

View File

@@ -1,17 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
Partir des fichiers suivants:
- pmocovers/src/config_ext.rs
- pmoaudiocache/src/config_ext.rs
- pmoqobuz/src/config_ext.rs
- pmocache/src/config_ext.rs
- pmoconfig/PASSWORD_ENCRYPTION.md
- pmoupnp/src/config_ext.rs
- pmoparadise/src/config_ext.rs
réalise une fiche descriptive sur le pattern à réaliser pour implémenter un trait d'extension de PMOConfig (pmoconfig::Config).
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/pmoconfig_ext.md`
Reste bien focalisé sur l'objectif principal.

View File

@@ -1,14 +0,0 @@
**Il faut suivre les instructions générales placées dans le fichier : Blackboard/Rules.md**
Partir des fichiers suivants:
- pmoparadise/src/source.rs
- pmoqobuz/src/source.rs
- pmosource/README.md
- pmosource/ARCHITECTURE.md
D'écrire dans un fichier d'architecture L'implémentation d'une nouvelle MusicSource.
Le résultat sera une documentation d'implémentation qui sera placé dans le fichier: `Blackboard/Architecture/music_source.md`
Reste bien focalisé sur l'objectif principal.

View File

@@ -1,934 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>music_source</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="guide-dimplémentation-dune-nouvelle-musicsource">Guide
dimplémentation dune nouvelle MusicSource</h1>
<p>Ce document décrit comment implémenter une nouvelle source musicale
dans lécosystème PMOMusic en suivant le trait <code>MusicSource</code>
défini dans le crate <code>pmosource</code>.</p>
<h2 id="table-des-matières">Table des matières</h2>
<ol type="1">
<li><a href="#vue-densemble">Vue densemble</a></li>
<li><a href="#structure-dune-musicsource">Structure dune
MusicSource</a></li>
<li><a href="#implémentation-du-trait-musicsource">Implémentation du
trait MusicSource</a></li>
<li><a href="#patterns-dimplémentation">Patterns
dimplémentation</a></li>
<li><a href="#intégration-avec-lécosystème-pmomusic">Intégration avec
lécosystème PMOMusic</a></li>
<li><a href="#checklist-de-mise-en-œuvre">Checklist de mise en
œuvre</a></li>
<li><a href="#exemples-de-référence">Exemples de référence</a></li>
</ol>
<h2 id="vue-densemble">Vue densemble</h2>
<p>Une <code>MusicSource</code> est une abstraction qui représente une
source de contenu musical dans PMOMusic. Elle peut être :</p>
<ul>
<li><strong>Dynamique (FIFO)</strong> : Radio Paradise, streaming radio,
playlists live</li>
<li><strong>Statique</strong> : Albums Qobuz, bibliothèque locale,
playlists fixes</li>
</ul>
<p>Le trait <code>MusicSource</code> définit une interface unifiée pour
: - La navigation UPnP ContentDirectory (browse) - La résolution dURI
audio (avec cache) - La gestion de playlists FIFO (pour les sources
dynamiques) - Le suivi des changements (update_id, last_change)</p>
<h2 id="structure-dune-musicsource">Structure dune MusicSource</h2>
<h3 id="organisation-du-code">Organisation du code</h3>
<pre><code>pmo&lt;votre-source&gt;/
├── src/
│ ├── lib.rs # Exports publics
│ ├── source.rs # Implémentation MusicSource
│ ├── client.rs # Client API (optionnel)
│ ├── models.rs # Structures de données
│ ├── config.rs # Configuration
│ └── didl.rs # Conversion DIDL-Lite (optionnel)
├── assets/
│ └── default.webp # Logo 300x300px
├── Cargo.toml
└── README.md</pre>
<h3 id="dépendances-principales">Dépendances principales</h3>
<div class="sourceCode" id="cb2"><pre
class="sourceCode toml"><code class="sourceCode toml"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="dt">pmosource</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmosource&quot;</span><span class="op"> }</span></span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a><span class="dt">pmodidl</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmodidl&quot;</span><span class="op"> }</span></span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoplaylist</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmoplaylist&quot;</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si FIFO</span></span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoaudiocache</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmoaudiocache&quot;</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si cache</span></span>
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="dt">pmocovers</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmocovers&quot;</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span> <span class="co"># Si cache</span></span>
<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a><span class="dt">async-trait</span> <span class="op">=</span> <span class="st">&quot;0.1&quot;</span></span>
<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a><span class="dt">tokio</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;1&quot;</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">&quot;sync&quot;</span><span class="op">] }</span></span>
<span id="cb2-10"><a href="#cb2-10" aria-hidden="true" tabindex="-1"></a><span class="dt">serde</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;1&quot;</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">&quot;derive&quot;</span><span class="op">] }</span></span>
<span id="cb2-11"><a href="#cb2-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb2-12"><a href="#cb2-12" aria-hidden="true" tabindex="-1"></a><span class="kw">[features]</span></span>
<span id="cb2-13"><a href="#cb2-13" aria-hidden="true" tabindex="-1"></a><span class="dt">default</span> <span class="op">=</span> <span class="op">[</span><span class="st">&quot;cache&quot;</span><span class="op">]</span></span>
<span id="cb2-14"><a href="#cb2-14" aria-hidden="true" tabindex="-1"></a><span class="dt">cache</span> <span class="op">=</span> <span class="op">[</span><span class="st">&quot;pmoaudiocache&quot;</span><span class="op">,</span> <span class="st">&quot;pmocovers&quot;</span><span class="op">]</span></span>
<span id="cb2-15"><a href="#cb2-15" aria-hidden="true" tabindex="-1"></a><span class="dt">playlist</span> <span class="op">=</span> <span class="op">[</span><span class="st">&quot;pmoplaylist&quot;</span><span class="op">]</span></span></pre></div>
<h2 id="implémentation-du-trait-musicsource">Implémentation du trait
MusicSource</h2>
<h3 id="informations-de-base">1. Informations de base</h3>
<p>Chaque source doit fournir :</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmosource::</span><span class="op">{</span>async_trait<span class="op">,</span> MusicSource<span class="op">};</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="op">,</span> <span class="bu">Debug</span><span class="at">)]</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> MyMusicSource <span class="op">{</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Champs internes</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> MyMusicSource <span class="op">{</span></span>
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> name(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="op">&amp;</span><span class="dt">str</span> <span class="op">{</span></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;Ma Source Musicale&quot;</span> <span class="co">// Nom affiché dans l&#39;UI</span></span>
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> id(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="op">&amp;</span><span class="dt">str</span> <span class="op">{</span></span>
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;my-music-source&quot;</span> <span class="co">// ID unique (format: lowercase-kebab-case)</span></span>
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> default_image(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="op">&amp;</span>[<span class="dt">u8</span>] <span class="op">{</span></span>
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Logo WebP 300x300px inclus dans le binaire</span></span>
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="pp">include_bytes!</span>(<span class="st">&quot;../assets/default.webp&quot;</span>)</span>
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> default_image_mime_type(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="op">&amp;</span><span class="dt">str</span> <span class="op">{</span></span>
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;image/webp&quot;</span> <span class="co">// Toujours WebP</span></span>
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Règles :</strong> - <code>id()</code> doit être unique parmi
toutes les sources - <code>id()</code> doit être en lowercase-kebab-case
- <code>default_image()</code> doit être un WebP 300x300px</p>
<h3 id="navigation-contentdirectory">2. Navigation ContentDirectory</h3>
<h4 id="container-racine">2.1 Container racine</h4>
<div class="sourceCode" id="cb4"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> root_container(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Container<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Container <span class="op">{</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>id()<span class="op">.</span>to_string()<span class="op">,</span> <span class="co">// &quot;my-music-source&quot;</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> <span class="st">&quot;0&quot;</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="co">// Toujours &quot;0&quot; pour la racine</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;1&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> child_count<span class="op">:</span> <span class="cn">None</span><span class="op">,</span> <span class="co">// Optionnel</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> searchable<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;1&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>name()<span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">&quot;object.container&quot;</span><span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="cn">None</span><span class="op">,</span></span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="cn">None</span><span class="op">,</span></span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="browse">2.2 Browse</h4>
<p>La méthode <code>browse()</code> est le cœur de la navigation :</p>
<div class="sourceCode" id="cb5"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> browse(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>BrowseResult<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> <span class="kw">self</span><span class="op">.</span>parse_object_id(object_id) <span class="op">{</span></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Root <span class="op">=&gt;</span> <span class="op">{</span></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner les sous-containers principaux</span></span>
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> containers <span class="op">=</span> <span class="pp">vec!</span>[</span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_albums_container()<span class="op">,</span></span>
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_playlists_container()<span class="op">,</span></span>
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>build_favorites_container()<span class="op">,</span></span>
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a> ]<span class="op">;</span></span>
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Containers(containers))</span>
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Album <span class="op">{</span> album_id <span class="op">}</span> <span class="op">=&gt;</span> <span class="op">{</span></span>
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner le container + ses tracks</span></span>
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> album_container <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>build_album_container(<span class="op">&amp;</span>album_id)<span class="op">;</span></span>
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> tracks <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_album_tracks(<span class="op">&amp;</span>album_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Mixed <span class="op">{</span></span>
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[album_container]<span class="op">,</span></span>
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> tracks<span class="op">,</span></span>
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb5-21"><a href="#cb5-21" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb5-22"><a href="#cb5-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-23"><a href="#cb5-23" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Track <span class="op">{</span> track_id <span class="op">}</span> <span class="op">=&gt;</span> <span class="op">{</span></span>
<span id="cb5-24"><a href="#cb5-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner les détails d&#39;un track</span></span>
<span id="cb5-25"><a href="#cb5-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> track <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_track_item(<span class="op">&amp;</span>track_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb5-26"><a href="#cb5-26" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">BrowseResult::</span>Items(<span class="pp">vec!</span>[track]))</span>
<span id="cb5-27"><a href="#cb5-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb5-28"><a href="#cb5-28" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-29"><a href="#cb5-29" aria-hidden="true" tabindex="-1"></a> _ <span class="op">=&gt;</span> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>ObjectNotFound(</span>
<span id="cb5-30"><a href="#cb5-30" aria-hidden="true" tabindex="-1"></a> <span class="pp">format!</span>(<span class="st">&quot;Unknown object: {}&quot;</span><span class="op">,</span> object_id)</span>
<span id="cb5-31"><a href="#cb5-31" aria-hidden="true" tabindex="-1"></a> ))</span>
<span id="cb5-32"><a href="#cb5-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb5-33"><a href="#cb5-33" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Schema dObject ID recommandé :</strong></p>
<pre><code>&lt;source-id&gt; # Racine
&lt;source-id&gt;:albums # Container albums
&lt;source-id&gt;:album:&lt;album_id&gt; # Album spécifique
&lt;source-id&gt;:track:&lt;track_id&gt; # Track spécifique
&lt;source-id&gt;:playlist:&lt;playlist_id&gt; # Playlist spécifique</pre>
<p><strong>Types de BrowseResult :</strong> -
<code>Containers(Vec&lt;Container&gt;)</code> : Liste de containers
(navigation) - <code>Items(Vec&lt;Item&gt;)</code> : Liste de tracks
(lecture) - <code>Mixed { containers, items }</code> : Les deux (album
avec tracks)</p>
<h4 id="résolution-duri">2.3 Résolution dURI</h4>
<div class="sourceCode" id="cb7"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> resolve_uri(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a> <span class="co">// Étape 1 : Vérifier le cache audio</span></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(cached_pk) <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_cached_audio_pk(object_id)<span class="op">.</span><span class="kw">await</span> <span class="op">{</span></span>
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="cn">Ok</span>(<span class="pp">format!</span>(<span class="st">&quot;{}/audio/flac/{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> cached_pk))<span class="op">;</span></span>
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Étape 2 : Retourner l&#39;URI originale</span></span>
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> <span class="kw">self</span><span class="op">.</span>parse_object_id(object_id) <span class="op">{</span></span>
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a> <span class="pp">ObjectIdType::</span>Track <span class="op">{</span> track_id <span class="op">}</span> <span class="op">=&gt;</span> <span class="op">{</span></span>
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> stream_url <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>get_stream_url(<span class="op">&amp;</span>track_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(stream_url)</span>
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a> _ <span class="op">=&gt;</span> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>UriResolutionError(</span>
<span id="cb7-14"><a href="#cb7-14" aria-hidden="true" tabindex="-1"></a> <span class="pp">format!</span>(<span class="st">&quot;Cannot resolve URI for: {}&quot;</span><span class="op">,</span> object_id)</span>
<span id="cb7-15"><a href="#cb7-15" aria-hidden="true" tabindex="-1"></a> ))</span>
<span id="cb7-16"><a href="#cb7-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb7-17"><a href="#cb7-17" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Ordre de résolution :</strong> 1. Cache audio local (si
disponible) 2. URI originale (API streaming, fichier local, etc.)</p>
<h3 id="support-fifo-sources-dynamiques">3. Support FIFO (sources
dynamiques)</h3>
<p>Si votre source est dynamique (radio, streaming live) :</p>
<div class="sourceCode" id="cb8"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylist::</span>PlaylistManager<span class="op">;</span></span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">tokio::sync::</span>RwLock<span class="op">;</span></span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioSource <span class="op">{</span></span>
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> playlist_id<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;&gt;,</span></span>
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>SystemTime<span class="op">&gt;&gt;,</span></span>
<span id="cb8-10"><a href="#cb8-10" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb8-11"><a href="#cb8-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-12"><a href="#cb8-12" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb8-13"><a href="#cb8-13" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> RadioSource <span class="op">{</span></span>
<span id="cb8-14"><a href="#cb8-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> supports_fifo(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">bool</span> <span class="op">{</span></span>
<span id="cb8-15"><a href="#cb8-15" aria-hidden="true" tabindex="-1"></a> <span class="cn">true</span> <span class="co">// Cette source utilise une FIFO</span></span>
<span id="cb8-16"><a href="#cb8-16" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-17"><a href="#cb8-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-18"><a href="#cb8-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> append_track(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> Item) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb8-19"><a href="#cb8-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer le gestionnaire de playlist</span></span>
<span id="cb8-20"><a href="#cb8-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb8-21"><a href="#cb8-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> writer <span class="op">=</span> manager</span>
<span id="cb8-22"><a href="#cb8-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_persistent_write_handle(<span class="kw">self</span><span class="op">.</span>playlist_id<span class="op">.</span>clone())</span>
<span id="cb8-23"><a href="#cb8-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-24"><a href="#cb8-24" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-25"><a href="#cb8-25" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-26"><a href="#cb8-26" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire le PK depuis l&#39;URI du track</span></span>
<span id="cb8-27"><a href="#cb8-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> pk <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>extract_pk_from_item(<span class="op">&amp;</span>track)<span class="op">?;</span></span>
<span id="cb8-28"><a href="#cb8-28" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-29"><a href="#cb8-29" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter à la playlist</span></span>
<span id="cb8-30"><a href="#cb8-30" aria-hidden="true" tabindex="-1"></a> writer</span>
<span id="cb8-31"><a href="#cb8-31" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>push_lazy(pk)</span>
<span id="cb8-32"><a href="#cb8-32" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-33"><a href="#cb8-33" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-34"><a href="#cb8-34" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-35"><a href="#cb8-35" aria-hidden="true" tabindex="-1"></a> <span class="co">// Incrémenter update_id</span></span>
<span id="cb8-36"><a href="#cb8-36" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb8-37"><a href="#cb8-37" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-38"><a href="#cb8-38" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(())</span>
<span id="cb8-39"><a href="#cb8-39" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-40"><a href="#cb8-40" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-41"><a href="#cb8-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> remove_oldest(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Option</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb8-42"><a href="#cb8-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb8-43"><a href="#cb8-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> manager</span>
<span id="cb8-44"><a href="#cb8-44" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_read_handle(<span class="op">&amp;</span><span class="kw">self</span><span class="op">.</span>playlist_id)</span>
<span id="cb8-45"><a href="#cb8-45" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-46"><a href="#cb8-46" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-47"><a href="#cb8-47" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-48"><a href="#cb8-48" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer le plus ancien</span></span>
<span id="cb8-49"><a href="#cb8-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(<span class="dv">1</span>)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-50"><a href="#cb8-50" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-51"><a href="#cb8-51" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-52"><a href="#cb8-52" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(item) <span class="op">=</span> items<span class="op">.</span>first() <span class="op">{</span></span>
<span id="cb8-53"><a href="#cb8-53" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adapter l&#39;item au schéma de la source</span></span>
<span id="cb8-54"><a href="#cb8-54" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> adapted <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>adapt_item_to_schema(item<span class="op">.</span>clone())<span class="op">;</span></span>
<span id="cb8-55"><a href="#cb8-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb8-56"><a href="#cb8-56" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">Some</span>(adapted))</span>
<span id="cb8-57"><a href="#cb8-57" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
<span id="cb8-58"><a href="#cb8-58" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">None</span>)</span>
<span id="cb8-59"><a href="#cb8-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-60"><a href="#cb8-60" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-61"><a href="#cb8-61" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-62"><a href="#cb8-62" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> update_id(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">u32</span> <span class="op">{</span></span>
<span id="cb8-63"><a href="#cb8-63" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span><span class="kw">self</span><span class="op">.</span>update_counter<span class="op">.</span>read()<span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-64"><a href="#cb8-64" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-65"><a href="#cb8-65" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-66"><a href="#cb8-66" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> last_change(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Option</span><span class="op">&lt;</span>SystemTime<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb8-67"><a href="#cb8-67" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(<span class="op">*</span><span class="kw">self</span><span class="op">.</span>last_change<span class="op">.</span>read()<span class="op">.</span><span class="kw">await</span>)</span>
<span id="cb8-68"><a href="#cb8-68" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-69"><a href="#cb8-69" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-70"><a href="#cb8-70" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_items(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> offset<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span> count<span class="op">:</span> <span class="dt">usize</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb8-71"><a href="#cb8-71" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb8-72"><a href="#cb8-72" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> manager</span>
<span id="cb8-73"><a href="#cb8-73" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_read_handle(<span class="op">&amp;</span><span class="kw">self</span><span class="op">.</span>playlist_id)</span>
<span id="cb8-74"><a href="#cb8-74" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-75"><a href="#cb8-75" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-76"><a href="#cb8-76" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-77"><a href="#cb8-77" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer les items</span></span>
<span id="cb8-78"><a href="#cb8-78" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader</span>
<span id="cb8-79"><a href="#cb8-79" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>to_items(count)</span>
<span id="cb8-80"><a href="#cb8-80" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb8-81"><a href="#cb8-81" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">MusicSourceError::</span>PlaylistError(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb8-82"><a href="#cb8-82" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-83"><a href="#cb8-83" aria-hidden="true" tabindex="-1"></a> <span class="co">// Adapter au schéma de la source</span></span>
<span id="cb8-84"><a href="#cb8-84" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> adapted <span class="op">=</span> items<span class="op">.</span>into_iter()</span>
<span id="cb8-85"><a href="#cb8-85" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>item<span class="op">|</span> <span class="kw">self</span><span class="op">.</span>adapt_item_to_schema(item))</span>
<span id="cb8-86"><a href="#cb8-86" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>collect()<span class="op">;</span></span>
<span id="cb8-87"><a href="#cb8-87" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-88"><a href="#cb8-88" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(adapted)</span>
<span id="cb8-89"><a href="#cb8-89" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-90"><a href="#cb8-90" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb8-91"><a href="#cb8-91" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-92"><a href="#cb8-92" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioSource <span class="op">{</span></span>
<span id="cb8-93"><a href="#cb8-93" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> bump_update_counter(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">{</span></span>
<span id="cb8-94"><a href="#cb8-94" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> counter <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>update_counter<span class="op">.</span>write()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb8-95"><a href="#cb8-95" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>counter <span class="op">=</span> counter<span class="op">.</span>wrapping_add(<span class="dv">1</span>)<span class="op">.</span>max(<span class="dv">1</span>)<span class="op">;</span></span>
<span id="cb8-96"><a href="#cb8-96" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> last <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>last_change<span class="op">.</span>write()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb8-97"><a href="#cb8-97" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>last <span class="op">=</span> <span class="pp">SystemTime::</span>now()<span class="op">;</span></span>
<span id="cb8-98"><a href="#cb8-98" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-99"><a href="#cb8-99" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Points clés :</strong> - Utiliser
<code>pmoplaylist::PlaylistManager</code> singleton - Incrémenter
<code>update_id</code> à chaque modification - Mettre à jour
<code>last_change</code> à chaque modification - Adapter les IDs des
items au schéma de la source</p>
<h3 id="support-statique-albums-bibliothèques">4. Support statique
(albums, bibliothèques)</h3>
<p>Si votre source est statique (catalogue, albums) :</p>
<div class="sourceCode" id="cb9"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> MusicSource <span class="cf">for</span> CatalogSource <span class="op">{</span></span>
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> supports_fifo(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">bool</span> <span class="op">{</span></span>
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a> <span class="cn">false</span> <span class="co">// Pas de FIFO</span></span>
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> append_track(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> _track<span class="op">:</span> Item) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="cn">Err</span>(<span class="pp">MusicSourceError::</span>NotSupported(</span>
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;This source is read-only&quot;</span><span class="op">.</span>to_string()</span>
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> ))</span>
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> remove_oldest(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Option</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="cn">None</span>) <span class="co">// Pas de suppression</span></span>
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> update_id(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">u32</span> <span class="op">{</span></span>
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a> <span class="dv">0</span> <span class="co">// Jamais de changement</span></span>
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> last_change(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Option</span><span class="op">&lt;</span>SystemTime<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span> <span class="co">// Pas de suivi des changements</span></span>
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-25"><a href="#cb9-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_items(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> offset<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span> count<span class="op">:</span> <span class="dt">usize</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb9-26"><a href="#cb9-26" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner une liste paginée depuis le catalogue</span></span>
<span id="cb9-27"><a href="#cb9-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_catalog_items(offset<span class="op">,</span> count)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb9-28"><a href="#cb9-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb9-29"><a href="#cb9-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="patterns-dimplémentation">Patterns dimplémentation</h2>
<h3 id="pattern-1-source-dynamique-avec-fifo-radio-paradise">Pattern 1 :
Source dynamique avec FIFO (Radio Paradise)</h3>
<p><strong>Caractéristiques :</strong> - Flux continu de tracks -
Capacité limitée (50-100 tracks) - Suppression automatique des plus
anciens - <code>supports_fifo() = true</code></p>
<p><strong>Structure :</strong></p>
<div class="sourceCode" id="cb10"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioParadiseSource <span class="op">{</span></span>
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;&gt;,</span></span>
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>SystemTime<span class="op">&gt;&gt;,</span></span>
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> callback_tokens<span class="op">:</span> Arc<span class="op">&lt;</span><span class="pp">std::sync::</span>Mutex<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">u64</span><span class="op">&gt;&gt;&gt;,</span></span>
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> container_notifier<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&amp;</span>[<span class="dt">String</span>]) <span class="op">+</span> <span class="bu">Send</span> <span class="op">+</span> <span class="bu">Sync</span><span class="op">&gt;&gt;,</span></span>
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioParadiseSource <span class="op">{</span></span>
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer des callbacks sur les playlists pour notifier les changements</span></span>
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> attach_playlist_callbacks(<span class="kw">self</span><span class="op">:</span> <span class="op">&amp;</span>Arc<span class="op">&lt;</span><span class="dt">Self</span><span class="op">&gt;</span>) <span class="op">{</span></span>
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_ids <span class="op">=</span> <span class="pp">vec!</span>[</span>
<span id="cb10-14"><a href="#cb10-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>live_playlist_id()<span class="op">,</span></span>
<span id="cb10-15"><a href="#cb10-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>history_playlist_id()<span class="op">,</span></span>
<span id="cb10-16"><a href="#cb10-16" aria-hidden="true" tabindex="-1"></a> ]<span class="op">;</span></span>
<span id="cb10-17"><a href="#cb10-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-18"><a href="#cb10-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb10-19"><a href="#cb10-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> tokens <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>callback_tokens<span class="op">.</span>lock()<span class="op">.</span>unwrap()<span class="op">;</span></span>
<span id="cb10-20"><a href="#cb10-20" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-21"><a href="#cb10-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> pid <span class="kw">in</span> playlist_ids <span class="op">{</span></span>
<span id="cb10-22"><a href="#cb10-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> weak <span class="op">=</span> <span class="pp">Arc::</span>downgrade(<span class="kw">self</span>)<span class="op">;</span></span>
<span id="cb10-23"><a href="#cb10-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> pid_clone <span class="op">=</span> pid<span class="op">.</span>clone()<span class="op">;</span></span>
<span id="cb10-24"><a href="#cb10-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> token <span class="op">=</span> manager<span class="op">.</span>register_callback(<span class="kw">move</span> <span class="op">|</span>event<span class="op">|</span> <span class="op">{</span></span>
<span id="cb10-25"><a href="#cb10-25" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> event<span class="op">.</span>playlist_id <span class="op">==</span> pid_clone <span class="op">{</span></span>
<span id="cb10-26"><a href="#cb10-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(strong) <span class="op">=</span> weak<span class="op">.</span>upgrade() <span class="op">{</span></span>
<span id="cb10-27"><a href="#cb10-27" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb10-28"><a href="#cb10-28" aria-hidden="true" tabindex="-1"></a> strong<span class="op">.</span>bump_update_counter()<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb10-29"><a href="#cb10-29" aria-hidden="true" tabindex="-1"></a> <span class="co">// Notifier ContentDirectory</span></span>
<span id="cb10-30"><a href="#cb10-30" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(notifier) <span class="op">=</span> strong<span class="op">.</span>container_notifier<span class="op">.</span>as_ref() <span class="op">{</span></span>
<span id="cb10-31"><a href="#cb10-31" aria-hidden="true" tabindex="-1"></a> notifier(<span class="op">&amp;</span>[<span class="pp">format!</span>(<span class="st">&quot;radio-paradise:history&quot;</span>)])<span class="op">;</span></span>
<span id="cb10-32"><a href="#cb10-32" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-33"><a href="#cb10-33" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb10-34"><a href="#cb10-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-35"><a href="#cb10-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-36"><a href="#cb10-36" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb10-37"><a href="#cb10-37" aria-hidden="true" tabindex="-1"></a> tokens<span class="op">.</span>push(token)<span class="op">;</span></span>
<span id="cb10-38"><a href="#cb10-38" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-39"><a href="#cb10-39" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-40"><a href="#cb10-40" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Points clés :</strong> - Callbacks sur
<code>pmoplaylist</code> pour détecter les changements - Notification du
ContentDirectory via un notifier injecté - <code>update_counter</code>
partagé via <code>Arc&lt;RwLock&lt;u32&gt;&gt;</code></p>
<h3 id="pattern-2-source-catalogue-avec-playlists-lazy-qobuz">Pattern 2
: Source catalogue avec playlists lazy (Qobuz)</h3>
<p><strong>Caractéristiques :</strong> - Catalogue vaste (millions de
tracks) - Playlists créées à la demande - Cache lazy (cover eager, audio
lazy) - <code>supports_fifo() = false</code></p>
<p><strong>Structure :</strong></p>
<div class="sourceCode" id="cb11"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> QobuzSource <span class="op">{</span></span>
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> inner<span class="op">:</span> Arc<span class="op">&lt;</span>QobuzSourceInner<span class="op">&gt;,</span></span>
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a><span class="kw">struct</span> QobuzSourceInner <span class="op">{</span></span>
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> Arc<span class="op">&lt;</span>QobuzClient<span class="op">&gt;,</span></span>
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a> cache_manager<span class="op">:</span> SourceCacheManager<span class="op">,</span></span>
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> <span class="pp">tokio::sync::</span>RwLock<span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;,</span></span>
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> <span class="pp">tokio::sync::</span>RwLock<span class="op">&lt;</span>SystemTime<span class="op">&gt;,</span></span>
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> QobuzSource <span class="op">{</span></span>
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter un track avec cache lazy</span></span>
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> add_track_lazy(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> <span class="op">&amp;</span>Track) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>(<span class="dt">String</span><span class="op">,</span> <span class="dt">String</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> track_id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;qobuz://track/{}&quot;</span><span class="op">,</span> track<span class="op">.</span>id)<span class="op">;</span></span>
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> lazy_pk <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;QOBUZ:{}&quot;</span><span class="op">,</span> track<span class="op">.</span>id)<span class="op">;</span></span>
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Cache cover EAGERLY (petit, UI en a besoin)</span></span>
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cached_cover_pk <span class="op">=</span> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(<span class="kw">ref</span> image_url) <span class="op">=</span> track<span class="op">.</span>album<span class="op">.</span>as_ref()</span>
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>and_then(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>image<span class="op">.</span>as_ref()) <span class="op">{</span></span>
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>inner<span class="op">.</span>cache_manager<span class="op">.</span>cache_cover(image_url)<span class="op">.</span><span class="kw">await</span><span class="op">.</span>ok()</span>
<span id="cb11-24"><a href="#cb11-24" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
<span id="cb11-25"><a href="#cb11-25" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span></span>
<span id="cb11-26"><a href="#cb11-26" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
<span id="cb11-27"><a href="#cb11-27" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-28"><a href="#cb11-28" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Préparer metadata</span></span>
<span id="cb11-29"><a href="#cb11-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> metadata <span class="op">=</span> AudioMetadata <span class="op">{</span></span>
<span id="cb11-30"><a href="#cb11-30" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="cn">Some</span>(track<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb11-31"><a href="#cb11-31" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> track<span class="op">.</span>performer<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>p<span class="op">|</span> p<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb11-32"><a href="#cb11-32" aria-hidden="true" tabindex="-1"></a> album<span class="op">:</span> track<span class="op">.</span>album<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb11-33"><a href="#cb11-33" aria-hidden="true" tabindex="-1"></a> duration_secs<span class="op">:</span> <span class="cn">Some</span>(track<span class="op">.</span>duration <span class="kw">as</span> <span class="dt">u64</span>)<span class="op">,</span></span>
<span id="cb11-34"><a href="#cb11-34" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... autres champs</span></span>
<span id="cb11-35"><a href="#cb11-35" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
<span id="cb11-36"><a href="#cb11-36" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-37"><a href="#cb11-37" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Cache audio LAZILY (grand, téléchargé à la demande)</span></span>
<span id="cb11-38"><a href="#cb11-38" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cached_audio_pk <span class="op">=</span> <span class="kw">self</span></span>
<span id="cb11-39"><a href="#cb11-39" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>inner</span>
<span id="cb11-40"><a href="#cb11-40" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_manager</span>
<span id="cb11-41"><a href="#cb11-41" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_audio_lazy_with_provider(</span>
<span id="cb11-42"><a href="#cb11-42" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span>lazy_pk<span class="op">,</span></span>
<span id="cb11-43"><a href="#cb11-43" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(metadata<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb11-44"><a href="#cb11-44" aria-hidden="true" tabindex="-1"></a> cached_cover_pk<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb11-45"><a href="#cb11-45" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb11-46"><a href="#cb11-46" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-47"><a href="#cb11-47" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-48"><a href="#cb11-48" aria-hidden="true" tabindex="-1"></a> <span class="co">// 4. Stocker metadata</span></span>
<span id="cb11-49"><a href="#cb11-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>inner<span class="op">.</span>cache_manager<span class="op">.</span>update_metadata(</span>
<span id="cb11-50"><a href="#cb11-50" aria-hidden="true" tabindex="-1"></a> track_id<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb11-51"><a href="#cb11-51" aria-hidden="true" tabindex="-1"></a> <span class="pp">pmosource::</span>TrackMetadata <span class="op">{</span></span>
<span id="cb11-52"><a href="#cb11-52" aria-hidden="true" tabindex="-1"></a> original_uri<span class="op">:</span> stream_url<span class="op">,</span></span>
<span id="cb11-53"><a href="#cb11-53" aria-hidden="true" tabindex="-1"></a> cached_audio_pk<span class="op">:</span> <span class="cn">Some</span>(cached_audio_pk<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb11-54"><a href="#cb11-54" aria-hidden="true" tabindex="-1"></a> cached_cover_pk<span class="op">,</span></span>
<span id="cb11-55"><a href="#cb11-55" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
<span id="cb11-56"><a href="#cb11-56" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb11-57"><a href="#cb11-57" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-58"><a href="#cb11-58" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>((track_id<span class="op">,</span> cached_audio_pk))</span>
<span id="cb11-59"><a href="#cb11-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-60"><a href="#cb11-60" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-61"><a href="#cb11-61" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer une playlist d&#39;album avec TTL</span></span>
<span id="cb11-62"><a href="#cb11-62" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> get_or_create_album_playlist_items(</span>
<span id="cb11-63"><a href="#cb11-63" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span></span>
<span id="cb11-64"><a href="#cb11-64" aria-hidden="true" tabindex="-1"></a> album_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb11-65"><a href="#cb11-65" aria-hidden="true" tabindex="-1"></a> limit<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
<span id="cb11-66"><a href="#cb11-66" aria-hidden="true" tabindex="-1"></a> ) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb11-67"><a href="#cb11-67" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> ALBUM_PLAYLIST_TTL<span class="op">:</span> Duration <span class="op">=</span> <span class="pp">Duration::</span>from_secs(<span class="dv">7</span> <span class="op">*</span> <span class="dv">24</span> <span class="op">*</span> <span class="dv">3600</span>)<span class="op">;</span></span>
<span id="cb11-68"><a href="#cb11-68" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-69"><a href="#cb11-69" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;qobuz-album-{}&quot;</span><span class="op">,</span> album_id)<span class="op">;</span></span>
<span id="cb11-70"><a href="#cb11-70" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb11-71"><a href="#cb11-71" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-72"><a href="#cb11-72" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier validité (existe ET non expirée ET non vide)</span></span>
<span id="cb11-73"><a href="#cb11-73" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> is_valid <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>is_album_playlist_valid(<span class="op">&amp;</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-74"><a href="#cb11-74" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-75"><a href="#cb11-75" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> is_valid <span class="op">{</span></span>
<span id="cb11-76"><a href="#cb11-76" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer depuis playlist existante</span></span>
<span id="cb11-77"><a href="#cb11-77" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> playlist_manager<span class="op">.</span>get_read_handle(<span class="op">&amp;</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-78"><a href="#cb11-78" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(limit)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-79"><a href="#cb11-79" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span> <span class="kw">self</span><span class="op">.</span>adapt_playlist_items_to_qobuz(items<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb11-80"><a href="#cb11-80" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-81"><a href="#cb11-81" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-82"><a href="#cb11-82" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer nouvelle playlist</span></span>
<span id="cb11-83"><a href="#cb11-83" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> writer <span class="op">=</span> playlist_manager</span>
<span id="cb11-84"><a href="#cb11-84" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>create_persistent_playlist_with_role(</span>
<span id="cb11-85"><a href="#cb11-85" aria-hidden="true" tabindex="-1"></a> playlist_id<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb11-86"><a href="#cb11-86" aria-hidden="true" tabindex="-1"></a> <span class="pp">pmoplaylist::PlaylistRole::</span>Album<span class="op">,</span></span>
<span id="cb11-87"><a href="#cb11-87" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb11-88"><a href="#cb11-88" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-89"><a href="#cb11-89" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-90"><a href="#cb11-90" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter tracks avec cache lazy</span></span>
<span id="cb11-91"><a href="#cb11-91" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_album_to_playlist(<span class="op">&amp;</span>playlist_id<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-92"><a href="#cb11-92" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-93"><a href="#cb11-93" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer items</span></span>
<span id="cb11-94"><a href="#cb11-94" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> reader <span class="op">=</span> playlist_manager<span class="op">.</span>get_read_handle(<span class="op">&amp;</span>playlist_id)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-95"><a href="#cb11-95" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> reader<span class="op">.</span>to_items(limit)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb11-96"><a href="#cb11-96" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>adapt_playlist_items_to_qobuz(items<span class="op">,</span> album_id)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb11-97"><a href="#cb11-97" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb11-98"><a href="#cb11-98" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Points clés :</strong> - Cache lazy pour laudio (téléchargé
à la demande) - Cache eager pour les covers (petit, UI en a besoin) -
Playlists avec TTL (7 jours) - <code>LazyProvider</code> pour
télécharger laudio lors de la lecture</p>
<h3 id="pattern-3-adaptation-des-ids-entre-playlist-et-source">Pattern 3
: Adaptation des IDs entre playlist et source</h3>
<p>Lorsquune source utilise <code>pmoplaylist</code>, les items
retournés ont des IDs génériques. Il faut les adapter au schéma de la
source :</p>
<div class="sourceCode" id="cb12"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> adapt_playlist_items_to_source(</span>
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span></span>
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>Item<span class="op">&gt;,</span></span>
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>Item<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">mut</span> adapted <span class="op">=</span> <span class="dt">Vec</span><span class="pp">::</span>with_capacity(items<span class="op">.</span>len())<span class="op">;</span></span>
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> <span class="kw">mut</span> item <span class="kw">in</span> items <span class="op">{</span></span>
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire cache_pk depuis l&#39;URL du resource</span></span>
<span id="cb12-10"><a href="#cb12-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cache_pk <span class="op">=</span> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(resource) <span class="op">=</span> item<span class="op">.</span>resources<span class="op">.</span>first() <span class="op">{</span></span>
<span id="cb12-11"><a href="#cb12-11" aria-hidden="true" tabindex="-1"></a> resource</span>
<span id="cb12-12"><a href="#cb12-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>url</span>
<span id="cb12-13"><a href="#cb12-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>strip_prefix(<span class="st">&quot;/audio/flac/&quot;</span>)</span>
<span id="cb12-14"><a href="#cb12-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>s<span class="op">|</span> s<span class="op">.</span>to_string())</span>
<span id="cb12-15"><a href="#cb12-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
<span id="cb12-16"><a href="#cb12-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">None</span></span>
<span id="cb12-17"><a href="#cb12-17" aria-hidden="true" tabindex="-1"></a> <span class="op">};</span></span>
<span id="cb12-18"><a href="#cb12-18" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-19"><a href="#cb12-19" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(pk) <span class="op">=</span> cache_pk <span class="op">{</span></span>
<span id="cb12-20"><a href="#cb12-20" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer source_track_id depuis metadata</span></span>
<span id="cb12-21"><a href="#cb12-21" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Ok</span>(<span class="cn">Some</span>(track_id_value)) <span class="op">=</span> <span class="kw">self</span></span>
<span id="cb12-22"><a href="#cb12-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_manager</span>
<span id="cb12-23"><a href="#cb12-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>get_audio_metadata(<span class="op">&amp;</span>pk<span class="op">,</span> <span class="st">&quot;source_track_id&quot;</span>)</span>
<span id="cb12-24"><a href="#cb12-24" aria-hidden="true" tabindex="-1"></a> <span class="op">{</span></span>
<span id="cb12-25"><a href="#cb12-25" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(track_id) <span class="op">=</span> track_id_value<span class="op">.</span>as_str() <span class="op">{</span></span>
<span id="cb12-26"><a href="#cb12-26" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>id <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;my-source:track:{}&quot;</span><span class="op">,</span> track_id)<span class="op">;</span></span>
<span id="cb12-27"><a href="#cb12-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-28"><a href="#cb12-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-29"><a href="#cb12-29" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-30"><a href="#cb12-30" aria-hidden="true" tabindex="-1"></a> <span class="co">// Convertir URL relative en absolue</span></span>
<span id="cb12-31"><a href="#cb12-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(resource) <span class="op">=</span> item<span class="op">.</span>resources<span class="op">.</span>first_mut() <span class="op">{</span></span>
<span id="cb12-32"><a href="#cb12-32" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> resource<span class="op">.</span>url<span class="op">.</span>starts_with(<span class="ch">&#39;/&#39;</span>) <span class="op">{</span></span>
<span id="cb12-33"><a href="#cb12-33" aria-hidden="true" tabindex="-1"></a> resource<span class="op">.</span>url <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;{}{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> resource<span class="op">.</span>url)<span class="op">;</span></span>
<span id="cb12-34"><a href="#cb12-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-35"><a href="#cb12-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-36"><a href="#cb12-36" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-37"><a href="#cb12-37" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-38"><a href="#cb12-38" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>parent_id <span class="op">=</span> parent_id<span class="op">.</span>to_string()<span class="op">;</span></span>
<span id="cb12-39"><a href="#cb12-39" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-40"><a href="#cb12-40" aria-hidden="true" tabindex="-1"></a> <span class="co">// Normaliser album art</span></span>
<span id="cb12-41"><a href="#cb12-41" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> <span class="kw">let</span> <span class="cn">Some</span>(art) <span class="op">=</span> item<span class="op">.</span>album_art<span class="op">.</span>as_mut() <span class="op">{</span></span>
<span id="cb12-42"><a href="#cb12-42" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> art<span class="op">.</span>starts_with(<span class="ch">&#39;/&#39;</span>) <span class="op">{</span></span>
<span id="cb12-43"><a href="#cb12-43" aria-hidden="true" tabindex="-1"></a> <span class="op">*</span>art <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;{}{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>base_url<span class="op">,</span> art)<span class="op">;</span></span>
<span id="cb12-44"><a href="#cb12-44" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-45"><a href="#cb12-45" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
<span id="cb12-46"><a href="#cb12-46" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>album_art <span class="op">=</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>default_cover_url())<span class="op">;</span></span>
<span id="cb12-47"><a href="#cb12-47" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-48"><a href="#cb12-48" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-49"><a href="#cb12-49" aria-hidden="true" tabindex="-1"></a> <span class="co">// Ajouter genre par défaut si absent (requis par certains clients)</span></span>
<span id="cb12-50"><a href="#cb12-50" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> item<span class="op">.</span>genre<span class="op">.</span>is_none() <span class="op">{</span></span>
<span id="cb12-51"><a href="#cb12-51" aria-hidden="true" tabindex="-1"></a> item<span class="op">.</span>genre <span class="op">=</span> <span class="cn">Some</span>(<span class="st">&quot;Music&quot;</span><span class="op">.</span>to_string())<span class="op">;</span></span>
<span id="cb12-52"><a href="#cb12-52" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-53"><a href="#cb12-53" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-54"><a href="#cb12-54" aria-hidden="true" tabindex="-1"></a> adapted<span class="op">.</span>push(item)<span class="op">;</span></span>
<span id="cb12-55"><a href="#cb12-55" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-56"><a href="#cb12-56" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-57"><a href="#cb12-57" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(adapted)</span>
<span id="cb12-58"><a href="#cb12-58" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Points clés :</strong> - Stocker <code>source_track_id</code>
dans les metadata du cache audio - Reconstituer lID correct lors de la
récupération depuis playlist - Normaliser URLs (relatives → absolues) -
Ajouter champs requis par certains clients UPnP</p>
<h2 id="intégration-avec-lécosystème-pmomusic">Intégration avec
lécosystème PMOMusic</h2>
<h3 id="avec-pmoplaylist">Avec pmoplaylist</h3>
<p>Pour les sources dynamiques et les catalogues :</p>
<div class="sourceCode" id="cb13"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylist::</span><span class="op">{</span>PlaylistManager<span class="op">,</span> PlaylistRole<span class="op">};</span></span>
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer une playlist persistante</span></span>
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> manager <span class="op">=</span> PlaylistManager()<span class="op">;</span></span>
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> writer <span class="op">=</span> manager</span>
<span id="cb13-6"><a href="#cb13-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>create_persistent_playlist_with_role(</span>
<span id="cb13-7"><a href="#cb13-7" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;my-source-album-123&quot;</span><span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb13-8"><a href="#cb13-8" aria-hidden="true" tabindex="-1"></a> <span class="pp">PlaylistRole::</span>Album<span class="op">,</span></span>
<span id="cb13-9"><a href="#cb13-9" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb13-10"><a href="#cb13-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-11"><a href="#cb13-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-12"><a href="#cb13-12" aria-hidden="true" tabindex="-1"></a><span class="co">// Configurer metadata</span></span>
<span id="cb13-13"><a href="#cb13-13" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_title(<span class="st">&quot;Album Title&quot;</span><span class="op">.</span>to_string())<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-14"><a href="#cb13-14" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_artist(<span class="cn">Some</span>(<span class="st">&quot;Artist Name&quot;</span><span class="op">.</span>to_string()))<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-15"><a href="#cb13-15" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>set_cover_pk(<span class="cn">Some</span>(<span class="st">&quot;cover-pk&quot;</span><span class="op">.</span>to_string()))<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-16"><a href="#cb13-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-17"><a href="#cb13-17" aria-hidden="true" tabindex="-1"></a><span class="co">// Ajouter tracks avec cache lazy</span></span>
<span id="cb13-18"><a href="#cb13-18" aria-hidden="true" tabindex="-1"></a>writer<span class="op">.</span>push_lazy_batch(<span class="pp">vec!</span>[<span class="st">&quot;pk1&quot;</span><span class="op">,</span> <span class="st">&quot;pk2&quot;</span><span class="op">,</span> <span class="st">&quot;pk3&quot;</span>])<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb13-19"><a href="#cb13-19" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-20"><a href="#cb13-20" aria-hidden="true" tabindex="-1"></a><span class="co">// Activer mode lazy (lookahead 2 tracks)</span></span>
<span id="cb13-21"><a href="#cb13-21" aria-hidden="true" tabindex="-1"></a>manager<span class="op">.</span>enable_lazy_mode(<span class="st">&quot;my-source-album-123&quot;</span><span class="op">,</span> <span class="dv">2</span>)<span class="op">;</span></span></pre></div>
<h3 id="avec-pmoaudiocache-et-pmocovers-via-sourcecachemanager">Avec
pmoaudiocache et pmocovers (via SourceCacheManager)</h3>
<div class="sourceCode" id="cb14"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmosource::</span>SourceCacheManager<span class="op">;</span></span>
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer le manager centralisé</span></span>
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cache_manager <span class="op">=</span> <span class="pp">SourceCacheManager::</span>from_registry(<span class="st">&quot;my-source&quot;</span><span class="op">.</span>to_string())<span class="op">?;</span></span>
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a><span class="co">// Enregistrer un LazyProvider</span></span>
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a>cache_manager<span class="op">.</span>register_lazy_provider(<span class="pp">Arc::</span>new(<span class="pp">MyLazyProvider::</span>new(client)))<span class="op">;</span></span>
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a><span class="co">// Cache eager (cover)</span></span>
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cover_pk <span class="op">=</span> cache_manager<span class="op">.</span>cache_cover(<span class="st">&quot;https://example.com/cover.jpg&quot;</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a><span class="co">// Cache lazy (audio)</span></span>
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> audio_pk <span class="op">=</span> cache_manager</span>
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>cache_audio_lazy_with_provider(</span>
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;MY-SOURCE:123&quot;</span><span class="op">,</span> <span class="co">// Lazy PK</span></span>
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(metadata)<span class="op">,</span></span>
<span id="cb14-17"><a href="#cb14-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Some</span>(cover_pk)<span class="op">,</span></span>
<span id="cb14-18"><a href="#cb14-18" aria-hidden="true" tabindex="-1"></a> )</span>
<span id="cb14-19"><a href="#cb14-19" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb14-20"><a href="#cb14-20" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-21"><a href="#cb14-21" aria-hidden="true" tabindex="-1"></a><span class="co">// Récupérer metadata</span></span>
<span id="cb14-22"><a href="#cb14-22" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> value <span class="op">=</span> cache_manager<span class="op">.</span>get_audio_metadata(<span class="op">&amp;</span>audio_pk<span class="op">,</span> <span class="st">&quot;key&quot;</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span></pre></div>
<p><strong>LazyProvider personnalisé :</strong></p>
<div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoaudiocache::</span><span class="op">{</span>LazyProvider<span class="op">,</span> LazyProviderError<span class="op">};</span></span>
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> MyLazyProvider <span class="op">{</span></span>
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a> client<span class="op">:</span> Arc<span class="op">&lt;</span>MyClient<span class="op">&gt;,</span></span>
<span id="cb15-5"><a href="#cb15-5" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb15-6"><a href="#cb15-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-7"><a href="#cb15-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb15-8"><a href="#cb15-8" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> LazyProvider <span class="cf">for</span> MyLazyProvider <span class="op">{</span></span>
<span id="cb15-9"><a href="#cb15-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> fetch_audio(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> lazy_pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">u8</span><span class="op">&gt;,</span> LazyProviderError<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb15-10"><a href="#cb15-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Extraire l&#39;ID depuis le lazy_pk</span></span>
<span id="cb15-11"><a href="#cb15-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> id <span class="op">=</span> lazy_pk</span>
<span id="cb15-12"><a href="#cb15-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>strip_prefix(<span class="st">&quot;MY-SOURCE:&quot;</span>)</span>
<span id="cb15-13"><a href="#cb15-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or_else(<span class="op">||</span> <span class="pp">LazyProviderError::</span>InvalidKey)<span class="op">?;</span></span>
<span id="cb15-14"><a href="#cb15-14" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-15"><a href="#cb15-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Récupérer l&#39;URL de streaming</span></span>
<span id="cb15-16"><a href="#cb15-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> stream_url <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>client<span class="op">.</span>get_stream_url(id)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb15-17"><a href="#cb15-17" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb15-18"><a href="#cb15-18" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-19"><a href="#cb15-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Télécharger l&#39;audio</span></span>
<span id="cb15-20"><a href="#cb15-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> response <span class="op">=</span> <span class="pp">reqwest::</span>get(<span class="op">&amp;</span>stream_url)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb15-22"><a href="#cb15-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-23"><a href="#cb15-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> bytes <span class="op">=</span> response<span class="op">.</span>bytes()<span class="op">.</span><span class="kw">await</span></span>
<span id="cb15-24"><a href="#cb15-24" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="pp">LazyProviderError::</span>FetchFailed(e<span class="op">.</span>to_string()))<span class="op">?;</span></span>
<span id="cb15-25"><a href="#cb15-25" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-26"><a href="#cb15-26" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(bytes<span class="op">.</span>to_vec())</span>
<span id="cb15-27"><a href="#cb15-27" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb15-28"><a href="#cb15-28" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="avec-pmodidl">Avec pmodidl</h3>
<p>Conversion de vos structures en DIDL-Lite :</p>
<div class="sourceCode" id="cb16"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmodidl::</span><span class="op">{</span>Container<span class="op">,</span> Item<span class="op">,</span> Resource<span class="op">};</span></span>
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a><span class="co">// Container</span></span>
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ToDIDLContainer <span class="op">{</span></span>
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_container(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Container<span class="op">&gt;;</span></span>
<span id="cb16-6"><a href="#cb16-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb16-7"><a href="#cb16-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb16-8"><a href="#cb16-8" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ToDIDLContainer <span class="cf">for</span> MyAlbum <span class="op">{</span></span>
<span id="cb16-9"><a href="#cb16-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_container(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Container<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb16-10"><a href="#cb16-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Container <span class="op">{</span></span>
<span id="cb16-11"><a href="#cb16-11" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="pp">format!</span>(<span class="st">&quot;my-source:album:{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>id)<span class="op">,</span></span>
<span id="cb16-12"><a href="#cb16-12" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> parent_id<span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb16-13"><a href="#cb16-13" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;1&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-14"><a href="#cb16-14" aria-hidden="true" tabindex="-1"></a> child_count<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>tracks_count<span class="op">.</span>map(<span class="op">|</span>c<span class="op">|</span> c<span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-15"><a href="#cb16-15" aria-hidden="true" tabindex="-1"></a> searchable<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;1&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-16"><a href="#cb16-16" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>title<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-17"><a href="#cb16-17" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">&quot;object.container.album.musicAlbum&quot;</span><span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb16-18"><a href="#cb16-18" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb16-19"><a href="#cb16-19" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_url<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-20"><a href="#cb16-20" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
<span id="cb16-21"><a href="#cb16-21" aria-hidden="true" tabindex="-1"></a> items<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
<span id="cb16-22"><a href="#cb16-22" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb16-23"><a href="#cb16-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb16-24"><a href="#cb16-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb16-25"><a href="#cb16-25" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb16-26"><a href="#cb16-26" aria-hidden="true" tabindex="-1"></a><span class="co">// Item</span></span>
<span id="cb16-27"><a href="#cb16-27" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ToDIDLItem <span class="op">{</span></span>
<span id="cb16-28"><a href="#cb16-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_item(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Item<span class="op">&gt;;</span></span>
<span id="cb16-29"><a href="#cb16-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb16-30"><a href="#cb16-30" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb16-31"><a href="#cb16-31" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ToDIDLItem <span class="cf">for</span> MyTrack <span class="op">{</span></span>
<span id="cb16-32"><a href="#cb16-32" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> to_didl_item(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Item<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb16-33"><a href="#cb16-33" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Item <span class="op">{</span></span>
<span id="cb16-34"><a href="#cb16-34" aria-hidden="true" tabindex="-1"></a> id<span class="op">:</span> <span class="pp">format!</span>(<span class="st">&quot;my-source:track:{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>id)<span class="op">,</span></span>
<span id="cb16-35"><a href="#cb16-35" aria-hidden="true" tabindex="-1"></a> parent_id<span class="op">:</span> parent_id<span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb16-36"><a href="#cb16-36" aria-hidden="true" tabindex="-1"></a> restricted<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;1&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-37"><a href="#cb16-37" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>title<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-38"><a href="#cb16-38" aria-hidden="true" tabindex="-1"></a> creator<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb16-39"><a href="#cb16-39" aria-hidden="true" tabindex="-1"></a> class<span class="op">:</span> <span class="st">&quot;object.item.audioItem.musicTrack&quot;</span><span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb16-40"><a href="#cb16-40" aria-hidden="true" tabindex="-1"></a> artist<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>artist<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>name<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb16-41"><a href="#cb16-41" aria-hidden="true" tabindex="-1"></a> album<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>album<span class="op">.</span>as_ref()<span class="op">.</span>map(<span class="op">|</span>a<span class="op">|</span> a<span class="op">.</span>title<span class="op">.</span>clone())<span class="op">,</span></span>
<span id="cb16-42"><a href="#cb16-42" aria-hidden="true" tabindex="-1"></a> genre<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;Music&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-43"><a href="#cb16-43" aria-hidden="true" tabindex="-1"></a> album_art<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_url<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-44"><a href="#cb16-44" aria-hidden="true" tabindex="-1"></a> album_art_pk<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>cover_pk<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-45"><a href="#cb16-45" aria-hidden="true" tabindex="-1"></a> date<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>release_date<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb16-46"><a href="#cb16-46" aria-hidden="true" tabindex="-1"></a> original_track_number<span class="op">:</span> <span class="cn">Some</span>(<span class="kw">self</span><span class="op">.</span>track_number)<span class="op">,</span></span>
<span id="cb16-47"><a href="#cb16-47" aria-hidden="true" tabindex="-1"></a> resources<span class="op">:</span> <span class="pp">vec!</span>[Resource <span class="op">{</span></span>
<span id="cb16-48"><a href="#cb16-48" aria-hidden="true" tabindex="-1"></a> protocol_info<span class="op">:</span> <span class="st">&quot;http-get:*:audio/flac:*&quot;</span><span class="op">.</span>to_string()<span class="op">,</span></span>
<span id="cb16-49"><a href="#cb16-49" aria-hidden="true" tabindex="-1"></a> bits_per_sample<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>bit_depth<span class="op">.</span>map(<span class="op">|</span>b<span class="op">|</span> b<span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-50"><a href="#cb16-50" aria-hidden="true" tabindex="-1"></a> sample_frequency<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>sample_rate<span class="op">.</span>map(<span class="op">|</span>s<span class="op">|</span> s<span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-51"><a href="#cb16-51" aria-hidden="true" tabindex="-1"></a> nr_audio_channels<span class="op">:</span> <span class="cn">Some</span>(<span class="st">&quot;2&quot;</span><span class="op">.</span>to_string())<span class="op">,</span></span>
<span id="cb16-52"><a href="#cb16-52" aria-hidden="true" tabindex="-1"></a> duration<span class="op">:</span> <span class="kw">self</span><span class="op">.</span>duration_as_upnp_format()<span class="op">,</span></span>
<span id="cb16-53"><a href="#cb16-53" aria-hidden="true" tabindex="-1"></a> url<span class="op">:</span> <span class="pp">format!</span>(<span class="st">&quot;/audio/flac/{}&quot;</span><span class="op">,</span> <span class="kw">self</span><span class="op">.</span>cache_pk)<span class="op">,</span></span>
<span id="cb16-54"><a href="#cb16-54" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>]<span class="op">,</span></span>
<span id="cb16-55"><a href="#cb16-55" aria-hidden="true" tabindex="-1"></a> descriptions<span class="op">:</span> <span class="pp">vec!</span>[]<span class="op">,</span></span>
<span id="cb16-56"><a href="#cb16-56" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb16-57"><a href="#cb16-57" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb16-58"><a href="#cb16-58" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="checklist-de-mise-en-œuvre">Checklist de mise en œuvre</h2>
<h3 id="phase-1-structure-de-base">Phase 1 : Structure de base</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Créer le crate
<code>pmo&lt;votre-source&gt;</code></label></li>
<li><label><input type="checkbox" />Ajouter les dépendances dans
<code>Cargo.toml</code></label></li>
<li><label><input type="checkbox" />Créer le logo WebP 300x300px dans
<code>assets/</code></label></li>
<li><label><input type="checkbox" />Définir la structure
principale</label></li>
<li><label><input type="checkbox" />Implémenter <code>name()</code>,
<code>id()</code>, <code>default_image()</code></label></li>
</ul>
<h3 id="phase-2-navigation-contentdirectory">Phase 2 : Navigation
ContentDirectory</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Définir le schéma dObject
ID</label></li>
<li><label><input type="checkbox" />Implémenter
<code>root_container()</code></label></li>
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
pour la racine</label></li>
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
pour les sous-containers</label></li>
<li><label><input type="checkbox" />Implémenter <code>browse()</code>
pour les items</label></li>
<li><label><input type="checkbox" />Tester la navigation avec un client
UPnP</label></li>
</ul>
<h3 id="phase-3-résolution-duri">Phase 3 : Résolution dURI</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Implémenter
<code>resolve_uri()</code> avec fallback</label></li>
<li><label><input type="checkbox" />Intégrer avec
<code>SourceCacheManager</code></label></li>
<li><label><input type="checkbox" />Implémenter
<code>LazyProvider</code> si cache lazy</label></li>
<li><label><input type="checkbox" />Tester la lecture audio</label></li>
</ul>
<h3 id="phase-4-support-fifo-si-dynamique">Phase 4 : Support FIFO (si
dynamique)</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Décider de la stratégie
FIFO</label></li>
<li><label><input type="checkbox" />Implémenter
<code>supports_fifo() = true</code></label></li>
<li><label><input type="checkbox" />Implémenter
<code>append_track()</code></label></li>
<li><label><input type="checkbox" />Implémenter
<code>remove_oldest()</code></label></li>
<li><label><input type="checkbox" />Implémenter <code>update_id()</code>
et <code>last_change()</code></label></li>
<li><label><input type="checkbox" />Enregistrer callbacks sur
playlists</label></li>
<li><label><input type="checkbox" />Tester ajout/suppression de
tracks</label></li>
</ul>
<h3 id="phase-5-support-statique-si-catalogue">Phase 5 : Support
statique (si catalogue)</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Implémenter
<code>supports_fifo() = false</code></label></li>
<li><label><input type="checkbox" />Implémenter <code>get_items()</code>
avec pagination</label></li>
<li><label><input type="checkbox" />Implémenter <code>search()</code> si
applicable</label></li>
<li><label><input type="checkbox" />Tester browsing du
catalogue</label></li>
</ul>
<h3 id="phase-6-intégration-avancée">Phase 6 : Intégration avancée</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Implémenter <code>get_item()</code>
pour metadata</label></li>
<li><label><input type="checkbox" />Implémenter
<code>capabilities()</code></label></li>
<li><label><input type="checkbox" />Implémenter
<code>get_available_formats()</code></label></li>
<li><label><input type="checkbox" />Ajouter gestion derreurs
robuste</label></li>
<li><label><input type="checkbox" />Documenter le code</label></li>
</ul>
<h3 id="phase-7-tests-et-validation">Phase 7 : Tests et validation</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Écrire tests unitaires</label></li>
<li><label><input type="checkbox" />Écrire tests
dintégration</label></li>
<li><label><input type="checkbox" />Tester avec différents clients
UPnP</label></li>
<li><label><input type="checkbox" />Valider les
performances</label></li>
<li><label><input type="checkbox" />Documenter les
limitations</label></li>
</ul>
<h2 id="exemples-de-référence">Exemples de référence</h2>
<h3 id="radio-paradise-source-dynamique-fifo">Radio Paradise (source
dynamique FIFO)</h3>
<p><strong>Fichier :</strong> <code>pmoparadise/src/source.rs</code></p>
<p><strong>Points dintérêt :</strong> - Structure avec
<code>Arc&lt;RwLock&lt;&gt;&gt;</code> pour létat partagé - Callbacks
sur playlists pour détecter les changements - Notifier injecté pour
ContentDirectory - Adaptation des IDs playlist → Radio Paradise -
Support de 4 canaux avec sous-containers</p>
<p><strong>Schema dObject ID :</strong></p>
<pre><code>radio-paradise # Racine
radio-paradise:channel:{slug} # Canal (main, mellow, rock, eclectic)
radio-paradise:channel:{slug}:live # Stream live
radio-paradise:channel:{slug}:liveplaylist # Playlist live (queue)
radio-paradise:channel:{slug}:liveplaylist:track:{pk} # Track dans queue
radio-paradise:channel:{slug}:history # Historique
radio-paradise:channel:{slug}:history:track:{pk} # Track dans historique</pre>
<h3 id="qobuz-source-catalogue-avec-playlists-lazy">Qobuz (source
catalogue avec playlists lazy)</h3>
<p><strong>Fichier :</strong> <code>pmoqobuz/src/source.rs</code></p>
<p><strong>Points dintérêt :</strong> - <code>SourceCacheManager</code>
centralisé - Cache lazy pour audio, eager pour covers -
<code>LazyProvider</code> personnalisé - Playlists dalbums avec TTL (7
jours) - Adaptation IDs playlist → Qobuz - Navigation hiérarchique
complexe (Discover, Genres, Favorites)</p>
<p><strong>Schema dObject ID :</strong></p>
<pre><code>qobuz # Racine
qobuz:discover # Discover Catalog
qobuz:discover:albums:ideal # Albums (Ideal Discography)
qobuz:discover:artists # Artistes Featured
qobuz:genres # Discover Genres
qobuz:genre:{id} # Genre spécifique
qobuz:genre:{id}:new-releases # Nouveautés du genre
qobuz:favorites # My Music
qobuz:favorites:albums # Albums favoris
qobuz:album:{id} # Album spécifique
qobuz:track:{id} # Track spécifique
qobuz:playlist:{id} # Playlist spécifique
qobuz:artist:{id} # Artiste spécifique</pre>
<h2 id="conseils-dimplémentation">Conseils dimplémentation</h2>
<h3 id="performance">Performance</h3>
<ol type="1">
<li><strong>Cache agressif</strong> : Utilisez
<code>SourceCacheManager</code> pour tout</li>
<li><strong>Pagination</strong> : Limitez le nombre ditems retournés
(max 100)</li>
<li><strong>Lazy loading</strong> : Ne chargez que ce qui est
demandé</li>
<li><strong>Rate limiting</strong> : Respectez les limites API de la
source</li>
<li><strong>Arc&lt;&gt;</strong> : Partagez les données coûteuses</li>
</ol>
<h3 id="compatibilité-upnp">Compatibilité UPnP</h3>
<ol type="1">
<li><strong>Genre obligatoire</strong> : Certains clients (gupnp-av-cp)
requièrent <code>&lt;upnp:genre&gt;</code></li>
<li><strong>URLs absolues</strong> : Toujours retourner des URLs
complètes (pas de chemins relatifs)</li>
<li><strong>Protocol Info</strong> : Utilisez
<code>http-get:*:audio/flac:*</code> pour FLAC</li>
<li><strong>Duration</strong> : Format <code>H:MM:SS</code> (ex:
<code>0:03:45</code>)</li>
<li><strong>childCount</strong> : Optionnel mais recommandé pour
lUI</li>
</ol>
<h3 id="gestion-derreurs">Gestion derreurs</h3>
<ol type="1">
<li><strong>ObjectNotFound</strong> : ID invalide</li>
<li><strong>BrowseError</strong> : Erreur générique de navigation</li>
<li><strong>UriResolutionError</strong> : Impossible de résoudre
lURI</li>
<li><strong>PlaylistError</strong> : Erreur dinteraction avec
pmoplaylist</li>
<li><strong>CacheError</strong> : Erreur de cache</li>
</ol>
<h3 id="thread-safety">Thread Safety</h3>
<ol type="1">
<li><strong>Arc&lt;RwLock&lt;&gt;&gt;</strong> : Pour létat mutable
partagé</li>
<li><strong>tokio::sync::RwLock</strong> : Pour lasync</li>
<li><strong>Éviter Rc&lt;&gt;</strong> : Pas thread-safe</li>
<li><strong>Clone</strong> : Implémentez <code>Clone</code> pour
<code>Arc&lt;&gt;</code></li>
</ol>
<h2 id="conclusion">Conclusion</h2>
<p>Limplémentation dune nouvelle <code>MusicSource</code> suit ces
étapes :</p>
<ol type="1">
<li><strong>Définir le schéma dObject ID</strong> : Hiérarchie claire
et cohérente</li>
<li><strong>Implémenter la navigation</strong> : <code>browse()</code>
pour tous les niveaux</li>
<li><strong>Résoudre les URIs</strong> : Cache local dabord, puis
original</li>
<li><strong>Gérer le cache</strong> : <code>SourceCacheManager</code> +
<code>LazyProvider</code></li>
<li><strong>Adapter les IDs</strong> : Playlist → Schema de la
source</li>
<li><strong>Notifier les changements</strong> : <code>update_id</code> +
callbacks</li>
</ol>
<p>Les exemples Radio Paradise et Qobuz couvrent les deux patterns
principaux : - <strong>Dynamique FIFO</strong> : Radio Paradise -
<strong>Catalogue lazy</strong> : Qobuz</p>
<p>En suivant ces patterns, vous obtiendrez une source musicale
performante, compatible UPnP, et bien intégrée dans lécosystème
PMOMusic.</p>
</article>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,929 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pmoserver_ext</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="pattern-dextension-pmoserver-pmoserver_ext">Pattern dextension
PMOServer (<code>pmoserver_ext</code>)</h1>
<h2 id="vue-densemble">Vue densemble</h2>
<p>Le pattern <code>pmoserver_ext</code> permet détendre les
fonctionnalités du serveur HTTP <code>pmoserver</code> de manière
modulaire et découplée. Chaque crate spécialisée peut ajouter ses
propres routes HTTP sans que <code>pmoserver</code> ne dépende de ces
crates.</p>
<p><strong>Principe</strong> : Définir un trait dextension que
<code>pmoserver::Server</code> implémente via une feature Cargo.</p>
<h2 id="anatomie-dune-extension">Anatomie dune extension</h2>
<h3 id="structure-du-module">1. Structure du module</h3>
<p>Créer un module <code>pmoserver_ext.rs</code> dans la crate :</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoXXX/src/pmoserver_ext.rs</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="kw">crate</span><span class="pp">::</span><span class="op">{</span><span class="co">/* types internes de la crate */</span><span class="op">};</span></span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">async_trait::</span>async_trait<span class="op">;</span></span>
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">axum::</span><span class="op">{</span>Router<span class="op">,</span> <span class="pp">routing::</span>get<span class="op">,</span> Json<span class="op">,</span> <span class="pp">extract::</span><span class="op">{</span>State<span class="op">,</span> <span class="dt">Path</span><span class="op">}};</span></span>
<span id="cb1-9"><a href="#cb1-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb1-10"><a href="#cb1-10" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span></pre></div>
<p>Déclarer le module dans <code>lib.rs</code> :</p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoXXX/src/lib.rs</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">mod</span> pmoserver_ext<span class="op">;</span></span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">pmoserver_ext::</span>XXXExt<span class="op">;</span></span></pre></div>
<p>Ajouter la feature dans <code>Cargo.toml</code> :</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode toml"><code class="sourceCode toml"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[features]</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoserver</span> <span class="op">=</span> <span class="op">[</span><span class="st">&quot;dep:axum&quot;</span><span class="op">,</span> <span class="st">&quot;dep:async-trait&quot;</span><span class="op">]</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="dt">axum</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;0.8&quot;</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="dt">async-trait</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;0.1&quot;</span><span class="op">, </span><span class="dt">optional</span><span class="op"> =</span> <span class="cn">true</span><span class="op"> }</span></span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a><span class="dt">pmoserver</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">path</span><span class="op"> =</span> <span class="st">&quot;../pmoserver&quot;</span><span class="op"> }</span></span></pre></div>
<h3 id="définir-le-trait-dextension">2. Définir le trait
dextension</h3>
<p><strong>Convention de nommage</strong> : <code>{Domaine}Ext</code>
avec méthodes préfixées <code>init_*</code></p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Trait pour étendre pmoserver avec les fonctionnalités XXX</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> XXXExt <span class="op">{</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Initialise l&#39;extension XXX et enregistre les routes HTTP</span></span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Arguments</span></span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">/// * `param1` - Description du paramètre</span></span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Returns</span></span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Instance partagée de la ressource créée</span></span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> <span class="co">/// # Exemple</span></span>
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ```ignore</span></span>
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="co">/// use pmoserver::ServerBuilder;</span></span>
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> <span class="co">/// use pmoXXX::XXXExt;</span></span>
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> <span class="co">///</span></span>
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> <span class="co">/// let mut server = ServerBuilder::new(...).build();</span></span>
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a> <span class="co">/// let resource = server.init_xxx(param1).await?;</span></span>
<span id="cb4-20"><a href="#cb4-20" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ```</span></span>
<span id="cb4-21"><a href="#cb4-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> param1<span class="op">:</span> <span class="dt">String</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>Resource<span class="op">&gt;&gt;;</span></span>
<span id="cb4-22"><a href="#cb4-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="implémenter-le-trait">3. Implémenter le trait</h3>
<p>Implémenter le trait pour <code>pmoserver::Server</code> :</p>
<div class="sourceCode" id="cb5"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XXXExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span> param1<span class="op">:</span> <span class="dt">String</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>Resource<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Créer la ressource interne</span></span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Resource::</span>new(param1)<span class="op">?</span>)<span class="op">;</span></span>
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Créer l&#39;état partagé pour les handlers</span></span>
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">XxxState::</span>new(resource<span class="op">.</span>clone())<span class="op">;</span></span>
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Créer le router avec les routes</span></span>
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> create_xxx_router(state)<span class="op">;</span></span>
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// 4. Enregistrer le router sur le serveur</span></span>
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">&quot;/api/xxx&quot;</span><span class="op">,</span> router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="co">// 5. Retourner la ressource pour usage ultérieur</span></span>
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="état-partagé-state">4. État partagé (State)</h3>
<p>Créer une structure détat cloneable pour les handlers :</p>
<div class="sourceCode" id="cb6"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// État partagé pour les handlers XXX</span></span>
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> XxxState <span class="op">{</span></span>
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a> resource<span class="op">:</span> Arc<span class="op">&lt;</span>Resource<span class="op">&gt;,</span></span>
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XxxState <span class="op">{</span></span>
<span id="cb6-8"><a href="#cb6-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> new(resource<span class="op">:</span> Arc<span class="op">&lt;</span>Resource<span class="op">&gt;</span>) <span class="op">-&gt;</span> <span class="dt">Self</span> <span class="op">{</span></span>
<span id="cb6-9"><a href="#cb6-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">Self</span> <span class="op">{</span> resource <span class="op">}</span></span>
<span id="cb6-10"><a href="#cb6-10" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb6-11"><a href="#cb6-11" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="créer-le-router">5. Créer le router</h3>
<p>Définir les routes et handlers :</p>
<div class="sourceCode" id="cb7"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Crée le router pour l&#39;API XXX</span></span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a><span class="kw">fn</span> create_xxx_router(state<span class="op">:</span> XxxState) <span class="op">-&gt;</span> Router <span class="op">{</span></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a> <span class="pp">Router::</span>new()</span>
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/items&quot;</span><span class="op">,</span> get(list_items)<span class="op">.</span>post(create_item))</span>
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/items/{id}&quot;</span><span class="op">,</span> get(get_item)<span class="op">.</span>delete(delete_item))</span>
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)</span>
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a><span class="co">// Handlers</span></span>
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(</span>
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;</span></span>
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> Json<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>ItemSummary<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list_items()<span class="op">;</span></span>
<span id="cb7-14"><a href="#cb7-14" aria-hidden="true" tabindex="-1"></a> Json(items)</span>
<span id="cb7-15"><a href="#cb7-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb7-16"><a href="#cb7-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-17"><a href="#cb7-17" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_item(</span>
<span id="cb7-18"><a href="#cb7-18" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb7-19"><a href="#cb7-19" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb7-20"><a href="#cb7-20" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Json<span class="op">&lt;</span>Item<span class="op">&gt;,</span> StatusCode<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb7-21"><a href="#cb7-21" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>get_item(<span class="op">&amp;</span>id)</span>
<span id="cb7-22"><a href="#cb7-22" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or(<span class="pp">StatusCode::</span>NOT_FOUND)</span>
<span id="cb7-23"><a href="#cb7-23" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(Json)</span>
<span id="cb7-24"><a href="#cb7-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="méthodes-disponibles-du-serveur">Méthodes disponibles du
serveur</h2>
<p><code>pmoserver::Server</code> expose ces méthodes pour enregistrer
des routes :</p>
<table>
<colgroup>
<col style="width: 56%" />
<col style="width: 43%" />
</colgroup>
<thead>
<tr>
<th>Méthode</th>
<th>Usage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>add_handler(path, handler)</code></td>
<td>Ajoute un handler simple sans état</td>
</tr>
<tr>
<td><code>add_handler_with_state(path, handler, state)</code></td>
<td>Ajoute un handler avec état partagé</td>
</tr>
<tr>
<td><code>add_router(path, router)</code></td>
<td>Monte un sous-router Axum</td>
</tr>
<tr>
<td><code>add_openapi(router, doc, tag)</code></td>
<td>Enregistre une API avec documentation OpenAPI</td>
</tr>
<tr>
<td><code>add_spa::&lt;W&gt;(path)</code></td>
<td>Sert une Single Page Application (RustEmbed)</td>
</tr>
<tr>
<td><code>base_url()</code></td>
<td>Récupère lURL de base du serveur</td>
</tr>
</tbody>
</table>
<h2 id="documentation-openapi-avec-utoipa">Documentation OpenAPI avec
utoipa</h2>
<p>La documentation OpenAPI est essentielle pour une extension
<code>pmoserver</code>. Elle génère automatiquement une interface
Swagger UI et documente les endpoints de lAPI.</p>
<h3 id="configuration-de-base">Configuration de base</h3>
<p>Ajouter <code>utoipa</code> dans <code>Cargo.toml</code> :</p>
<div class="sourceCode" id="cb8"><pre
class="sourceCode toml"><code class="sourceCode toml"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[dependencies]</span></span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a><span class="dt">utoipa</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;5&quot;</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">&quot;axum_extras&quot;</span><span class="op">] }</span></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="dt">serde</span> <span class="op">=</span> <span class="op">{ </span><span class="dt">version</span><span class="op"> =</span> <span class="st">&quot;1&quot;</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">&quot;derive&quot;</span><span class="op">] }</span></span></pre></div>
<h3 id="définir-les-schémas-de-données">1. Définir les schémas de
données</h3>
<p>Annoter les structures de réponse/requête avec
<code>#[derive(ToSchema)]</code> :</p>
<div class="sourceCode" id="cb9"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">serde::</span><span class="op">{</span>Serialize<span class="op">,</span> Deserialize<span class="op">};</span></span>
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">utoipa::</span>ToSchema<span class="op">;</span></span>
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a><span class="co">/// Information sur un item</span></span>
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ItemInfo <span class="op">{</span></span>
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="co">/// ID unique de l&#39;item</span></span>
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;item-123&quot;</span><span class="at">)]</span></span>
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> id<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nom de l&#39;item</span></span>
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;Mon Item&quot;</span><span class="at">)]</span></span>
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> name<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Description optionnelle</span></span>
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;Une description détaillée&quot;</span><span class="at">)]</span></span>
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Timestamp de création (millisecondes)</span></span>
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">1234567890</span><span class="at">)]</span></span>
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> created_at<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a><span class="co">/// Liste d&#39;items</span></span>
<span id="cb9-25"><a href="#cb9-25" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb9-26"><a href="#cb9-26" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ItemList <span class="op">{</span></span>
<span id="cb9-27"><a href="#cb9-27" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nombre total d&#39;items</span></span>
<span id="cb9-28"><a href="#cb9-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> total<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
<span id="cb9-29"><a href="#cb9-29" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb9-30"><a href="#cb9-30" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Items de la page courante</span></span>
<span id="cb9-31"><a href="#cb9-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> items<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>ItemInfo<span class="op">&gt;,</span></span>
<span id="cb9-32"><a href="#cb9-32" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb9-33"><a href="#cb9-33" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-34"><a href="#cb9-34" aria-hidden="true" tabindex="-1"></a><span class="co">/// Requête de création d&#39;item</span></span>
<span id="cb9-35"><a href="#cb9-35" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Deserialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb9-36"><a href="#cb9-36" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> CreateItemRequest <span class="op">{</span></span>
<span id="cb9-37"><a href="#cb9-37" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Nom de l&#39;item à créer</span></span>
<span id="cb9-38"><a href="#cb9-38" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;Nouvel Item&quot;</span><span class="at">)]</span></span>
<span id="cb9-39"><a href="#cb9-39" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> name<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb9-40"><a href="#cb9-40" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb9-41"><a href="#cb9-41" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Description optionnelle</span></span>
<span id="cb9-42"><a href="#cb9-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb9-43"><a href="#cb9-43" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb9-44"><a href="#cb9-44" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-45"><a href="#cb9-45" aria-hidden="true" tabindex="-1"></a><span class="co">/// Réponse d&#39;erreur standard</span></span>
<span id="cb9-46"><a href="#cb9-46" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb9-47"><a href="#cb9-47" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ErrorResponse <span class="op">{</span></span>
<span id="cb9-48"><a href="#cb9-48" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Message d&#39;erreur</span></span>
<span id="cb9-49"><a href="#cb9-49" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;Item not found&quot;</span><span class="at">)]</span></span>
<span id="cb9-50"><a href="#cb9-50" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> error<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb9-51"><a href="#cb9-51" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Points clés</strong> : -
<code>#[schema(example = "...")]</code> : Fournit des exemples pour la
doc Swagger - Documenter chaque champ avec <code>///</code> pour
apparaître dans lAPI - Utiliser <code>Option&lt;T&gt;</code> pour les
champs optionnels</p>
<h3 id="annoter-les-handlers">2. Annoter les handlers</h3>
<p>Utiliser <code>#[utoipa::path(...)]</code> pour documenter chaque
endpoint :</p>
<div class="sourceCode" id="cb10"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /items - Liste tous les items</span></span>
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">&quot;/items&quot;</span><span class="op">,</span></span>
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;limit&quot;</span> <span class="op">=</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Nombre max d&#39;items à retourner&quot;</span><span class="at">)</span><span class="op">,</span></span>
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;offset&quot;</span> <span class="op">=</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Offset pour la pagination&quot;</span><span class="at">)</span></span>
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Liste des items&quot;</span><span class="op">,</span> body <span class="op">=</span> ItemList<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Erreur serveur&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">&quot;items&quot;</span></span>
<span id="cb10-14"><a href="#cb10-14" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb10-15"><a href="#cb10-15" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(</span>
<span id="cb10-16"><a href="#cb10-16" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb10-17"><a href="#cb10-17" aria-hidden="true" tabindex="-1"></a> Query(params)<span class="op">:</span> Query<span class="op">&lt;</span>ListParams<span class="op">&gt;,</span></span>
<span id="cb10-18"><a href="#cb10-18" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Json<span class="op">&lt;</span>ItemList<span class="op">&gt;,</span> (StatusCode<span class="op">,</span> Json<span class="op">&lt;</span>ErrorResponse<span class="op">&gt;</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb10-19"><a href="#cb10-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list_items(params<span class="op">.</span>limit<span class="op">,</span> params<span class="op">.</span>offset)</span>
<span id="cb10-20"><a href="#cb10-20" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
<span id="cb10-21"><a href="#cb10-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
<span id="cb10-22"><a href="#cb10-22" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
<span id="cb10-23"><a href="#cb10-23" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
<span id="cb10-24"><a href="#cb10-24" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb10-25"><a href="#cb10-25" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(ItemList <span class="op">{</span></span>
<span id="cb10-26"><a href="#cb10-26" aria-hidden="true" tabindex="-1"></a> total<span class="op">:</span> items<span class="op">.</span>len()<span class="op">,</span></span>
<span id="cb10-27"><a href="#cb10-27" aria-hidden="true" tabindex="-1"></a> items<span class="op">,</span></span>
<span id="cb10-28"><a href="#cb10-28" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>))</span>
<span id="cb10-29"><a href="#cb10-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb10-30"><a href="#cb10-30" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-31"><a href="#cb10-31" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /items/{id} - Récupère un item spécifique</span></span>
<span id="cb10-32"><a href="#cb10-32" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
<span id="cb10-33"><a href="#cb10-33" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
<span id="cb10-34"><a href="#cb10-34" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">&quot;/items/{id}&quot;</span><span class="op">,</span></span>
<span id="cb10-35"><a href="#cb10-35" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
<span id="cb10-36"><a href="#cb10-36" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;id&quot;</span> <span class="op">=</span> <span class="dt">String</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;ID unique de l&#39;item&quot;</span><span class="at">)</span></span>
<span id="cb10-37"><a href="#cb10-37" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-38"><a href="#cb10-38" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
<span id="cb10-39"><a href="#cb10-39" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Item trouvé&quot;</span><span class="op">,</span> body <span class="op">=</span> ItemInfo<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-40"><a href="#cb10-40" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">404</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Item non trouvé&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-41"><a href="#cb10-41" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Erreur serveur&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
<span id="cb10-42"><a href="#cb10-42" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-43"><a href="#cb10-43" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">&quot;items&quot;</span></span>
<span id="cb10-44"><a href="#cb10-44" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb10-45"><a href="#cb10-45" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_item(</span>
<span id="cb10-46"><a href="#cb10-46" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb10-47"><a href="#cb10-47" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb10-48"><a href="#cb10-48" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Json<span class="op">&lt;</span>ItemInfo<span class="op">&gt;,</span> (StatusCode<span class="op">,</span> Json<span class="op">&lt;</span>ErrorResponse<span class="op">&gt;</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb10-49"><a href="#cb10-49" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>get_item(<span class="op">&amp;</span>id)</span>
<span id="cb10-50"><a href="#cb10-50" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or_else(<span class="op">||</span> (</span>
<span id="cb10-51"><a href="#cb10-51" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>NOT_FOUND<span class="op">,</span></span>
<span id="cb10-52"><a href="#cb10-52" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span></span>
<span id="cb10-53"><a href="#cb10-53" aria-hidden="true" tabindex="-1"></a> error<span class="op">:</span> <span class="pp">format!</span>(<span class="st">&quot;Item {} not found&quot;</span><span class="op">,</span> id)</span>
<span id="cb10-54"><a href="#cb10-54" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb10-55"><a href="#cb10-55" aria-hidden="true" tabindex="-1"></a> ))</span>
<span id="cb10-56"><a href="#cb10-56" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(Json)</span>
<span id="cb10-57"><a href="#cb10-57" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb10-58"><a href="#cb10-58" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-59"><a href="#cb10-59" aria-hidden="true" tabindex="-1"></a><span class="co">/// POST /items - Crée un nouvel item</span></span>
<span id="cb10-60"><a href="#cb10-60" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
<span id="cb10-61"><a href="#cb10-61" aria-hidden="true" tabindex="-1"></a> post<span class="op">,</span></span>
<span id="cb10-62"><a href="#cb10-62" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">&quot;/items&quot;</span><span class="op">,</span></span>
<span id="cb10-63"><a href="#cb10-63" aria-hidden="true" tabindex="-1"></a> request_body <span class="op">=</span> CreateItemRequest<span class="op">,</span></span>
<span id="cb10-64"><a href="#cb10-64" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
<span id="cb10-65"><a href="#cb10-65" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">201</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Item créé&quot;</span><span class="op">,</span> body <span class="op">=</span> ItemInfo<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-66"><a href="#cb10-66" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">400</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Requête invalide&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-67"><a href="#cb10-67" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Erreur serveur&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
<span id="cb10-68"><a href="#cb10-68" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-69"><a href="#cb10-69" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">&quot;items&quot;</span></span>
<span id="cb10-70"><a href="#cb10-70" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb10-71"><a href="#cb10-71" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> create_item(</span>
<span id="cb10-72"><a href="#cb10-72" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb10-73"><a href="#cb10-73" aria-hidden="true" tabindex="-1"></a> Json(req)<span class="op">:</span> Json<span class="op">&lt;</span>CreateItemRequest<span class="op">&gt;,</span></span>
<span id="cb10-74"><a href="#cb10-74" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>(StatusCode<span class="op">,</span> Json<span class="op">&lt;</span>ItemInfo<span class="op">&gt;</span>)<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op">&lt;</span>ErrorResponse<span class="op">&gt;</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb10-75"><a href="#cb10-75" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> item <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>create_item(req<span class="op">.</span>name<span class="op">,</span> req<span class="op">.</span>description)</span>
<span id="cb10-76"><a href="#cb10-76" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
<span id="cb10-77"><a href="#cb10-77" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
<span id="cb10-78"><a href="#cb10-78" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
<span id="cb10-79"><a href="#cb10-79" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
<span id="cb10-80"><a href="#cb10-80" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb10-81"><a href="#cb10-81" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>((<span class="pp">StatusCode::</span>CREATED<span class="op">,</span> Json(item)))</span>
<span id="cb10-82"><a href="#cb10-82" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb10-83"><a href="#cb10-83" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-84"><a href="#cb10-84" aria-hidden="true" tabindex="-1"></a><span class="co">/// DELETE /items/{id} - Supprime un item</span></span>
<span id="cb10-85"><a href="#cb10-85" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
<span id="cb10-86"><a href="#cb10-86" aria-hidden="true" tabindex="-1"></a> delete<span class="op">,</span></span>
<span id="cb10-87"><a href="#cb10-87" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">&quot;/items/{id}&quot;</span><span class="op">,</span></span>
<span id="cb10-88"><a href="#cb10-88" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
<span id="cb10-89"><a href="#cb10-89" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;id&quot;</span> <span class="op">=</span> <span class="dt">String</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;ID unique de l&#39;item&quot;</span><span class="at">)</span></span>
<span id="cb10-90"><a href="#cb10-90" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-91"><a href="#cb10-91" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
<span id="cb10-92"><a href="#cb10-92" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">204</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Item supprimé&quot;</span><span class="at">)</span><span class="op">,</span></span>
<span id="cb10-93"><a href="#cb10-93" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">404</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Item non trouvé&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span><span class="op">,</span></span>
<span id="cb10-94"><a href="#cb10-94" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Erreur serveur&quot;</span><span class="op">,</span> body <span class="op">=</span> ErrorResponse<span class="at">)</span></span>
<span id="cb10-95"><a href="#cb10-95" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb10-96"><a href="#cb10-96" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">&quot;items&quot;</span></span>
<span id="cb10-97"><a href="#cb10-97" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb10-98"><a href="#cb10-98" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> delete_item(</span>
<span id="cb10-99"><a href="#cb10-99" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb10-100"><a href="#cb10-100" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb10-101"><a href="#cb10-101" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>StatusCode<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op">&lt;</span>ErrorResponse<span class="op">&gt;</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb10-102"><a href="#cb10-102" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>resource<span class="op">.</span>delete_item(<span class="op">&amp;</span>id)</span>
<span id="cb10-103"><a href="#cb10-103" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
<span id="cb10-104"><a href="#cb10-104" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
<span id="cb10-105"><a href="#cb10-105" aria-hidden="true" tabindex="-1"></a> Json(ErrorResponse <span class="op">{</span> error<span class="op">:</span> e<span class="op">.</span>to_string() <span class="op">}</span>)</span>
<span id="cb10-106"><a href="#cb10-106" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?;</span></span>
<span id="cb10-107"><a href="#cb10-107" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb10-108"><a href="#cb10-108" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(<span class="pp">StatusCode::</span>NO_CONTENT)</span>
<span id="cb10-109"><a href="#cb10-109" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Structure de <code>#[utoipa::path]</code></strong> : -
<strong>Méthode HTTP</strong> : <code>get</code>, <code>post</code>,
<code>put</code>, <code>delete</code>, <code>patch</code> -
<strong><code>path</code></strong> : Chemin de lendpoint (doit
correspondre au router) - <strong><code>params</code></strong> :
Paramètres Path ou Query avec description -
<strong><code>request_body</code></strong> : Type du body pour POST/PUT
- <strong><code>responses</code></strong> : Liste des réponses possibles
avec codes HTTP - <strong><code>tag</code></strong> : Groupe dendpoints
dans Swagger UI</p>
<h3 id="créer-la-structure-openapi">3. Créer la structure OpenAPI</h3>
<p>Définir une structure avec <code>#[derive(OpenApi)]</code> :</p>
<div class="sourceCode" id="cb11"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">utoipa::</span>OpenApi<span class="op">;</span></span>
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="co">/// Documentation OpenAPI pour l&#39;API XXX</span></span>
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span>OpenApi<span class="at">)]</span></span>
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>openapi<span class="at">(</span></span>
<span id="cb11-6"><a href="#cb11-6" aria-hidden="true" tabindex="-1"></a> info<span class="at">(</span></span>
<span id="cb11-7"><a href="#cb11-7" aria-hidden="true" tabindex="-1"></a> title <span class="op">=</span> <span class="st">&quot;XXX API&quot;</span><span class="op">,</span></span>
<span id="cb11-8"><a href="#cb11-8" aria-hidden="true" tabindex="-1"></a> version <span class="op">=</span> <span class="st">&quot;1.0.0&quot;</span><span class="op">,</span></span>
<span id="cb11-9"><a href="#cb11-9" aria-hidden="true" tabindex="-1"></a> description <span class="op">=</span> <span class="st">r#&quot;</span></span>
<span id="cb11-10"><a href="#cb11-10" aria-hidden="true" tabindex="-1"></a><span class="st"># API REST pour XXX</span></span>
<span id="cb11-11"><a href="#cb11-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-12"><a href="#cb11-12" aria-hidden="true" tabindex="-1"></a><span class="st">Cette API permet de gérer les items XXX avec les fonctionnalités suivantes :</span></span>
<span id="cb11-13"><a href="#cb11-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-14"><a href="#cb11-14" aria-hidden="true" tabindex="-1"></a><span class="st">## Fonctionnalités</span></span>
<span id="cb11-15"><a href="#cb11-15" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-16"><a href="#cb11-16" aria-hidden="true" tabindex="-1"></a><span class="st">- **CRUD complet** : Création, lecture, mise à jour et suppression d&#39;items</span></span>
<span id="cb11-17"><a href="#cb11-17" aria-hidden="true" tabindex="-1"></a><span class="st">- **Pagination** : Support de limit/offset pour les listes</span></span>
<span id="cb11-18"><a href="#cb11-18" aria-hidden="true" tabindex="-1"></a><span class="st">- **Filtrage** : Recherche par critères multiples</span></span>
<span id="cb11-19"><a href="#cb11-19" aria-hidden="true" tabindex="-1"></a><span class="st">- **Validation** : Vérification automatique des données</span></span>
<span id="cb11-20"><a href="#cb11-20" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-21"><a href="#cb11-21" aria-hidden="true" tabindex="-1"></a><span class="st">## Exemples d&#39;utilisation</span></span>
<span id="cb11-22"><a href="#cb11-22" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb11-23"><a href="#cb11-23" aria-hidden="true" tabindex="-1"></a><span class="st">### Lister les items</span></span></pre></div>
<p>GET /api/xxx/items?limit=10&amp;offset=0</p>
<pre><code>
### Créer un item</pre>
<p>POST /api/xxx/items Content-Type: application/json</p>
<p>{ “name”: “Mon Item”, “description”: “Description détaillée” }</p>
<pre><code>
### Récupérer un item</pre>
<p>GET /api/xxx/items/item-123</p>
<pre><code>
### Supprimer un item</pre>
<p>DELETE /api/xxx/items/item-123</p>
<pre><code> &quot;#
),
paths(
list_items,
get_item,
create_item,
delete_item,
),
components(schemas(
ItemInfo,
ItemList,
CreateItemRequest,
ErrorResponse,
)),
tags(
(name = &quot;items&quot;, description = &quot;Opérations sur les items&quot;)
)
)]
pub struct ApiDoc;</pre>
<p><strong>Sections importantes</strong> : -
<strong><code>info</code></strong> : Titre, version et description
Markdown de lAPI - <strong><code>paths</code></strong> : Liste des
fonctions handler annotées -
<strong><code>components(schemas(...))</code></strong> : Liste des
structures <code>ToSchema</code> - <strong><code>tags</code></strong> :
Organisation des endpoints en groupes</p>
<h3 id="enregistrer-lapi-avec-openapi">4. Enregistrer lAPI avec
OpenAPI</h3>
<p>Dans limplémentation du trait dextension :</p>
<div class="sourceCode" id="cb16"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb16-1"><a href="#cb16-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb16-2"><a href="#cb16-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> XxxExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb16-3"><a href="#cb16-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_xxx(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>Resource<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb16-4"><a href="#cb16-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Resource::</span>new()<span class="op">?</span>)<span class="op">;</span></span>
<span id="cb16-5"><a href="#cb16-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> XxxState <span class="op">{</span> resource<span class="op">:</span> resource<span class="op">.</span>clone() <span class="op">};</span></span>
<span id="cb16-6"><a href="#cb16-6" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb16-7"><a href="#cb16-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer le router avec les routes</span></span>
<span id="cb16-8"><a href="#cb16-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
<span id="cb16-9"><a href="#cb16-9" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/items&quot;</span><span class="op">,</span> get(list_items)<span class="op">.</span>post(create_item))</span>
<span id="cb16-10"><a href="#cb16-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/items/{id}&quot;</span><span class="op">,</span> get(get_item)<span class="op">.</span>delete(delete_item))</span>
<span id="cb16-11"><a href="#cb16-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)<span class="op">;</span></span>
<span id="cb16-12"><a href="#cb16-12" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb16-13"><a href="#cb16-13" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer avec OpenAPI (génère aussi /swagger-ui/xxx)</span></span>
<span id="cb16-14"><a href="#cb16-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> openapi <span class="op">=</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">;</span></span>
<span id="cb16-15"><a href="#cb16-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(router<span class="op">,</span> openapi<span class="op">,</span> <span class="st">&quot;xxx&quot;</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb16-16"><a href="#cb16-16" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb16-17"><a href="#cb16-17" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
<span id="cb16-18"><a href="#cb16-18" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb16-19"><a href="#cb16-19" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Ce que fait <code>add_openapi</code></strong> : - Monte le
router sur <code>/api/{tag}/</code> - Génère la spec OpenAPI JSON sur
<code>/api/{tag}/openapi.json</code> - Crée une UI Swagger sur
<code>/swagger-ui/{tag}/</code></p>
<h3 id="exemple-complet-radio-paradise">5. Exemple complet : Radio
Paradise</h3>
<p><strong>Extrait de</strong>
<code>pmoparadise/src/pmoserver_ext.rs:93-315</code></p>
<div class="sourceCode" id="cb17"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="co">/// Information sur un morceau</span></span>
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> SongInfo <span class="op">{</span></span>
<span id="cb17-4"><a href="#cb17-4" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Index dans le block</span></span>
<span id="cb17-5"><a href="#cb17-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> index<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
<span id="cb17-6"><a href="#cb17-6" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Artiste</span></span>
<span id="cb17-7"><a href="#cb17-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> artist<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb17-8"><a href="#cb17-8" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Titre</span></span>
<span id="cb17-9"><a href="#cb17-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb17-10"><a href="#cb17-10" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Album</span></span>
<span id="cb17-11"><a href="#cb17-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> album<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb17-12"><a href="#cb17-12" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Année</span></span>
<span id="cb17-13"><a href="#cb17-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> year<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;,</span></span>
<span id="cb17-14"><a href="#cb17-14" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Temps écoulé depuis le début du block (ms)</span></span>
<span id="cb17-15"><a href="#cb17-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> elapsed_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
<span id="cb17-16"><a href="#cb17-16" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Durée du morceau (ms)</span></span>
<span id="cb17-17"><a href="#cb17-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> duration_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
<span id="cb17-18"><a href="#cb17-18" aria-hidden="true" tabindex="-1"></a> <span class="co">/// URL de la pochette</span></span>
<span id="cb17-19"><a href="#cb17-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> cover_url<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb17-20"><a href="#cb17-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb17-21"><a href="#cb17-21" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb17-22"><a href="#cb17-22" aria-hidden="true" tabindex="-1"></a><span class="co">/// Réponse pour l&#39;URL de streaming</span></span>
<span id="cb17-23"><a href="#cb17-23" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> ToSchema<span class="at">)]</span></span>
<span id="cb17-24"><a href="#cb17-24" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> StreamUrlResponse <span class="op">{</span></span>
<span id="cb17-25"><a href="#cb17-25" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Event ID du block</span></span>
<span id="cb17-26"><a href="#cb17-26" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">1234567</span><span class="at">)]</span></span>
<span id="cb17-27"><a href="#cb17-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> event<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
<span id="cb17-28"><a href="#cb17-28" aria-hidden="true" tabindex="-1"></a> <span class="co">/// URL de streaming FLAC</span></span>
<span id="cb17-29"><a href="#cb17-29" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="st">&quot;https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac&quot;</span><span class="at">)]</span></span>
<span id="cb17-30"><a href="#cb17-30" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> stream_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb17-31"><a href="#cb17-31" aria-hidden="true" tabindex="-1"></a> <span class="co">/// Durée totale (ms)</span></span>
<span id="cb17-32"><a href="#cb17-32" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>schema<span class="at">(</span>example <span class="op">=</span> <span class="dv">900000</span><span class="at">)]</span></span>
<span id="cb17-33"><a href="#cb17-33" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> length_ms<span class="op">:</span> <span class="dt">u64</span><span class="op">,</span></span>
<span id="cb17-34"><a href="#cb17-34" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb17-35"><a href="#cb17-35" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb17-36"><a href="#cb17-36" aria-hidden="true" tabindex="-1"></a><span class="co">/// GET /stream-url/{event_id} - Récupère l&#39;URL de streaming</span></span>
<span id="cb17-37"><a href="#cb17-37" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span><span class="pp">utoipa::</span>path<span class="at">(</span></span>
<span id="cb17-38"><a href="#cb17-38" aria-hidden="true" tabindex="-1"></a> get<span class="op">,</span></span>
<span id="cb17-39"><a href="#cb17-39" aria-hidden="true" tabindex="-1"></a> path <span class="op">=</span> <span class="st">&quot;/stream-url/{event_id}&quot;</span><span class="op">,</span></span>
<span id="cb17-40"><a href="#cb17-40" aria-hidden="true" tabindex="-1"></a> params<span class="at">(</span></span>
<span id="cb17-41"><a href="#cb17-41" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;event_id&quot;</span> <span class="op">=</span> <span class="dt">u64</span><span class="op">,</span> <span class="dt">Path</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Event ID du block&quot;</span><span class="at">)</span><span class="op">,</span></span>
<span id="cb17-42"><a href="#cb17-42" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span><span class="st">&quot;channel&quot;</span> <span class="op">=</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u8</span><span class="op">&gt;,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Channel ID (0-3)&quot;</span><span class="at">)</span></span>
<span id="cb17-43"><a href="#cb17-43" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb17-44"><a href="#cb17-44" aria-hidden="true" tabindex="-1"></a> responses<span class="at">(</span></span>
<span id="cb17-45"><a href="#cb17-45" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">200</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;URL de streaming&quot;</span><span class="op">,</span> body <span class="op">=</span> StreamUrlResponse<span class="at">)</span><span class="op">,</span></span>
<span id="cb17-46"><a href="#cb17-46" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>status <span class="op">=</span> <span class="dv">500</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Erreur serveur&quot;</span><span class="at">)</span></span>
<span id="cb17-47"><a href="#cb17-47" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb17-48"><a href="#cb17-48" aria-hidden="true" tabindex="-1"></a> tag <span class="op">=</span> <span class="st">&quot;Radio Paradise&quot;</span></span>
<span id="cb17-49"><a href="#cb17-49" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb17-50"><a href="#cb17-50" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> get_stream_url(</span>
<span id="cb17-51"><a href="#cb17-51" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>RadioParadiseState<span class="op">&gt;,</span></span>
<span id="cb17-52"><a href="#cb17-52" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(event_id)<span class="op">:</span> <span class="dt">Path</span><span class="op">&lt;</span><span class="dt">u64</span><span class="op">&gt;,</span></span>
<span id="cb17-53"><a href="#cb17-53" aria-hidden="true" tabindex="-1"></a> Query(params)<span class="op">:</span> Query<span class="op">&lt;</span>ParadiseQuery<span class="op">&gt;,</span></span>
<span id="cb17-54"><a href="#cb17-54" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Json<span class="op">&lt;</span>StreamUrlResponse<span class="op">&gt;,</span> StatusCode<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb17-55"><a href="#cb17-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> client <span class="op">=</span> state<span class="op">.</span>client_for_params(<span class="op">&amp;</span>params)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb17-56"><a href="#cb17-56" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> block <span class="op">=</span> client<span class="op">.</span>get_block(<span class="cn">Some</span>(event_id))<span class="op">.</span><span class="kw">await</span><span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="op">{</span></span>
<span id="cb17-57"><a href="#cb17-57" aria-hidden="true" tabindex="-1"></a> <span class="pp">tracing::error!</span>(<span class="st">&quot;Failed to fetch block {}: {}&quot;</span><span class="op">,</span> event_id<span class="op">,</span> e)<span class="op">;</span></span>
<span id="cb17-58"><a href="#cb17-58" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR</span>
<span id="cb17-59"><a href="#cb17-59" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">?;</span></span>
<span id="cb17-60"><a href="#cb17-60" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb17-61"><a href="#cb17-61" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(StreamUrlResponse <span class="op">{</span></span>
<span id="cb17-62"><a href="#cb17-62" aria-hidden="true" tabindex="-1"></a> event<span class="op">:</span> block<span class="op">.</span>event<span class="op">,</span></span>
<span id="cb17-63"><a href="#cb17-63" aria-hidden="true" tabindex="-1"></a> stream_url<span class="op">:</span> block<span class="op">.</span>url<span class="op">,</span></span>
<span id="cb17-64"><a href="#cb17-64" aria-hidden="true" tabindex="-1"></a> length_ms<span class="op">:</span> block<span class="op">.</span>length<span class="op">,</span></span>
<span id="cb17-65"><a href="#cb17-65" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>))</span>
<span id="cb17-66"><a href="#cb17-66" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb17-67"><a href="#cb17-67" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb17-68"><a href="#cb17-68" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span>OpenApi<span class="at">)]</span></span>
<span id="cb17-69"><a href="#cb17-69" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>openapi<span class="at">(</span></span>
<span id="cb17-70"><a href="#cb17-70" aria-hidden="true" tabindex="-1"></a> info<span class="at">(</span></span>
<span id="cb17-71"><a href="#cb17-71" aria-hidden="true" tabindex="-1"></a> title <span class="op">=</span> <span class="st">&quot;Radio Paradise API&quot;</span><span class="op">,</span></span>
<span id="cb17-72"><a href="#cb17-72" aria-hidden="true" tabindex="-1"></a> version <span class="op">=</span> <span class="st">&quot;1.0.0&quot;</span><span class="op">,</span></span>
<span id="cb17-73"><a href="#cb17-73" aria-hidden="true" tabindex="-1"></a> description <span class="op">=</span> <span class="st">&quot;API REST pour accéder aux métadonnées Radio Paradise&quot;</span></span>
<span id="cb17-74"><a href="#cb17-74" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb17-75"><a href="#cb17-75" aria-hidden="true" tabindex="-1"></a> paths<span class="at">(</span></span>
<span id="cb17-76"><a href="#cb17-76" aria-hidden="true" tabindex="-1"></a> get_now_playing<span class="op">,</span></span>
<span id="cb17-77"><a href="#cb17-77" aria-hidden="true" tabindex="-1"></a> get_current_block<span class="op">,</span></span>
<span id="cb17-78"><a href="#cb17-78" aria-hidden="true" tabindex="-1"></a> get_stream_url<span class="op">,</span></span>
<span id="cb17-79"><a href="#cb17-79" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span><span class="op">,</span></span>
<span id="cb17-80"><a href="#cb17-80" aria-hidden="true" tabindex="-1"></a> components<span class="at">(</span>schemas<span class="at">(</span></span>
<span id="cb17-81"><a href="#cb17-81" aria-hidden="true" tabindex="-1"></a> SongInfo<span class="op">,</span></span>
<span id="cb17-82"><a href="#cb17-82" aria-hidden="true" tabindex="-1"></a> StreamUrlResponse<span class="op">,</span></span>
<span id="cb17-83"><a href="#cb17-83" aria-hidden="true" tabindex="-1"></a> <span class="at">))</span><span class="op">,</span></span>
<span id="cb17-84"><a href="#cb17-84" aria-hidden="true" tabindex="-1"></a> tags<span class="at">(</span></span>
<span id="cb17-85"><a href="#cb17-85" aria-hidden="true" tabindex="-1"></a> <span class="at">(</span>name <span class="op">=</span> <span class="st">&quot;Radio Paradise&quot;</span><span class="op">,</span> description <span class="op">=</span> <span class="st">&quot;Endpoints Radio Paradise&quot;</span><span class="at">)</span></span>
<span id="cb17-86"><a href="#cb17-86" aria-hidden="true" tabindex="-1"></a> <span class="at">)</span></span>
<span id="cb17-87"><a href="#cb17-87" aria-hidden="true" tabindex="-1"></a><span class="at">)]</span></span>
<span id="cb17-88"><a href="#cb17-88" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> RadioParadiseApiDoc<span class="op">;</span></span></pre></div>
<h3 id="résultat-interface-swagger">Résultat : Interface Swagger</h3>
<p>Après avoir appelé <code>init_xxx()</code>, lAPI est accessible
:</p>
<ul>
<li><strong>API JSON</strong> :
<code>http://localhost:8080/api/xxx/</code></li>
<li><strong>Spec OpenAPI</strong> :
<code>http://localhost:8080/api/xxx/openapi.json</code></li>
<li><strong>Swagger UI</strong> :
<code>http://localhost:8080/swagger-ui/xxx/</code></li>
</ul>
<p>Linterface Swagger permet : - Parcourir tous les endpoints avec leur
documentation - Tester les requêtes directement depuis le navigateur -
Voir les schémas de données avec exemples - Consulter les codes de
réponse HTTP possibles</p>
<h2 id="patterns-courants">Patterns courants</h2>
<h3 id="pattern-1-extension-simple-avec-router">Pattern 1 : Extension
simple avec router</h3>
<p><strong>Exemple</strong> : <code>pmoparadise</code>
(pmoparadise/src/pmoserver_ext.rs:367-392)</p>
<div class="sourceCode" id="cb18"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb18-1"><a href="#cb18-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb18-2"><a href="#cb18-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> RadioParadiseExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb18-3"><a href="#cb18-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_radioparadise(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>State<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb18-4"><a href="#cb18-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">RadioParadiseState::</span>new()<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb18-5"><a href="#cb18-5" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-6"><a href="#cb18-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Créer le router API</span></span>
<span id="cb18-7"><a href="#cb18-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> api_router <span class="op">=</span> create_api_router(state<span class="op">.</span>clone())<span class="op">;</span></span>
<span id="cb18-8"><a href="#cb18-8" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-9"><a href="#cb18-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Enregistrer avec OpenAPI</span></span>
<span id="cb18-10"><a href="#cb18-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(api_router<span class="op">,</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">,</span> <span class="st">&quot;radioparadise&quot;</span>)</span>
<span id="cb18-11"><a href="#cb18-11" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb18-12"><a href="#cb18-12" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-13"><a href="#cb18-13" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(state)</span>
<span id="cb18-14"><a href="#cb18-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-15"><a href="#cb18-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="pattern-2-extension-avec-cache-et-fichiers">Pattern 2 :
Extension avec cache et fichiers</h3>
<p><strong>Exemple</strong> : <code>pmoaudiocache</code>
(pmoaudiocache/src/lib.rs:225-260)</p>
<div class="sourceCode" id="cb19"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb19-1"><a href="#cb19-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb19-2"><a href="#cb19-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> AudioCacheExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb19-3"><a href="#cb19-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_audio_cache(</span>
<span id="cb19-4"><a href="#cb19-4" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span><span class="op">,</span></span>
<span id="cb19-5"><a href="#cb19-5" aria-hidden="true" tabindex="-1"></a> cache_dir<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span></span>
<span id="cb19-6"><a href="#cb19-6" aria-hidden="true" tabindex="-1"></a> limit<span class="op">:</span> <span class="dt">usize</span><span class="op">,</span></span>
<span id="cb19-7"><a href="#cb19-7" aria-hidden="true" tabindex="-1"></a> ) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>Cache<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb19-8"><a href="#cb19-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> cache <span class="op">=</span> <span class="pp">Arc::</span>new(new_cache(cache_dir<span class="op">,</span> limit)<span class="op">?</span>)<span class="op">;</span></span>
<span id="cb19-9"><a href="#cb19-9" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb19-10"><a href="#cb19-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Router pour servir les fichiers FLAC</span></span>
<span id="cb19-11"><a href="#cb19-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> file_router <span class="op">=</span> create_file_router(cache<span class="op">.</span>clone()<span class="op">,</span> <span class="st">&quot;audio/flac&quot;</span>)<span class="op">;</span></span>
<span id="cb19-12"><a href="#cb19-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">&quot;/&quot;</span><span class="op">,</span> file_router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb19-13"><a href="#cb19-13" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb19-14"><a href="#cb19-14" aria-hidden="true" tabindex="-1"></a> <span class="co">// API REST</span></span>
<span id="cb19-15"><a href="#cb19-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> api_router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
<span id="cb19-16"><a href="#cb19-16" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/&quot;</span><span class="op">,</span> get(list)<span class="op">.</span>post(add))</span>
<span id="cb19-17"><a href="#cb19-17" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/{pk}&quot;</span><span class="op">,</span> get(get_info)<span class="op">.</span>delete(delete))</span>
<span id="cb19-18"><a href="#cb19-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(cache<span class="op">.</span>clone())<span class="op">;</span></span>
<span id="cb19-19"><a href="#cb19-19" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb19-20"><a href="#cb19-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_openapi(api_router<span class="op">,</span> <span class="pp">ApiDoc::</span>openapi()<span class="op">,</span> <span class="st">&quot;audio&quot;</span>)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb19-21"><a href="#cb19-21" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb19-22"><a href="#cb19-22" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(cache)</span>
<span id="cb19-23"><a href="#cb19-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb19-24"><a href="#cb19-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="pattern-3-extension-avec-routes-dynamiques">Pattern 3 :
Extension avec routes dynamiques</h3>
<p><strong>Exemple</strong> : <code>pmomediaserver</code>
(pmomediaserver/src/paradise_streaming.rs:70-148)</p>
<div class="sourceCode" id="cb20"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb20-1"><a href="#cb20-1" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb20-2"><a href="#cb20-2" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ParadiseStreamingExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb20-3"><a href="#cb20-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_paradise_streaming(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>Manager<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb20-4"><a href="#cb20-4" aria-hidden="true" tabindex="-1"></a> <span class="co">// 1. Récupérer/créer les ressources partagées</span></span>
<span id="cb20-5"><a href="#cb20-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> audio_cache <span class="op">=</span> get_or_init_audio_cache(<span class="kw">self</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb20-6"><a href="#cb20-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> manager <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">Manager::</span>new(audio_cache)<span class="op">.</span><span class="kw">await</span><span class="op">?</span>)<span class="op">;</span></span>
<span id="cb20-7"><a href="#cb20-7" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb20-8"><a href="#cb20-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// 2. Créer l&#39;état partagé</span></span>
<span id="cb20-9"><a href="#cb20-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> <span class="pp">Arc::</span>new(StreamingState <span class="op">{</span> manager<span class="op">:</span> manager<span class="op">.</span>clone() <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb20-10"><a href="#cb20-10" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb20-11"><a href="#cb20-11" aria-hidden="true" tabindex="-1"></a> <span class="co">// 3. Enregistrer les routes pour chaque canal</span></span>
<span id="cb20-12"><a href="#cb20-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">for</span> descriptor <span class="kw">in</span> ALL_CHANNELS<span class="op">.</span>iter() <span class="op">{</span></span>
<span id="cb20-13"><a href="#cb20-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> slug <span class="op">=</span> descriptor<span class="op">.</span>slug<span class="op">;</span></span>
<span id="cb20-14"><a href="#cb20-14" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb20-15"><a href="#cb20-15" aria-hidden="true" tabindex="-1"></a> <span class="co">// Route streaming FLAC</span></span>
<span id="cb20-16"><a href="#cb20-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> path <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;/stream/{}/flac&quot;</span><span class="op">,</span> slug)<span class="op">;</span></span>
<span id="cb20-17"><a href="#cb20-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_handler_with_state(</span>
<span id="cb20-18"><a href="#cb20-18" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span>path<span class="op">,</span></span>
<span id="cb20-19"><a href="#cb20-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">move</span> <span class="op">|</span>State(s)<span class="op">:</span> State<span class="op">&lt;</span>Arc<span class="op">&lt;</span>StreamingState<span class="op">&gt;&gt;|</span> <span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb20-20"><a href="#cb20-20" aria-hidden="true" tabindex="-1"></a> stream_flac(s<span class="op">.</span>manager<span class="op">.</span>clone()<span class="op">,</span> descriptor<span class="op">.</span>id)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb20-21"><a href="#cb20-21" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
<span id="cb20-22"><a href="#cb20-22" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb20-23"><a href="#cb20-23" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb20-24"><a href="#cb20-24" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb20-25"><a href="#cb20-25" aria-hidden="true" tabindex="-1"></a> <span class="co">// Route streaming OGG</span></span>
<span id="cb20-26"><a href="#cb20-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> path <span class="op">=</span> <span class="pp">format!</span>(<span class="st">&quot;/stream/{}/ogg&quot;</span><span class="op">,</span> slug)<span class="op">;</span></span>
<span id="cb20-27"><a href="#cb20-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_handler_with_state(</span>
<span id="cb20-28"><a href="#cb20-28" aria-hidden="true" tabindex="-1"></a> <span class="op">&amp;</span>path<span class="op">,</span></span>
<span id="cb20-29"><a href="#cb20-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">move</span> <span class="op">|</span>State(s)<span class="op">:</span> State<span class="op">&lt;</span>Arc<span class="op">&lt;</span>StreamingState<span class="op">&gt;&gt;|</span> <span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb20-30"><a href="#cb20-30" aria-hidden="true" tabindex="-1"></a> stream_ogg(s<span class="op">.</span>manager<span class="op">.</span>clone()<span class="op">,</span> descriptor<span class="op">.</span>id)<span class="op">.</span><span class="kw">await</span></span>
<span id="cb20-31"><a href="#cb20-31" aria-hidden="true" tabindex="-1"></a> <span class="op">},</span></span>
<span id="cb20-32"><a href="#cb20-32" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>clone()<span class="op">,</span></span>
<span id="cb20-33"><a href="#cb20-33" aria-hidden="true" tabindex="-1"></a> )<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb20-34"><a href="#cb20-34" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb20-35"><a href="#cb20-35" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb20-36"><a href="#cb20-36" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(manager)</span>
<span id="cb20-37"><a href="#cb20-37" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb20-38"><a href="#cb20-38" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="gestion-des-opérations-longues">Gestion des opérations
longues</h2>
<h3 id="utiliser-spawn_blocking-pour-le-code-synchrone">Utiliser
<code>spawn_blocking</code> pour le code synchrone</h3>
<p>Pour éviter de bloquer le runtime Tokio avec du code synchrone :</p>
<div class="sourceCode" id="cb21"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb21-1"><a href="#cb21-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_renderers(</span>
<span id="cb21-2"><a href="#cb21-2" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>ControlPointState<span class="op">&gt;</span></span>
<span id="cb21-3"><a href="#cb21-3" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> Json<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span>Summary<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb21-4"><a href="#cb21-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> control_point <span class="op">=</span> state<span class="op">.</span>control_point<span class="op">.</span>clone()<span class="op">;</span></span>
<span id="cb21-5"><a href="#cb21-5" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb21-6"><a href="#cb21-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> summaries <span class="op">=</span> <span class="pp">tokio::task::</span>spawn_blocking(<span class="kw">move</span> <span class="op">||</span> <span class="op">{</span></span>
<span id="cb21-7"><a href="#cb21-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> renderers <span class="op">=</span> control_point<span class="op">.</span>list_music_renderers()<span class="op">;</span></span>
<span id="cb21-8"><a href="#cb21-8" aria-hidden="true" tabindex="-1"></a> renderers<span class="op">.</span>into_iter()</span>
<span id="cb21-9"><a href="#cb21-9" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map(<span class="op">|</span>r<span class="op">|</span> <span class="pp">Summary::</span>from(<span class="op">&amp;</span>r))</span>
<span id="cb21-10"><a href="#cb21-10" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>collect()</span>
<span id="cb21-11"><a href="#cb21-11" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb21-12"><a href="#cb21-12" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb21-13"><a href="#cb21-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_default()<span class="op">;</span></span>
<span id="cb21-14"><a href="#cb21-14" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb21-15"><a href="#cb21-15" aria-hidden="true" tabindex="-1"></a> Json(summaries)</span>
<span id="cb21-16"><a href="#cb21-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="ajouter-des-timeouts-pour-les-opérations-réseau">Ajouter des
timeouts pour les opérations réseau</h3>
<div class="sourceCode" id="cb22"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb22-1"><a href="#cb22-1" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> COMMAND_TIMEOUT<span class="op">:</span> Duration <span class="op">=</span> <span class="pp">Duration::</span>from_secs(<span class="dv">5</span>)<span class="op">;</span></span>
<span id="cb22-2"><a href="#cb22-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb22-3"><a href="#cb22-3" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> play_renderer(</span>
<span id="cb22-4"><a href="#cb22-4" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>ControlPointState<span class="op">&gt;,</span></span>
<span id="cb22-5"><a href="#cb22-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">Path</span>(id)<span class="op">:</span> <span class="dt">Path</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb22-6"><a href="#cb22-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Json<span class="op">&lt;</span>Response<span class="op">&gt;,</span> (StatusCode<span class="op">,</span> Json<span class="op">&lt;</span><span class="bu">Error</span><span class="op">&gt;</span>)<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb22-7"><a href="#cb22-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> renderer <span class="op">=</span> state<span class="op">.</span>get_renderer(<span class="op">&amp;</span>id)</span>
<span id="cb22-8"><a href="#cb22-8" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>ok_or((<span class="pp">StatusCode::</span>NOT_FOUND<span class="op">,</span> Json(<span class="bu">Error</span><span class="pp">::</span>not_found())))<span class="op">?;</span></span>
<span id="cb22-9"><a href="#cb22-9" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb22-10"><a href="#cb22-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> play_task <span class="op">=</span> <span class="pp">tokio::task::</span>spawn_blocking(<span class="kw">move</span> <span class="op">||</span> renderer<span class="op">.</span>play())<span class="op">;</span></span>
<span id="cb22-11"><a href="#cb22-11" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb22-12"><a href="#cb22-12" aria-hidden="true" tabindex="-1"></a> <span class="pp">time::</span>timeout(COMMAND_TIMEOUT<span class="op">,</span> play_task)</span>
<span id="cb22-13"><a href="#cb22-13" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span><span class="kw">await</span></span>
<span id="cb22-14"><a href="#cb22-14" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>_<span class="op">|</span> (</span>
<span id="cb22-15"><a href="#cb22-15" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>GATEWAY_TIMEOUT<span class="op">,</span></span>
<span id="cb22-16"><a href="#cb22-16" aria-hidden="true" tabindex="-1"></a> Json(<span class="bu">Error</span><span class="pp">::</span>timeout())</span>
<span id="cb22-17"><a href="#cb22-17" aria-hidden="true" tabindex="-1"></a> ))<span class="op">?</span></span>
<span id="cb22-18"><a href="#cb22-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> (</span>
<span id="cb22-19"><a href="#cb22-19" aria-hidden="true" tabindex="-1"></a> <span class="pp">StatusCode::</span>INTERNAL_SERVER_ERROR<span class="op">,</span></span>
<span id="cb22-20"><a href="#cb22-20" aria-hidden="true" tabindex="-1"></a> Json(<span class="bu">Error</span><span class="pp">::</span>internal(e))</span>
<span id="cb22-21"><a href="#cb22-21" aria-hidden="true" tabindex="-1"></a> ))<span class="op">??;</span></span>
<span id="cb22-22"><a href="#cb22-22" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb22-23"><a href="#cb22-23" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(Json(<span class="pp">Response::</span>success()))</span>
<span id="cb22-24"><a href="#cb22-24" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="utiliser-spawn-pour-les-tâches-en-arrière-plan">Utiliser
<code>spawn</code> pour les tâches en arrière-plan</h3>
<p>Pour les opérations qui ne nécessitent pas dattendre le résultat
:</p>
<div class="sourceCode" id="cb23"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb23-1"><a href="#cb23-1" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> trigger_action(</span>
<span id="cb23-2"><a href="#cb23-2" aria-hidden="true" tabindex="-1"></a> State(state)<span class="op">:</span> State<span class="op">&lt;</span>XxxState<span class="op">&gt;,</span></span>
<span id="cb23-3"><a href="#cb23-3" aria-hidden="true" tabindex="-1"></a> Json(req)<span class="op">:</span> Json<span class="op">&lt;</span>Request<span class="op">&gt;,</span></span>
<span id="cb23-4"><a href="#cb23-4" aria-hidden="true" tabindex="-1"></a>) <span class="op">-&gt;</span> Json<span class="op">&lt;</span>Response<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb23-5"><a href="#cb23-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Valider la requête</span></span>
<span id="cb23-6"><a href="#cb23-6" aria-hidden="true" tabindex="-1"></a> state<span class="op">.</span>validate(<span class="op">&amp;</span>req)<span class="op">?;</span></span>
<span id="cb23-7"><a href="#cb23-7" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb23-8"><a href="#cb23-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Lancer l&#39;action en arrière-plan</span></span>
<span id="cb23-9"><a href="#cb23-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state_clone <span class="op">=</span> state<span class="op">.</span>clone()<span class="op">;</span></span>
<span id="cb23-10"><a href="#cb23-10" aria-hidden="true" tabindex="-1"></a> <span class="pp">tokio::task::</span>spawn(<span class="kw">async</span> <span class="kw">move</span> <span class="op">{</span></span>
<span id="cb23-11"><a href="#cb23-11" aria-hidden="true" tabindex="-1"></a> <span class="cf">match</span> state_clone<span class="op">.</span>perform_action(req)<span class="op">.</span><span class="kw">await</span> <span class="op">{</span></span>
<span id="cb23-12"><a href="#cb23-12" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(_) <span class="op">=&gt;</span> <span class="pp">debug!</span>(<span class="st">&quot;Action completed&quot;</span>)<span class="op">,</span></span>
<span id="cb23-13"><a href="#cb23-13" aria-hidden="true" tabindex="-1"></a> <span class="cn">Err</span>(e) <span class="op">=&gt;</span> <span class="pp">warn!</span>(<span class="st">&quot;Action failed: {}&quot;</span><span class="op">,</span> e)<span class="op">,</span></span>
<span id="cb23-14"><a href="#cb23-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb23-15"><a href="#cb23-15" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)<span class="op">;</span></span>
<span id="cb23-16"><a href="#cb23-16" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb23-17"><a href="#cb23-17" aria-hidden="true" tabindex="-1"></a> <span class="co">// Retourner immédiatement</span></span>
<span id="cb23-18"><a href="#cb23-18" aria-hidden="true" tabindex="-1"></a> Json(<span class="pp">Response::</span>accepted())</span>
<span id="cb23-19"><a href="#cb23-19" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="checklist-dimplémentation">Checklist dimplémentation</h2>
<h3 id="configuration-de-base-1">Configuration de base</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Créer le module
<code>pmoserver_ext.rs</code> avec
<code>#[cfg(feature = "pmoserver")]</code></label></li>
<li><label><input type="checkbox" />Ajouter la feature
<code>pmoserver</code> dans <code>Cargo.toml</code> avec dépendances
optionnelles</label></li>
<li><label><input type="checkbox" />Re-exporter le trait dans
<code>lib.rs</code></label></li>
</ul>
<h3 id="définition-du-trait">Définition du trait</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Définir le trait
<code>{Domaine}Ext</code> avec méthode <code>init_*</code></label></li>
<li><label><input type="checkbox" />Créer la structure
<code>{Domaine}State</code> avec
<code>#[derive(Clone)]</code></label></li>
<li><label><input type="checkbox" />Implémenter le trait pour
<code>pmoserver::Server</code></label></li>
</ul>
<h3 id="documentation-openapi">Documentation OpenAPI</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Ajouter <code>utoipa</code> dans les
dépendances</label></li>
<li><label><input type="checkbox" />Définir les schémas de
réponse/requête avec <code>#[derive(ToSchema)]</code></label></li>
<li><label><input type="checkbox" />Ajouter des exemples avec
<code>#[schema(example = "...")]</code></label></li>
<li><label><input type="checkbox" />Annoter chaque handler avec
<code>#[utoipa::path(...)]</code></label></li>
<li><label><input type="checkbox" />Créer la structure
<code>#[derive(OpenApi)]</code> avec documentation complète</label></li>
<li><label><input type="checkbox" />Lister tous les paths et schemas
dans <code>#[openapi(...)]</code></label></li>
</ul>
<h3 id="handlers-et-routes">Handlers et routes</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Créer les handlers avec les
extracteurs Axum appropriés</label></li>
<li><label><input type="checkbox" />Gérer les erreurs avec des codes
HTTP sémantiques</label></li>
<li><label><input type="checkbox" />Créer le router et lenregistrer
avec <code>add_openapi()</code></label></li>
<li><label><input type="checkbox" />Ajouter des logs (debug, info, warn,
error)</label></li>
</ul>
<h3 id="performance-et-robustesse">Performance et robustesse</h3>
<ul class="task-list">
<li><label><input type="checkbox" />Utiliser <code>spawn_blocking</code>
pour le code synchrone</label></li>
<li><label><input type="checkbox" />Ajouter des timeouts pour les
opérations réseau</label></li>
<li><label><input type="checkbox" />Utiliser <code>spawn</code> pour les
tâches en arrière-plan si nécessaire</label></li>
</ul>
<h2 id="exemple-complet-minimal">Exemple complet minimal</h2>
<div class="sourceCode" id="cb24"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb24-1"><a href="#cb24-1" aria-hidden="true" tabindex="-1"></a><span class="co">// pmoexample/src/pmoserver_ext.rs</span></span>
<span id="cb24-2"><a href="#cb24-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-3"><a href="#cb24-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-4"><a href="#cb24-4" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">async_trait::</span>async_trait<span class="op">;</span></span>
<span id="cb24-5"><a href="#cb24-5" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-6"><a href="#cb24-6" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">axum::</span><span class="op">{</span>Router<span class="op">,</span> <span class="pp">routing::</span>get<span class="op">,</span> Json<span class="op">,</span> <span class="pp">extract::</span>State<span class="op">};</span></span>
<span id="cb24-7"><a href="#cb24-7" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-8"><a href="#cb24-8" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::sync::</span>Arc<span class="op">;</span></span>
<span id="cb24-9"><a href="#cb24-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-10"><a href="#cb24-10" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="kw">crate</span><span class="pp">::</span>ExampleResource<span class="op">;</span></span>
<span id="cb24-11"><a href="#cb24-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-12"><a href="#cb24-12" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-13"><a href="#cb24-13" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Clone</span><span class="at">)]</span></span>
<span id="cb24-14"><a href="#cb24-14" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ExampleState <span class="op">{</span></span>
<span id="cb24-15"><a href="#cb24-15" aria-hidden="true" tabindex="-1"></a> resource<span class="op">:</span> Arc<span class="op">&lt;</span>ExampleResource<span class="op">&gt;,</span></span>
<span id="cb24-16"><a href="#cb24-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb24-17"><a href="#cb24-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-18"><a href="#cb24-18" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-19"><a href="#cb24-19" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb24-20"><a href="#cb24-20" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> ExampleExt <span class="op">{</span></span>
<span id="cb24-21"><a href="#cb24-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_example(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>ExampleResource<span class="op">&gt;&gt;;</span></span>
<span id="cb24-22"><a href="#cb24-22" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb24-23"><a href="#cb24-23" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-24"><a href="#cb24-24" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-25"><a href="#cb24-25" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>async_trait<span class="at">]</span></span>
<span id="cb24-26"><a href="#cb24-26" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> ExampleExt <span class="cf">for</span> <span class="pp">pmoserver::</span>Server <span class="op">{</span></span>
<span id="cb24-27"><a href="#cb24-27" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> <span class="kw">fn</span> init_example(<span class="op">&amp;</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span>ExampleResource<span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb24-28"><a href="#cb24-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> resource <span class="op">=</span> <span class="pp">Arc::</span>new(<span class="pp">ExampleResource::</span>new())<span class="op">;</span></span>
<span id="cb24-29"><a href="#cb24-29" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> state <span class="op">=</span> ExampleState <span class="op">{</span> resource<span class="op">:</span> resource<span class="op">.</span>clone() <span class="op">};</span></span>
<span id="cb24-30"><a href="#cb24-30" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb24-31"><a href="#cb24-31" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> router <span class="op">=</span> <span class="pp">Router::</span>new()</span>
<span id="cb24-32"><a href="#cb24-32" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>route(<span class="st">&quot;/items&quot;</span><span class="op">,</span> get(list_items))</span>
<span id="cb24-33"><a href="#cb24-33" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>with_state(state)<span class="op">;</span></span>
<span id="cb24-34"><a href="#cb24-34" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb24-35"><a href="#cb24-35" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>add_router(<span class="st">&quot;/api/example&quot;</span><span class="op">,</span> router)<span class="op">.</span><span class="kw">await</span><span class="op">;</span></span>
<span id="cb24-36"><a href="#cb24-36" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb24-37"><a href="#cb24-37" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(resource)</span>
<span id="cb24-38"><a href="#cb24-38" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb24-39"><a href="#cb24-39" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb24-40"><a href="#cb24-40" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb24-41"><a href="#cb24-41" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>cfg<span class="at">(</span>feature <span class="op">=</span> <span class="st">&quot;pmoserver&quot;</span><span class="at">)]</span></span>
<span id="cb24-42"><a href="#cb24-42" aria-hidden="true" tabindex="-1"></a><span class="kw">async</span> <span class="kw">fn</span> list_items(State(state)<span class="op">:</span> State<span class="op">&lt;</span>ExampleState<span class="op">&gt;</span>) <span class="op">-&gt;</span> Json<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;&gt;</span> <span class="op">{</span></span>
<span id="cb24-43"><a href="#cb24-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> items <span class="op">=</span> state<span class="op">.</span>resource<span class="op">.</span>list()<span class="op">;</span></span>
<span id="cb24-44"><a href="#cb24-44" aria-hidden="true" tabindex="-1"></a> Json(items)</span>
<span id="cb24-45"><a href="#cb24-45" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h2 id="références">Références</h2>
<h3 id="exemples-dans-le-codebase">Exemples dans le codebase</h3>
<table>
<colgroup>
<col style="width: 28%" />
<col style="width: 36%" />
<col style="width: 36%" />
</colgroup>
<thead>
<tr>
<th>Crate</th>
<th>Fichier</th>
<th>Pattern</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>pmoparadise</code></td>
<td><code>src/pmoserver_ext.rs:367-392</code></td>
<td>Extension simple avec OpenAPI</td>
</tr>
<tr>
<td><code>pmoaudiocache</code></td>
<td><code>src/lib.rs:225-260</code></td>
<td>Extension avec cache et fichiers</td>
</tr>
<tr>
<td><code>pmomediaserver</code></td>
<td><code>src/paradise_streaming.rs:70-148</code></td>
<td>Extension avec routes dynamiques</td>
</tr>
<tr>
<td><code>pmocontrol</code></td>
<td><code>src/pmoserver_ext.rs:68-92</code></td>
<td>Handlers avec <code>spawn_blocking</code></td>
</tr>
<tr>
<td><code>pmoapp</code></td>
<td><code>src/lib.rs:145-165</code></td>
<td>Extension SPA avec RustEmbed</td>
</tr>
</tbody>
</table>
<h3 id="dépendances-communes">Dépendances communes</h3>
<ul>
<li><code>axum</code> : Framework HTTP (Router, handlers,
extracteurs)</li>
<li><code>async-trait</code> : Support des traits async</li>
<li><code>tokio</code> : Runtime async (spawn, spawn_blocking,
timeout)</li>
<li><code>anyhow</code> : Gestion derreurs pour init</li>
<li><code>tracing</code> : Logging structuré</li>
<li><code>utoipa</code> : Documentation OpenAPI/Swagger</li>
<li><code>serde</code> : Sérialisation JSON</li>
</ul>
</article>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,209 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WeabApp_debouncingSSE</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="rapport-suppression-de-la-logique-de-débouncing-sse">Rapport :
Suppression de la logique de débouncing SSE</h1>
<p><strong>Date</strong>: 2026-01-12 <strong>Tâche</strong>:
WeabApp_debouncingSSE.md</p>
<h2 id="objectif">Objectif</h2>
<p>Supprimer la logique de débouncing inutile sur le canal SSE de
lapplication web PMOControl, puisque le serveur contrôle déjà le flux
des événements.</p>
<h2 id="analyse-préalable">Analyse préalable</h2>
<p>Jai identifié trois endroits avec des mécanismes de temporisation
dans lapplication web :</p>
<h3 id="mediabrowser.vue---débouncing-sse-à-supprimer">1.
MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)</h3>
<ul>
<li><strong>Débouncing</strong>: 200ms après invalidation du cache</li>
<li><strong>Cooldown</strong>: 2 secondes entre les rechargements</li>
<li><strong>Justification originale</strong>: “dédupliquer les
événements SSE dans le même batch (polling 500ms)”</li>
<li><strong>Problème</strong>: Cette logique est redondante puisque le
serveur contrôle déjà le flux SSE</li>
</ul>
<h3 id="userenderers.ts---smart-fetching-à-conserver">2. useRenderers.ts
- Smart fetching (À CONSERVER ✓)</h3>
<ul>
<li><strong>Mécanisme</strong>: Comparaison des timestamps
<code>lastEventAt</code> vs <code>lastSnapshotAt</code></li>
<li><strong>But</strong>: Éviter de refetch un snapshot déjà à jour</li>
<li><strong>Justification</strong>: Ce nest PAS du débouncing, cest
une optimisation intelligente qui évite des appels API inutiles</li>
</ul>
<h3 id="volumecontrol.vue---ui-debouncing-à-conserver">3.
VolumeControl.vue - UI debouncing (À CONSERVER ✓)</h3>
<ul>
<li><strong>Débouncing</strong>: 300ms sur les changements de
volume</li>
<li><strong>But</strong>: Réduire les appels API pendant que
lutilisateur fait glisser le curseur</li>
<li><strong>Justification</strong>: Débouncing légitime pour linterface
utilisateur</li>
</ul>
<h2 id="modifications-effectuées">Modifications effectuées</h2>
<h3
id="fichier-modifié-pmoappwebappsrccomponentspmocontrolmediabrowser.vue">Fichier
modifié:
<code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></h3>
<h4 id="suppression-des-variables-de-débouncing-ligne-27">1. Suppression
des variables de débouncing (ligne ~27)</h4>
<p><strong>Avant</strong>:</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flags pour gérer le rechargement automatique avec debounce et cooldown</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> refreshTimeoutId <span class="op">=</span> <span class="fu">ref</span><span class="op">&lt;</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">&gt;</span>(<span class="kw">null</span>)<span class="op">;</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> lastRefreshTime <span class="op">=</span> <span class="fu">ref</span><span class="op">&lt;</span><span class="dt">number</span><span class="op">&gt;</span>(<span class="dv">0</span>)<span class="op">;</span></span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> REFRESH_COOLDOWN_MS <span class="op">=</span> <span class="dv">2000</span><span class="op">;</span> <span class="co">// Ne pas recharger plus d&#39;une fois toutes les 2 secondes</span></span></pre></div>
<p><strong>Après</strong>:</p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flag pour gérer le rechargement automatique</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span></pre></div>
<h4 id="simplification-du-watcher-de-cache-ligne-53">2. Simplification
du watcher de cache (ligne ~53)</h4>
<p><strong>Avant</strong>:</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur &quot;Lire maintenant&quot; sur une playlist,</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="co">// et un cooldown de 5 secondes pour éviter les rechargements successifs</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=&gt;</span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> (data) <span class="kw">=&gt;</span> {</span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&amp;&amp;</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes</span></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> timeSinceLastRefresh <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>() <span class="op">-</span> lastRefreshTime<span class="op">.</span><span class="at">value</span><span class="op">;</span></span>
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (timeSinceLastRefresh <span class="op">&lt;</span> REFRESH_COOLDOWN_MS) {</span>
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé mais cooldown actif (</span><span class="sc">${</span><span class="bu">Math</span><span class="op">.</span><span class="fu">round</span>((REFRESH_COOLDOWN_MS <span class="op">-</span> timeSinceLastRefresh) <span class="op">/</span> <span class="dv">1000</span>)<span class="sc">}</span><span class="vs">s restantes), rechargement ignoré`</span><span class="op">,</span></span>
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span><span class="op">;</span></span>
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Annuler tout timeout en cours</span></span>
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">!==</span> <span class="kw">null</span>) {</span>
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">clearTimeout</span>(refreshTimeoutId<span class="op">.</span><span class="at">value</span>)<span class="op">;</span></span>
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Planifier le rechargement après 200ms</span></span>
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">window</span><span class="op">.</span><span class="fu">setTimeout</span>(<span class="kw">async</span> () <span class="kw">=&gt;</span> {</span>
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb3-27"><a href="#cb3-27" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb3-28"><a href="#cb3-28" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement après debounce...`</span><span class="op">,</span></span>
<span id="cb3-29"><a href="#cb3-29" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-30"><a href="#cb3-30" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
<span id="cb3-31"><a href="#cb3-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(</span>
<span id="cb3-32"><a href="#cb3-32" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span></span>
<span id="cb3-33"><a href="#cb3-33" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span></span>
<span id="cb3-34"><a href="#cb3-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">false</span><span class="op">,</span></span>
<span id="cb3-35"><a href="#cb3-35" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-36"><a href="#cb3-36" aria-hidden="true" tabindex="-1"></a> lastRefreshTime<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>()<span class="op">;</span></span>
<span id="cb3-37"><a href="#cb3-37" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
<span id="cb3-38"><a href="#cb3-38" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">null</span><span class="op">;</span></span>
<span id="cb3-39"><a href="#cb3-39" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-40"><a href="#cb3-40" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span> <span class="dv">200</span>)<span class="op">;</span></span>
<span id="cb3-41"><a href="#cb3-41" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-42"><a href="#cb3-42" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
<span id="cb3-43"><a href="#cb3-43" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
<p><strong>Après</strong>:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur &quot;Lire maintenant&quot; sur une playlist,</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=&gt;</span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> (data) <span class="kw">=&gt;</span> {</span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Si browseData devient undefined alors que containerId est présent,</span></span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// et qu&#39;on n&#39;est pas déjà en train de charger, recharger immédiatement</span></span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&amp;&amp;</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement...`</span><span class="op">,</span></span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span> <span class="kw">false</span>)<span class="op">;</span></span>
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
<h2 id="résultats">Résultats</h2>
<h3 id="changements-de-comportement">Changements de comportement</h3>
<ul>
<li><strong>Avant</strong>: Délai de 200ms + cooldown de 2s entre les
rechargements de cache</li>
<li><strong>Après</strong>: Rechargement immédiat dès linvalidation du
cache</li>
<li><strong>Impact</strong>: Réactivité améliorée de linterface, les
mises à jour apparaissent immédiatement</li>
</ul>
<h3 id="réduction-de-complexité">Réduction de complexité</h3>
<ul>
<li><strong>3 variables supprimées</strong>:
<code>refreshTimeoutId</code>, <code>lastRefreshTime</code>,
<code>REFRESH_COOLDOWN_MS</code></li>
<li><strong>Logique simplifiée</strong>: De ~40 lignes à ~10 lignes dans
le watcher</li>
<li><strong>Code plus lisible</strong>: Intention claire sans mécanismes
de temporisation complexes</li>
</ul>
<h3 id="tests">Tests</h3>
<ul>
<li>✓ Le projet compile sans erreurs TypeScript</li>
<li>✓ Le flag <code>isRefreshing</code> empêche toujours les
rechargements concurrents</li>
<li>✓ Les autres composants (useRenderers.ts, VolumeControl.vue)
conservent leurs optimisations légitimes</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>La suppression du débouncing et du cooldown dans MediaBrowser.vue
simplifie le code tout en améliorant la réactivité de linterface.
Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client
étaient redondants et ajoutaient une latence artificielle.</p>
<p>Le code est maintenant plus simple, plus réactif, et fait confiance
au serveur pour contrôler la fréquence des événements SSE.</p>
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
<ul>
<li><code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></li>
</ul>
<h2 id="lignes-de-code">Lignes de code</h2>
<ul>
<li><strong>Supprimées</strong>: ~35 lignes (logique de
débouncing/cooldown)</li>
<li><strong>Ajoutées</strong>: ~5 lignes (logique simplifiée)</li>
<li><strong>Net</strong>: -30 lignes</li>
</ul>
</article>
</body>
</html>

View File

@@ -1,518 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinnable_cache_item</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1
id="rapport-implémentation-des-items-épinglables-dans-pmocache">Rapport
: Implémentation des items épinglables dans PMOcache</h1>
<h2 id="résumé">Résumé</h2>
<p>Implémentation réussie de la fonctionnalité ditems épinglables dans
la crate PMOcache, permettant de protéger certains items de léviction
automatique par la politique LRU. Cette fonctionnalité inclut également
un système de TTL (Time To Live) avec une règle métier empêchant quun
item soit à la fois épinglé et avec un TTL.</p>
<h2 id="modifications-apportées">Modifications apportées</h2>
<h3 id="structure-de-la-base-de-données-pmocachesrcdb.rs">1. Structure
de la base de données (<code>pmocache/src/db.rs</code>)</h3>
<h4 id="modification-du-schéma-de-la-table-asset">Modification du schéma
de la table <code>asset</code></h4>
<p>Ajout de deux nouvelles colonnes :</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode sql"><code class="sourceCode sql"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="kw">CREATE</span> <span class="kw">TABLE</span> <span class="cf">IF</span> <span class="kw">NOT</span> <span class="kw">EXISTS</span> asset (</span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> pk TEXT <span class="kw">PRIMARY</span> <span class="kw">KEY</span>,</span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> collection TEXT,</span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">id</span> TEXT,</span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> hits <span class="dt">INTEGER</span> <span class="kw">DEFAULT</span> <span class="dv">0</span>,</span>
<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a> last_used TEXT,</span>
<span id="cb1-7"><a href="#cb1-7" aria-hidden="true" tabindex="-1"></a> lazy_pk TEXT,</span>
<span id="cb1-8"><a href="#cb1-8" aria-hidden="true" tabindex="-1"></a> pinned <span class="dt">INTEGER</span> <span class="kw">DEFAULT</span> <span class="dv">0</span> <span class="kw">CHECK</span> (pinned <span class="kw">IN</span> (<span class="dv">0</span>, <span class="dv">1</span>)),</span>
<span id="cb1-9"><a href="#cb1-9" aria-hidden="true" tabindex="-1"></a> ttl_expires_at TEXT</span>
<span id="cb1-10"><a href="#cb1-10" aria-hidden="true" tabindex="-1"></a>)</span></pre></div>
<ul>
<li><strong><code>pinned</code></strong> : Booléen (0 ou 1) indiquant si
litem est épinglé</li>
<li><strong><code>ttl_expires_at</code></strong> : Date/heure
dexpiration au format RFC3339 (optionnel)</li>
</ul>
<h4 id="mise-à-jour-de-la-structure-cacheentry">Mise à jour de la
structure <code>CacheEntry</code></h4>
<p>Ajout des champs correspondants :</p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> CacheEntry <span class="op">{</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a> <span class="co">// ... champs existants ...</span></span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> pinned<span class="op">:</span> <span class="dt">bool</span><span class="op">,</span></span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> ttl_expires_at<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// ...</span></span>
<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="nouvelles-méthodes-dans-db">Nouvelles méthodes dans
<code>DB</code></h4>
<h5 id="gestion-du-comptage">Gestion du comptage</h5>
<ul>
<li><strong><code>count_unpinned()</code></strong> : Compte uniquement
les items non épinglés
<ul>
<li>Les items épinglés ne comptent pas dans la limite du cache</li>
</ul></li>
</ul>
<h5 id="gestion-du-pinning">Gestion du pinning</h5>
<ul>
<li><p><strong><code>pin(pk: &amp;str)</code></strong> : Épingle un
item</p>
<ul>
<li>Vérifie que litem na pas de TTL défini (règle métier)</li>
<li>Retourne une erreur si le TTL est déjà défini</li>
</ul></li>
<li><p><strong><code>unpin(pk: &amp;str)</code></strong> : Désépingle un
item</p></li>
<li><p><strong><code>is_pinned(pk: &amp;str)</code></strong> : Vérifie
si un item est épinglé</p></li>
</ul>
<h5 id="gestion-du-ttl">Gestion du TTL</h5>
<ul>
<li><p><strong><code>set_ttl(pk: &amp;str, expires_at: &amp;str)</code></strong>
: Définit le TTL dun item</p>
<ul>
<li>Vérifie que litem nest pas épinglé (règle métier)</li>
<li>Retourne une erreur si litem est épinglé</li>
</ul></li>
<li><p><strong><code>clear_ttl(pk: &amp;str)</code></strong> : Supprime
le TTL dun item</p></li>
<li><p><strong><code>get_expired()</code></strong> : Récupère tous les
items dont le TTL est dépassé</p></li>
</ul>
<h5 id="modification-de-get_oldest">Modification de
<code>get_oldest()</code></h5>
<p>La requête SQL exclut maintenant les items épinglés :</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode sql"><code class="sourceCode sql"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="op">..</span>. <span class="kw">FROM</span> asset</span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> pinned <span class="op">=</span> <span class="dv">0</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="kw">ORDER</span> <span class="kw">BY</span> last_used <span class="kw">ASC</span>, hits <span class="kw">ASC</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="kw">LIMIT</span> ?<span class="dv">1</span></span></pre></div>
<h3 id="logique-du-cache-pmocachesrccache.rs">2. Logique du cache
(<code>pmocache/src/cache.rs</code>)</h3>
<h4 id="méthodes-publiques-ajoutées">Méthodes publiques ajoutées</h4>
<div class="sourceCode" id="cb4"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> pin(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> unpin(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> is_pinned(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">bool</span><span class="op">&gt;</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> set_ttl(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span><span class="op">,</span> expires_at<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">async</span> <span class="kw">fn</span> clear_ttl(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span></span></pre></div>
<h4 id="modification-de-enforce_limit">Modification de
<code>enforce_limit()</code></h4>
<p>La politique déviction a été améliorée :</p>
<ol type="1">
<li><strong>Suppression prioritaire des items expirés</strong> : Les
items dont le TTL est dépassé sont supprimés en premier</li>
<li><strong>Comptage des items non épinglés</strong> : Utilise
<code>count_unpinned()</code> au lieu de <code>count()</code></li>
<li><strong>Protection des items épinglés</strong> : Ils ne peuvent pas
être évincés par LRU</li>
<li><strong>Logging amélioré</strong> : Messages distincts pour les
items expirés et léviction LRU</li>
</ol>
<h3 id="tests-pmocacheteststest_pinnable.rs">3. Tests
(<code>pmocache/tests/test_pinnable.rs</code>)</h3>
<p>Création dune suite complète de tests (9 tests, tous passants) :</p>
<ol type="1">
<li><strong><code>test_pin_unpin</code></strong> : Vérifie lépinglage
et le désépinglage basiques</li>
<li><strong><code>test_pinned_excluded_from_lru</code></strong> :
Vérifie que les items épinglés ne sont pas évincés</li>
<li><strong><code>test_pinned_count_separately</code></strong> : Vérifie
le comptage séparé des items épinglés</li>
<li><strong><code>test_cannot_pin_with_ttl</code></strong> : Vérifie la
règle métier TTL → pas de pinning</li>
<li><strong><code>test_cannot_set_ttl_when_pinned</code></strong> :
Vérifie la règle métier pinned → pas de TTL</li>
<li><strong><code>test_ttl_expiration</code></strong> : Vérifie la
suppression automatique des items expirés</li>
<li><strong><code>test_clear_ttl</code></strong> : Vérifie la
suppression du TTL</li>
<li><strong><code>test_get_expired</code></strong> : Vérifie la
récupération des items expirés</li>
<li><strong><code>test_cache_entry_fields</code></strong> : Vérifie les
valeurs des champs dans <code>CacheEntry</code></li>
</ol>
<h2 id="règles-métier-implémentées">Règles métier implémentées</h2>
<h3 id="incompatibilité-ttl-pinned">Incompatibilité TTL ↔︎ Pinned</h3>
<p>Un item ne peut pas être à la fois épinglé ET avoir un TTL :</p>
<ul>
<li><strong>Si TTL défini</strong> : <code>pin()</code> retourne une
erreur</li>
<li><strong>Si épinglé</strong> : <code>set_ttl()</code> retourne une
erreur</li>
</ul>
<p>Cette règle garantit une sémantique claire : -
<strong>Épinglé</strong> = permanent, protégé de léviction -
<strong>TTL</strong> = temporaire, sera supprimé à expiration</p>
<h3 id="comptage-des-items">Comptage des items</h3>
<p>Les items épinglés sont <strong>exclus</strong> du comptage de la
limite du cache :</p>
<ul>
<li>Un cache de limite 100 peut contenir 100 items non épinglés + N
items épinglés</li>
<li>Seuls les items non épinglés sont pris en compte pour léviction
LRU</li>
</ul>
<h3 id="ordre-de-suppression-lors-de-enforce_limit">Ordre de suppression
lors de <code>enforce_limit()</code></h3>
<ol type="1">
<li><strong>Items expirés (TTL dépassé)</strong> : supprimés en
priorité</li>
<li><strong>Items LRU</strong> : si la limite est toujours dépassée,
suppression des plus vieux items <strong>non épinglés</strong></li>
</ol>
<h2 id="compatibilité">Compatibilité</h2>
<h3 id="migration-de-base-de-données">Migration de base de données</h3>
<p><strong>Aucune migration nécessaire</strong> : Les colonnes
<code>pinned</code> et <code>ttl_expires_at</code> ont des valeurs par
défaut : - <code>pinned = 0</code> (non épinglé) -
<code>ttl_expires_at = NULL</code> (pas de TTL)</p>
<p>Les bases existantes seront automatiquement mises à jour au prochain
démarrage via le <code>CREATE TABLE IF NOT EXISTS</code> avec les
nouvelles colonnes.</p>
<h3 id="rétrocompatibilité-du-code">Rétrocompatibilité du code</h3>
<p>Toutes les méthodes existantes continuent de fonctionner sans
modification : - Les items existants ne sont pas épinglés par défaut -
Le comportement LRU standard reste identique pour les items non
épinglés</p>
<h2 id="exemples-dutilisation">Exemples dutilisation</h2>
<h3 id="utilisation-programmatique-rust">Utilisation programmatique
(Rust)</h3>
<div class="sourceCode" id="cb5"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmocache::</span><span class="op">{</span>Cache<span class="op">,</span> CacheConfig<span class="op">};</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">chrono::</span><span class="op">{</span>Duration<span class="op">,</span> Utc<span class="op">};</span></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Créer un cache</span></span>
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> cache <span class="op">=</span> <span class="pp">Cache::</span><span class="op">&lt;</span>MyConfig<span class="op">&gt;</span><span class="pp">::</span>new(<span class="st">&quot;./cache&quot;</span><span class="op">,</span> <span class="dv">100</span>)<span class="op">.</span>unwrap()<span class="op">;</span></span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a><span class="co">// Ajouter un fichier</span></span>
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> pk <span class="op">=</span> cache<span class="op">.</span>add_from_url(<span class="st">&quot;https://example.com/file.dat&quot;</span><span class="op">,</span> <span class="cn">None</span>)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="co">// Épingler pour protéger de l&#39;éviction</span></span>
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a>cache<span class="op">.</span>pin(<span class="op">&amp;</span>pk)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a><span class="co">// Ou définir un TTL de 24 heures</span></span>
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> expires_at <span class="op">=</span> (<span class="pp">Utc::</span>now() <span class="op">+</span> <span class="pp">Duration::</span>hours(<span class="dv">24</span>))<span class="op">.</span>to_rfc3339()<span class="op">;</span></span>
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a>cache<span class="op">.</span>set_ttl(<span class="op">&amp;</span>pk2<span class="op">,</span> <span class="op">&amp;</span>expires_at)<span class="op">.</span><span class="kw">await</span><span class="op">?;</span></span>
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a><span class="co">// Vérifier le statut</span></span>
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> cache<span class="op">.</span>is_pinned(<span class="op">&amp;</span>pk)<span class="op">.</span><span class="kw">await</span><span class="op">?</span> <span class="op">{</span></span>
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="pp">println!</span>(<span class="st">&quot;Fichier protégé&quot;</span>)<span class="op">;</span></span>
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h3 id="utilisation-via-lapi-rest">Utilisation via lAPI REST</h3>
<h4 id="récupérer-le-statut-de-pinning">Récupérer le statut de
pinning</h4>
<div class="sourceCode" id="cb6"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="ex">GET</span> /api/cache/{pk}/pin</span>
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pk&quot;</span><span class="ex">:</span> <span class="st">&quot;1a2b3c4d5e6f7a8b&quot;</span>,</span>
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pinned&quot;</span><span class="ex">:</span> false,</span>
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;ttl_expires_at&quot;</span><span class="ex">:</span> null</span>
<span id="cb6-8"><a href="#cb6-8" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
<h4 id="épingler-un-item">Épingler un item</h4>
<div class="sourceCode" id="cb7"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="ex">POST</span> /api/cache/{pk}/pin</span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pk&quot;</span><span class="ex">:</span> <span class="st">&quot;1a2b3c4d5e6f7a8b&quot;</span>,</span>
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;Item &#39;1a2b3c4d5e6f7a8b&#39; pinned successfully&quot;</span></span>
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-9"><a href="#cb7-9" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 409 CONFLICT <span class="er">(</span><span class="ex">si</span> TTL défini<span class="kw">)</span><span class="bu">:</span></span>
<span id="cb7-10"><a href="#cb7-10" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb7-11"><a href="#cb7-11" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;error&quot;</span><span class="ex">:</span> <span class="st">&quot;CONFLICT&quot;</span>,</span>
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;Cannot pin an item with TTL set. Clear TTL first.&quot;</span></span>
<span id="cb7-13"><a href="#cb7-13" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
<h4 id="désépingler-un-item">Désépingler un item</h4>
<div class="sourceCode" id="cb8"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="ex">DELETE</span> /api/cache/{pk}/pin</span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pk&quot;</span><span class="ex">:</span> <span class="st">&quot;1a2b3c4d5e6f7a8b&quot;</span>,</span>
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;Item &#39;1a2b3c4d5e6f7a8b&#39; unpinned successfully&quot;</span></span>
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
<h4 id="définir-un-ttl">Définir un TTL</h4>
<div class="sourceCode" id="cb9"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="ex">POST</span> /api/cache/{pk}/ttl</span>
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a><span class="ex">Content-Type:</span> application/json</span>
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;expires_at&quot;</span><span class="ex">:</span> <span class="st">&quot;2025-01-20T10:30:00Z&quot;</span></span>
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pk&quot;</span><span class="ex">:</span> <span class="st">&quot;1a2b3c4d5e6f7a8b&quot;</span>,</span>
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;TTL set successfully for item &#39;1a2b3c4d5e6f7a8b&#39;&quot;</span></span>
<span id="cb9-12"><a href="#cb9-12" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
<span id="cb9-13"><a href="#cb9-13" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-14"><a href="#cb9-14" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 409 CONFLICT <span class="er">(</span><span class="ex">si</span> épinglé<span class="kw">)</span><span class="bu">:</span></span>
<span id="cb9-15"><a href="#cb9-15" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb9-16"><a href="#cb9-16" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;error&quot;</span><span class="ex">:</span> <span class="st">&quot;CONFLICT&quot;</span>,</span>
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;Cannot set TTL on a pinned item. Unpin first.&quot;</span></span>
<span id="cb9-18"><a href="#cb9-18" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span>
<span id="cb9-19"><a href="#cb9-19" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb9-20"><a href="#cb9-20" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 400 BAD REQUEST <span class="er">(</span><span class="ex">format</span> invalide<span class="kw">)</span><span class="bu">:</span></span>
<span id="cb9-21"><a href="#cb9-21" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb9-22"><a href="#cb9-22" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;error&quot;</span><span class="ex">:</span> <span class="st">&quot;INVALID_DATE&quot;</span>,</span>
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;Invalid RFC3339 date format&quot;</span></span>
<span id="cb9-24"><a href="#cb9-24" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
<h4 id="supprimer-un-ttl">Supprimer un TTL</h4>
<div class="sourceCode" id="cb10"><pre
class="sourceCode bash"><code class="sourceCode bash"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="ex">DELETE</span> /api/cache/{pk}/ttl</span>
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a><span class="ex">Response</span> 200 OK:</span>
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a><span class="kw">{</span></span>
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;pk&quot;</span><span class="ex">:</span> <span class="st">&quot;1a2b3c4d5e6f7a8b&quot;</span>,</span>
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="st">&quot;message&quot;</span><span class="ex">:</span> <span class="st">&quot;TTL cleared successfully for item &#39;1a2b3c4d5e6f7a8b&#39;&quot;</span></span>
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a><span class="kw">}</span></span></pre></div>
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
<h3 id="phase-1-implémentation-de-base">Phase 1 : Implémentation de
base</h3>
<ol type="1">
<li><strong><code>pmocache/src/db.rs</code></strong> :
<ul>
<li>Modification du schéma SQL</li>
<li>Ajout de champs dans <code>CacheEntry</code></li>
<li>Ajout de 8 nouvelles méthodes</li>
<li>Modification de <code>get_oldest()</code>, <code>get()</code>,
<code>get_from_id()</code>, <code>get_all()</code>,
<code>get_by_collection()</code></li>
</ul></li>
<li><strong><code>pmocache/src/cache.rs</code></strong> :
<ul>
<li>Ajout de 5 méthodes publiques</li>
<li>Modification de <code>enforce_limit()</code></li>
</ul></li>
<li><strong><code>pmocache/tests/test_pinnable.rs</code></strong> :
<ul>
<li>Nouveau fichier de tests (9 tests)</li>
</ul></li>
</ol>
<h3 id="phase-2-enrichissement-de-lapi-rest">Phase 2 : Enrichissement de
lAPI REST</h3>
<ol start="4" type="1">
<li><strong><code>pmocache/src/api.rs</code></strong> :
<ul>
<li>Ajout de 3 nouvelles structures de données :
<code>SetTtlRequest</code>, <code>PinResponse</code>,
<code>PinStatus</code></li>
<li>Ajout de 5 nouveaux handlers dAPI :
<ul>
<li><code>get_pin_status()</code> : Récupération du statut de
pinning</li>
<li><code>pin_item()</code> : Épinglage dun item</li>
<li><code>unpin_item()</code> : Désépinglage dun item</li>
<li><code>set_item_ttl()</code> : Définition du TTL</li>
<li><code>clear_item_ttl()</code> : Suppression du TTL</li>
</ul></li>
</ul></li>
<li><strong><code>pmocache/src/pmoserver_ext.rs</code></strong> :
<ul>
<li>Ajout de 4 nouvelles routes dans <code>create_api_router()</code> :
<ul>
<li><code>GET /{pk}/pin</code> : Statut de pinning</li>
<li><code>POST /{pk}/pin</code> : Épingler</li>
<li><code>DELETE /{pk}/pin</code> : Désépingler</li>
<li><code>POST /{pk}/ttl</code> : Définir TTL</li>
<li><code>DELETE /{pk}/ttl</code> : Supprimer TTL</li>
</ul></li>
</ul></li>
<li><strong><code>pmocache/src/openapi.rs</code></strong> :
<ul>
<li>Mise à jour de la macro <code>create_cache_openapi!</code> pour
inclure :
<ul>
<li>Les 5 nouveaux endpoints dans la documentation</li>
<li>Les 3 nouvelles structures dans les schémas OpenAPI</li>
</ul></li>
</ul></li>
<li><strong><code>pmocache/src/lib.rs</code></strong> :
<ul>
<li>Export des nouvelles structures publiques pour lAPI</li>
</ul></li>
</ol>
<h2 id="api-rest-et-documentation-openapi">API REST et Documentation
OpenAPI</h2>
<h3 id="routes-disponibles">Routes disponibles</h3>
<p>Toutes les routes sont préfixées par <code>/api/{cache_name}/</code>
(ex: <code>/api/covers/</code>, <code>/api/audio/</code>).</p>
<table>
<colgroup>
<col style="width: 31%" />
<col style="width: 24%" />
<col style="width: 44%" />
</colgroup>
<thead>
<tr>
<th>Méthode</th>
<th>Route</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>GET</code></td>
<td><code>/{pk}/pin</code></td>
<td>Récupère le statut de pinning dun item</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/{pk}/pin</code></td>
<td>Épingle un item (le protège de léviction LRU)</td>
</tr>
<tr>
<td><code>DELETE</code></td>
<td><code>/{pk}/pin</code></td>
<td>Désépingle un item</td>
</tr>
<tr>
<td><code>POST</code></td>
<td><code>/{pk}/ttl</code></td>
<td>Définit le TTL dun item (expiration automatique)</td>
</tr>
<tr>
<td><code>DELETE</code></td>
<td><code>/{pk}/ttl</code></td>
<td>Supprime le TTL dun item</td>
</tr>
</tbody>
</table>
<h3 id="codes-de-statut-http">Codes de statut HTTP</h3>
<table>
<colgroup>
<col style="width: 18%" />
<col style="width: 42%" />
<col style="width: 39%" />
</colgroup>
<thead>
<tr>
<th>Code</th>
<th>Signification</th>
<th>Cas dusage</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>200 OK</code></td>
<td>Opération réussie</td>
<td>Tous les cas de succès</td>
</tr>
<tr>
<td><code>400 BAD REQUEST</code></td>
<td>Requête invalide</td>
<td>Format de date TTL invalide</td>
</tr>
<tr>
<td><code>404 NOT FOUND</code></td>
<td>Item non trouvé</td>
<td>PK inexistant dans le cache</td>
</tr>
<tr>
<td><code>409 CONFLICT</code></td>
<td>Conflit de règle métier</td>
<td>Tentative de pin avec TTL ou vice-versa</td>
</tr>
<tr>
<td><code>500 INTERNAL SERVER ERROR</code></td>
<td>Erreur serveur</td>
<td>Erreur de base de données</td>
</tr>
</tbody>
</table>
<h3 id="documentation-openapiswagger">Documentation OpenAPI/Swagger</h3>
<p>La documentation OpenAPI est automatiquement générée et inclut :</p>
<ul>
<li><strong>Schémas de données</strong> :
<ul>
<li><code>PinStatus</code> : Statut de pinning (pinned,
ttl_expires_at)</li>
<li><code>PinResponse</code> : Réponse dopération de pinning</li>
<li><code>SetTtlRequest</code> : Requête de définition de TTL</li>
<li><code>CacheEntry</code> : Mis à jour avec les champs
<code>pinned</code> et <code>ttl_expires_at</code></li>
</ul></li>
<li><strong>Endpoints documentés</strong> :
<ul>
<li>Description détaillée de chaque route</li>
<li>Exemples de requêtes et réponses</li>
<li>Codes derreur possibles</li>
</ul></li>
<li><strong>Interface Swagger UI</strong> :
<ul>
<li>Accessible à <code>/swagger-ui/{cache_name}</code></li>
<li>Permet de tester lAPI directement depuis le navigateur</li>
</ul></li>
</ul>
<h3 id="gestion-des-erreurs">Gestion des erreurs</h3>
<p>LAPI suit une structure derreur cohérente :</p>
<div class="sourceCode" id="cb11"><pre
class="sourceCode json"><code class="sourceCode json"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;error&quot;</span><span class="fu">:</span> <span class="st">&quot;CODE_ERREUR&quot;</span><span class="fu">,</span></span>
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;message&quot;</span><span class="fu">:</span> <span class="st">&quot;Description lisible de l&#39;erreur&quot;</span></span>
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
<p>Les règles métier sont appliquées strictement : - <strong>409
CONFLICT</strong> si tentative de pin avec TTL défini - <strong>409
CONFLICT</strong> si tentative de set TTL sur item épinglé - Messages
derreur explicites guidant lutilisateur</p>
<h2 id="tests">Tests</h2>
<ul>
<li><strong>Suite de tests dédiée</strong> : 9 tests, tous passants</li>
<li><strong>Tests existants</strong> : Tous les tests de
<code>test_cache.rs</code> passent toujours</li>
<li><strong>Couverture</strong> : Toutes les nouvelles fonctionnalités
sont testées</li>
<li><strong>Compilation</strong> : Aucune erreur, tous les modules
compilent correctement</li>
</ul>
<h2 id="résultat">Résultat</h2>
<p><strong>Implémentation complète et fonctionnelle</strong> des
items épinglables avec TTL<br />
<strong>Règle métier</strong> TTL ↔︎ Pinned correctement
implémentée<br />
<strong>Tests exhaustifs</strong> validant tous les cas dusage<br />
<strong>Compatibilité</strong> avec les bases de données
existantes<br />
<strong>Pas de régression</strong> sur les tests existants<br />
<strong>API REST complète</strong> avec 5 nouveaux endpoints<br />
<strong>Documentation OpenAPI</strong> automatiquement générée<br />
<strong>Gestion derreurs cohérente</strong> avec codes HTTP
appropriés</p>
</article>
</body>
</html>

View File

@@ -1,209 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WeabApp_debouncingSSE</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="rapport-suppression-de-la-logique-de-débouncing-sse">Rapport :
Suppression de la logique de débouncing SSE</h1>
<p><strong>Date</strong>: 2026-01-12 <strong>Tâche</strong>:
WeabApp_debouncingSSE.md</p>
<h2 id="objectif">Objectif</h2>
<p>Supprimer la logique de débouncing inutile sur le canal SSE de
lapplication web PMOControl, puisque le serveur contrôle déjà le flux
des événements.</p>
<h2 id="analyse-préalable">Analyse préalable</h2>
<p>Jai identifié trois endroits avec des mécanismes de temporisation
dans lapplication web :</p>
<h3 id="mediabrowser.vue---débouncing-sse-à-supprimer">1.
MediaBrowser.vue - Débouncing SSE (À SUPPRIMER ✓)</h3>
<ul>
<li><strong>Débouncing</strong>: 200ms après invalidation du cache</li>
<li><strong>Cooldown</strong>: 2 secondes entre les rechargements</li>
<li><strong>Justification originale</strong>: “dédupliquer les
événements SSE dans le même batch (polling 500ms)”</li>
<li><strong>Problème</strong>: Cette logique est redondante puisque le
serveur contrôle déjà le flux SSE</li>
</ul>
<h3 id="userenderers.ts---smart-fetching-à-conserver">2. useRenderers.ts
- Smart fetching (À CONSERVER ✓)</h3>
<ul>
<li><strong>Mécanisme</strong>: Comparaison des timestamps
<code>lastEventAt</code> vs <code>lastSnapshotAt</code></li>
<li><strong>But</strong>: Éviter de refetch un snapshot déjà à jour</li>
<li><strong>Justification</strong>: Ce nest PAS du débouncing, cest
une optimisation intelligente qui évite des appels API inutiles</li>
</ul>
<h3 id="volumecontrol.vue---ui-debouncing-à-conserver">3.
VolumeControl.vue - UI debouncing (À CONSERVER ✓)</h3>
<ul>
<li><strong>Débouncing</strong>: 300ms sur les changements de
volume</li>
<li><strong>But</strong>: Réduire les appels API pendant que
lutilisateur fait glisser le curseur</li>
<li><strong>Justification</strong>: Débouncing légitime pour linterface
utilisateur</li>
</ul>
<h2 id="modifications-effectuées">Modifications effectuées</h2>
<h3
id="fichier-modifié-pmoappwebappsrccomponentspmocontrolmediabrowser.vue">Fichier
modifié:
<code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></h3>
<h4 id="suppression-des-variables-de-débouncing-ligne-27">1. Suppression
des variables de débouncing (ligne ~27)</h4>
<p><strong>Avant</strong>:</p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flags pour gérer le rechargement automatique avec debounce et cooldown</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> refreshTimeoutId <span class="op">=</span> <span class="fu">ref</span><span class="op">&lt;</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">&gt;</span>(<span class="kw">null</span>)<span class="op">;</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> lastRefreshTime <span class="op">=</span> <span class="fu">ref</span><span class="op">&lt;</span><span class="dt">number</span><span class="op">&gt;</span>(<span class="dv">0</span>)<span class="op">;</span></span>
<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> REFRESH_COOLDOWN_MS <span class="op">=</span> <span class="dv">2000</span><span class="op">;</span> <span class="co">// Ne pas recharger plus d&#39;une fois toutes les 2 secondes</span></span></pre></div>
<p><strong>Après</strong>:</p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Flag pour gérer le rechargement automatique</span></span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> isRefreshing <span class="op">=</span> <span class="fu">ref</span>(<span class="kw">false</span>)<span class="op">;</span></span></pre></div>
<h4 id="simplification-du-watcher-de-cache-ligne-53">2. Simplification
du watcher de cache (ligne ~53)</h4>
<p><strong>Avant</strong>:</p>
<div class="sourceCode" id="cb3"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur &quot;Lire maintenant&quot; sur une playlist,</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Utilise un debounce de 3 secondes pour regrouper les multiples invalidations</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a><span class="co">// et un cooldown de 5 secondes pour éviter les rechargements successifs</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=&gt;</span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> (data) <span class="kw">=&gt;</span> {</span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&amp;&amp;</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="co">// Vérifier le cooldown: ignorer si on a rechargé il y a moins de 5 secondes</span></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">const</span> timeSinceLastRefresh <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>() <span class="op">-</span> lastRefreshTime<span class="op">.</span><span class="at">value</span><span class="op">;</span></span>
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (timeSinceLastRefresh <span class="op">&lt;</span> REFRESH_COOLDOWN_MS) {</span>
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb3-14"><a href="#cb3-14" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé mais cooldown actif (</span><span class="sc">${</span><span class="bu">Math</span><span class="op">.</span><span class="fu">round</span>((REFRESH_COOLDOWN_MS <span class="op">-</span> timeSinceLastRefresh) <span class="op">/</span> <span class="dv">1000</span>)<span class="sc">}</span><span class="vs">s restantes), rechargement ignoré`</span><span class="op">,</span></span>
<span id="cb3-15"><a href="#cb3-15" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-16"><a href="#cb3-16" aria-hidden="true" tabindex="-1"></a> <span class="cf">return</span><span class="op">;</span></span>
<span id="cb3-17"><a href="#cb3-17" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-18"><a href="#cb3-18" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-19"><a href="#cb3-19" aria-hidden="true" tabindex="-1"></a> <span class="co">// Annuler tout timeout en cours</span></span>
<span id="cb3-20"><a href="#cb3-20" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">!==</span> <span class="kw">null</span>) {</span>
<span id="cb3-21"><a href="#cb3-21" aria-hidden="true" tabindex="-1"></a> <span class="pp">clearTimeout</span>(refreshTimeoutId<span class="op">.</span><span class="at">value</span>)<span class="op">;</span></span>
<span id="cb3-22"><a href="#cb3-22" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-23"><a href="#cb3-23" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb3-24"><a href="#cb3-24" aria-hidden="true" tabindex="-1"></a> <span class="co">// Planifier le rechargement après 200ms</span></span>
<span id="cb3-25"><a href="#cb3-25" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">window</span><span class="op">.</span><span class="fu">setTimeout</span>(<span class="kw">async</span> () <span class="kw">=&gt;</span> {</span>
<span id="cb3-26"><a href="#cb3-26" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb3-27"><a href="#cb3-27" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb3-28"><a href="#cb3-28" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement après debounce...`</span><span class="op">,</span></span>
<span id="cb3-29"><a href="#cb3-29" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-30"><a href="#cb3-30" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
<span id="cb3-31"><a href="#cb3-31" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(</span>
<span id="cb3-32"><a href="#cb3-32" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span></span>
<span id="cb3-33"><a href="#cb3-33" aria-hidden="true" tabindex="-1"></a> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span></span>
<span id="cb3-34"><a href="#cb3-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">false</span><span class="op">,</span></span>
<span id="cb3-35"><a href="#cb3-35" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb3-36"><a href="#cb3-36" aria-hidden="true" tabindex="-1"></a> lastRefreshTime<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="bu">Date</span><span class="op">.</span><span class="fu">now</span>()<span class="op">;</span></span>
<span id="cb3-37"><a href="#cb3-37" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
<span id="cb3-38"><a href="#cb3-38" aria-hidden="true" tabindex="-1"></a> refreshTimeoutId<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">null</span><span class="op">;</span></span>
<span id="cb3-39"><a href="#cb3-39" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-40"><a href="#cb3-40" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span> <span class="dv">200</span>)<span class="op">;</span></span>
<span id="cb3-41"><a href="#cb3-41" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb3-42"><a href="#cb3-42" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
<span id="cb3-43"><a href="#cb3-43" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
<p><strong>Après</strong>:</p>
<div class="sourceCode" id="cb4"><pre
class="sourceCode typescript"><code class="sourceCode typescript"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="co">// Recharger automatiquement si le cache est invalidé (ex: après un ContainersUpdated SSE)</span></span>
<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a><span class="co">// Cela se produit notamment quand on clique sur &quot;Lire maintenant&quot; sur une playlist,</span></span>
<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a><span class="co">// ce qui déclenche un événement ContainersUpdated qui invalide le cache</span></span>
<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a><span class="co">// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client</span></span>
<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a><span class="fu">watch</span>(</span>
<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a> () <span class="kw">=&gt;</span> browseData<span class="op">.</span><span class="at">value</span><span class="op">,</span></span>
<span id="cb4-7"><a href="#cb4-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">async</span> (data) <span class="kw">=&gt;</span> {</span>
<span id="cb4-8"><a href="#cb4-8" aria-hidden="true" tabindex="-1"></a> <span class="co">// Si browseData devient undefined alors que containerId est présent,</span></span>
<span id="cb4-9"><a href="#cb4-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// et qu&#39;on n&#39;est pas déjà en train de charger, recharger immédiatement</span></span>
<span id="cb4-10"><a href="#cb4-10" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> (<span class="op">!</span>data <span class="op">&amp;&amp;</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&amp;&amp;</span> <span class="op">!</span>isRefreshing<span class="op">.</span><span class="at">value</span>) {</span>
<span id="cb4-11"><a href="#cb4-11" aria-hidden="true" tabindex="-1"></a> <span class="bu">console</span><span class="op">.</span><span class="fu">log</span>(</span>
<span id="cb4-12"><a href="#cb4-12" aria-hidden="true" tabindex="-1"></a> <span class="vs">`[MediaBrowser] Cache invalidé pour </span><span class="sc">${</span>props<span class="op">.</span><span class="at">serverId</span><span class="sc">}</span><span class="vs">/</span><span class="sc">${</span>props<span class="op">.</span><span class="at">containerId</span><span class="sc">}</span><span class="vs">, rechargement...`</span><span class="op">,</span></span>
<span id="cb4-13"><a href="#cb4-13" aria-hidden="true" tabindex="-1"></a> )<span class="op">;</span></span>
<span id="cb4-14"><a href="#cb4-14" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">true</span><span class="op">;</span></span>
<span id="cb4-15"><a href="#cb4-15" aria-hidden="true" tabindex="-1"></a> <span class="cf">await</span> <span class="fu">browseContainer</span>(props<span class="op">.</span><span class="at">serverId</span><span class="op">,</span> props<span class="op">.</span><span class="at">containerId</span><span class="op">,</span> <span class="kw">false</span>)<span class="op">;</span></span>
<span id="cb4-16"><a href="#cb4-16" aria-hidden="true" tabindex="-1"></a> isRefreshing<span class="op">.</span><span class="at">value</span> <span class="op">=</span> <span class="kw">false</span><span class="op">;</span></span>
<span id="cb4-17"><a href="#cb4-17" aria-hidden="true" tabindex="-1"></a> }</span>
<span id="cb4-18"><a href="#cb4-18" aria-hidden="true" tabindex="-1"></a> }<span class="op">,</span></span>
<span id="cb4-19"><a href="#cb4-19" aria-hidden="true" tabindex="-1"></a>)<span class="op">;</span></span></pre></div>
<h2 id="résultats">Résultats</h2>
<h3 id="changements-de-comportement">Changements de comportement</h3>
<ul>
<li><strong>Avant</strong>: Délai de 200ms + cooldown de 2s entre les
rechargements de cache</li>
<li><strong>Après</strong>: Rechargement immédiat dès linvalidation du
cache</li>
<li><strong>Impact</strong>: Réactivité améliorée de linterface, les
mises à jour apparaissent immédiatement</li>
</ul>
<h3 id="réduction-de-complexité">Réduction de complexité</h3>
<ul>
<li><strong>3 variables supprimées</strong>:
<code>refreshTimeoutId</code>, <code>lastRefreshTime</code>,
<code>REFRESH_COOLDOWN_MS</code></li>
<li><strong>Logique simplifiée</strong>: De ~40 lignes à ~10 lignes dans
le watcher</li>
<li><strong>Code plus lisible</strong>: Intention claire sans mécanismes
de temporisation complexes</li>
</ul>
<h3 id="tests">Tests</h3>
<ul>
<li>✓ Le projet compile sans erreurs TypeScript</li>
<li>✓ Le flag <code>isRefreshing</code> empêche toujours les
rechargements concurrents</li>
<li>✓ Les autres composants (useRenderers.ts, VolumeControl.vue)
conservent leurs optimisations légitimes</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>La suppression du débouncing et du cooldown dans MediaBrowser.vue
simplifie le code tout en améliorant la réactivité de linterface.
Puisque le serveur contrôle déjà le flux SSE, ces mécanismes côté client
étaient redondants et ajoutaient une latence artificielle.</p>
<p>Le code est maintenant plus simple, plus réactif, et fait confiance
au serveur pour contrôler la fréquence des événements SSE.</p>
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
<ul>
<li><code>pmoapp/webapp/src/components/pmocontrol/MediaBrowser.vue</code></li>
</ul>
<h2 id="lignes-de-code">Lignes de code</h2>
<ul>
<li><strong>Supprimées</strong>: ~35 lignes (logique de
débouncing/cooldown)</li>
<li><strong>Ajoutées</strong>: ~5 lignes (logique simplifiée)</li>
<li><strong>Net</strong>: -30 lignes</li>
</ul>
</article>
</body>
</html>

View File

@@ -1,152 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>config_ext</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="rapport-documentation-du-pattern-dextension-pmoconfig">Rapport :
Documentation du pattern dextension pmoconfig</h1>
<h2 id="objectif-de-la-tâche">Objectif de la tâche</h2>
<p>Créer une fiche descriptive documentant le pattern dimplémentation
des traits dextension de <code>pmoconfig::Config</code> en analysant
les implémentations existantes dans les différents crates du projet.</p>
<h2 id="travail-réalisé">Travail réalisé</h2>
<h3 id="analyse-des-fichiers-source">1. Analyse des fichiers source</h3>
<p>Les fichiers suivants ont été analysés :</p>
<ul>
<li><code>pmocovers/src/config_ext.rs</code> - Pattern cache avec
conversion WebP</li>
<li><code>pmoaudiocache/src/config_ext.rs</code> - Pattern cache avec
conversion FLAC</li>
<li><code>pmoqobuz/src/config_ext.rs</code> - Pattern authentification
et rate limiting</li>
<li><code>pmocache/src/config_ext.rs</code> - Trait générique de cache
et macro</li>
<li><code>pmoconfig/PASSWORD_ENCRYPTION.md</code> - Documentation du
chiffrement</li>
<li><code>pmoupnp/src/config_ext.rs</code> - Pattern configuration
UPnP</li>
<li><code>pmoparadise/src/config_ext.rs</code> - Pattern configuration
minimale</li>
</ul>
<h3 id="patterns-identifiés">2. Patterns identifiés</h3>
<h4 id="pattern-de-base">Pattern de base</h4>
<p>Tous les traits dextension suivent la même structure : - Trait
public avec méthodes getter/setter - Implémentation pour
<code>pmoconfig::Config</code> - Utilisation de
<code>get_value</code>/<code>set_value</code> génériques - Constantes
pour valeurs par défaut</p>
<h4 id="patterns-spécialisés">Patterns spécialisés</h4>
<ul>
<li><strong>Cache</strong> : Utilisation de <code>CacheConfigExt</code>
et factory methods</li>
<li><strong>Authentification</strong> : Getters combinés, helpers de
validation, déchiffrement automatique</li>
<li><strong>Rate limiting</strong> : Configuration des limites avec
valeurs par défaut</li>
<li><strong>Configuration minimale</strong> : Auto-persistence des
valeurs par défaut</li>
<li><strong>UPnP</strong> : Configuration des identifiants devices</li>
</ul>
<h3 id="structure-de-la-documentation">3. Structure de la
documentation</h3>
<p>La documentation créée couvre :</p>
<ol type="1">
<li><strong>Vue densemble</strong> : Objectif et principe du
pattern</li>
<li><strong>Architecture</strong> : Structure et flux de données</li>
<li><strong>Implémentation</strong> : Guide détaillé avec patterns de
code</li>
<li><strong>Patterns spécialisés</strong> : Exemples pour chaque cas
dusage</li>
<li><strong>Bonnes pratiques</strong> : Nommage, erreurs,
documentation</li>
<li><strong>Exemples complets</strong> : 3 implémentations complètes
commentées</li>
<li><strong>Checklist</strong> : Liste de vérification pour nouveaux
traits</li>
<li><strong>Philosophie</strong> : Principes directeurs et
avantages</li>
</ol>
<h3 id="contenu-clé">4. Contenu clé</h3>
<h4 id="patterns-de-getters">Patterns de getters</h4>
<ul>
<li>Getter simple avec valeur par défaut</li>
<li>Getter avec auto-persistence</li>
<li>Getter optionnel</li>
<li>Getter avec déchiffrement</li>
<li>Getter avec parsing et fallback</li>
</ul>
<h4 id="patterns-de-setters">Patterns de setters</h4>
<ul>
<li>Setter simple</li>
<li>Setter avec transformation</li>
<li>Setter multiple (transaction)</li>
<li>Setter de nettoyage</li>
</ul>
<h4 id="helpers">Helpers</h4>
<ul>
<li>Factory methods</li>
<li>Getters combinés</li>
<li>Helpers de validation</li>
</ul>
<h3 id="hiérarchie-de-configuration-yaml">5. Hiérarchie de configuration
YAML</h3>
<p>Documentation des chemins standards : - <code>host.*</code> :
Configuration hôte/système - <code>accounts.*</code> : Comptes et
services - <code>sources.*</code> : Sources de médias</p>
<h2 id="résultat">Résultat</h2>
<p>Le document <code>Blackboard/Architecture/pmoconfig_ext.md</code> a
été créé avec : - 800+ lignes de documentation complète - 3 exemples
dimplémentation complète - Patterns pour tous les cas dusage
identifiés - Bonnes pratiques et anti-patterns - Checklist
dimplémentation</p>
<h2 id="fichiers-créés-ou-modifiés">Fichiers créés ou modifiés</h2>
<ul>
<li><strong>Créé</strong> :
<code>Blackboard/Architecture/pmoconfig_ext.md</code> - Documentation
complète du pattern</li>
<li><strong>Créé</strong> : <code>Blackboard/Report/config_ext.md</code>
- Ce rapport</li>
</ul>
<h2 id="conformité-avec-rules.md">Conformité avec Rules.md</h2>
<ul>
<li>Documentation placée dans <code>Blackboard/Architecture/</code>
comme demandé</li>
<li>Rapport créé dans <code>Blackboard/Report/</code> avec le même nom
de fichier</li>
<li>Analyse focalisée sur lobjectif principal</li>
<li>Documentation prête pour classification (Done/ToDiscuss) par
lhumain</li>
</ul>
</article>
</body>
</html>

View File

@@ -1,238 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>music_source</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1
id="rapport-documentation-dimplémentation-dune-nouvelle-musicsource">Rapport
: Documentation dimplémentation dune nouvelle MusicSource</h1>
<h2 id="objectif">Objectif</h2>
<p>Créer une documentation complète et pratique pour guider
limplémentation dune nouvelle source musicale dans lécosystème
PMOMusic.</p>
<h2 id="travail-réalisé">Travail réalisé</h2>
<h3 id="analyse-des-sources-existantes">1. Analyse des sources
existantes</h3>
<p>Jai analysé deux implémentations de référence :</p>
<ul>
<li><strong>pmoparadise/src/source.rs</strong> : Source dynamique avec
FIFO (radio streaming)</li>
<li><strong>pmoqobuz/src/source.rs</strong> : Source catalogue avec
playlists lazy</li>
</ul>
<p>Ainsi que la documentation du trait :</p>
<ul>
<li><strong>pmosource/README.md</strong> : Vue densemble du trait
MusicSource</li>
<li><strong>pmosource/ARCHITECTURE.md</strong> : Architecture et design
decisions</li>
</ul>
<h3 id="identification-des-patterns-principaux">2. Identification des
patterns principaux</h3>
<p>Deux patterns majeurs ont été identifiés :</p>
<h4 id="pattern-1-source-dynamique-fifo-radio-paradise">Pattern 1 :
Source dynamique FIFO (Radio Paradise)</h4>
<p><strong>Caractéristiques :</strong> - Flux continu de tracks avec
capacité limitée - Suppression automatique des plus anciens - Callbacks
sur playlists pour détecter les changements - Notification du
ContentDirectory via notifier injecté - Adaptation des IDs playlist →
schema source</p>
<p><strong>Éléments clés :</strong></p>
<div class="sourceCode" id="cb1"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a>update_counter<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;&gt;</span></span>
<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a>last_change<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>SystemTime<span class="op">&gt;&gt;</span></span>
<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a>callback_tokens<span class="op">:</span> Arc<span class="op">&lt;</span>Mutex<span class="op">&lt;</span><span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">u64</span><span class="op">&gt;&gt;&gt;</span></span>
<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a>container_notifier<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span>Arc<span class="op">&lt;</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&amp;</span>[<span class="dt">String</span>]) <span class="op">+</span> <span class="bu">Send</span> <span class="op">+</span> <span class="bu">Sync</span><span class="op">&gt;&gt;</span></span></pre></div>
<h4 id="pattern-2-source-catalogue-lazy-qobuz">Pattern 2 : Source
catalogue lazy (Qobuz)</h4>
<p><strong>Caractéristiques :</strong> - Catalogue vaste avec navigation
hiérarchique - Cache lazy pour audio, eager pour covers - Playlists
créées à la demande avec TTL - LazyProvider pour télécharger laudio à
la lecture - Métadonnées riches stockées dans le cache</p>
<p><strong>Éléments clés :</strong></p>
<div class="sourceCode" id="cb2"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a>SourceCacheManager centralisé</span>
<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a>QobuzLazyProvider implémentant LazyProvider</span>
<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a>Playlists avec rôle Album et TTL de <span class="dv">7</span> jours</span>
<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a>Adaptation IDs avec metadata source_track_id</span></pre></div>
<h3 id="structure-du-document-créé">3. Structure du document créé</h3>
<p>Le document <code>Blackboard/Architecture/music_source.md</code>
contient :</p>
<h4 id="table-des-matières">Table des matières</h4>
<ol type="1">
<li>Vue densemble</li>
<li>Structure dune MusicSource</li>
<li>Implémentation du trait MusicSource</li>
<li>Patterns dimplémentation</li>
<li>Intégration avec lécosystème PMOMusic</li>
<li>Checklist de mise en œuvre</li>
<li>Exemples de référence</li>
</ol>
<h4 id="sections-détaillées">Sections détaillées</h4>
<p><strong>Section 1 : Vue densemble</strong> - Définition dune
MusicSource - Types de sources (dynamique vs statique) - Capacités du
trait</p>
<p><strong>Section 2 : Structure</strong> - Organisation du code -
Dépendances recommandées - Features Cargo</p>
<p><strong>Section 3 : Implémentation du trait</strong> - Informations
de base (name, id, default_image) - Navigation ContentDirectory
(root_container, browse, resolve_uri) - Support FIFO (append_track,
remove_oldest, update_id) - Support statique (get_items, search)</p>
<p><strong>Section 4 : Patterns</strong> - Pattern 1 : Source dynamique
avec FIFO (code complet) - Pattern 2 : Source catalogue avec playlists
lazy (code complet) - Pattern 3 : Adaptation des IDs entre playlist et
source</p>
<p><strong>Section 5 : Intégration écosystème</strong> - pmoplaylist :
création et gestion de playlists - pmoaudiocache/pmocovers via
SourceCacheManager - pmodidl : conversion vers DIDL-Lite - LazyProvider
personnalisé</p>
<p><strong>Section 6 : Checklist</strong> - Phase 1 : Structure de base
- Phase 2 : Navigation ContentDirectory - Phase 3 : Résolution dURI -
Phase 4 : Support FIFO (si dynamique) - Phase 5 : Support statique (si
catalogue) - Phase 6 : Intégration avancée - Phase 7 : Tests et
validation</p>
<p><strong>Section 7 : Exemples de référence</strong> - Radio Paradise
(source dynamique FIFO) - Qobuz (source catalogue lazy) - Schemas
dObject ID détaillés</p>
<h3 id="points-techniques-importants-documentés">4. Points techniques
importants documentés</h3>
<h4 id="schema-dobject-id">Schema dObject ID</h4>
<p>Format recommandé hiérarchique :</p>
<pre><code>&lt;source-id&gt;
&lt;source-id&gt;:albums
&lt;source-id&gt;:album:&lt;album_id&gt;
&lt;source-id&gt;:track:&lt;track_id&gt;
&lt;source-id&gt;:playlist:&lt;playlist_id&gt;</pre>
<p>Exemples concrets de Radio Paradise et Qobuz fournis.</p>
<h4 id="adaptation-des-ids">Adaptation des IDs</h4>
<p>Code complet pour adapter les items de playlist au schema de la
source : - Extraction du cache_pk depuis lURL - Récupération du
source_track_id depuis metadata - Reconstruction de lID correct -
Normalisation des URLs (relatives → absolues) - Ajout de champs requis
(genre)</p>
<h4 id="cache-lazy-vs-eager">Cache lazy vs eager</h4>
<p>Stratégie claire : - <strong>Covers</strong> : Cache eager (petit, UI
en a besoin immédiatement) - <strong>Audio</strong> : Cache lazy (grand,
téléchargé à la demande)</p>
<h4 id="thread-safety">Thread Safety</h4>
<p>Règles explicites : - <code>Arc&lt;RwLock&lt;&gt;&gt;</code> pour
état mutable partagé - <code>tokio::sync::RwLock</code> pour async -
Éviter <code>Rc&lt;&gt;</code>, <code>RefCell</code> (non thread-safe) -
Implémenter <code>Clone</code> via <code>Arc&lt;&gt;</code></p>
<h4 id="compatibilité-upnp">Compatibilité UPnP</h4>
<p>Points de vigilance : - Genre obligatoire pour certains clients
(gupnp-av-cp) - URLs absolues uniquement - Protocol Info correct pour
FLAC - Duration au format <code>H:MM:SS</code> - childCount optionnel
mais recommandé</p>
<h3 id="code-dexemple-complet">5. Code dexemple complet</h3>
<p>Le document contient des exemples de code complets et fonctionnels
pour :</p>
<ol type="1">
<li><strong>Structure de base</strong> : définition de la struct et
implémentation basique</li>
<li><strong>Navigation</strong> : root_container et browse avec pattern
matching</li>
<li><strong>Résolution URI</strong> : avec fallback cache →
original</li>
<li><strong>FIFO</strong> : append_track, remove_oldest, callbacks</li>
<li><strong>Adaptation IDs</strong> : fonction complète
dadaptation</li>
<li><strong>LazyProvider</strong> : implémentation personnalisée</li>
<li><strong>Conversion DIDL</strong> : traits ToDIDLContainer et
ToDIDLItem</li>
</ol>
<h2 id="couverture-des-besoins">Couverture des besoins</h2>
<h3 id="sources-couvertes">Sources couvertes</h3>
<ul>
<li>✅ Radio Paradise : source dynamique FIFO</li>
<li>✅ Qobuz : source catalogue lazy</li>
<li>✅ Patterns génériques applicables à dautres sources</li>
</ul>
<h3 id="cas-dusage-couverts">Cas dusage couverts</h3>
<ul>
<li>✅ Source radio/streaming live</li>
<li>✅ Source catalogue de streaming (Spotify, Deezer, etc.)</li>
<li>✅ Source bibliothèque locale</li>
<li>✅ Source playlists fixes</li>
<li>✅ Source avec authentification (via client)</li>
</ul>
<h3 id="intégrations-couvertes">Intégrations couvertes</h3>
<ul>
<li>✅ pmoplaylist (FIFO et persistant)</li>
<li>✅ pmoaudiocache (cache audio)</li>
<li>✅ pmocovers (cache covers)</li>
<li>✅ SourceCacheManager (centralisé)</li>
<li>✅ LazyProvider (téléchargement lazy)</li>
<li>✅ pmodidl (DIDL-Lite)</li>
</ul>
<h2 id="limitations-et-améliorations-futures">Limitations et
améliorations futures</h2>
<h3 id="limitations-actuelles">Limitations actuelles</h3>
<ol type="1">
<li><strong>Search</strong> : Pas dexemple détaillé de search
(optionnel dans le trait)</li>
<li><strong>Authentification</strong> : Mentionné mais pas dexemple
complet</li>
<li><strong>Multi-format</strong> : Pas dexemple de source supportant
plusieurs formats</li>
<li><strong>Offline</strong> : Pas de pattern pour source
offline/synchronisation</li>
</ol>
<h3 id="améliorations-possibles">Améliorations possibles</h3>
<ol type="1">
<li>Ajouter un exemple complet de search avec filtres</li>
<li>Documenter lintégration avec un système dauth OAuth</li>
<li>Ajouter un pattern pour sources multi-formats (FLAC/MP3/AAC)</li>
<li>Documenter la gestion offline avec synchronisation</li>
</ol>
<h2 id="fichiers-créés">Fichiers créés</h2>
<ul>
<li><code>Blackboard/Architecture/music_source.md</code> : Documentation
complète (15 sections, ~800 lignes)</li>
</ul>
<h2 id="conclusion">Conclusion</h2>
<p>Le document créé fournit un guide complet et pratique pour
implémenter une nouvelle MusicSource. Il combine :</p>
<ul>
<li><strong>Théorie</strong> : Architecture, design patterns,
principes</li>
<li><strong>Pratique</strong> : Code complet, exemples réels,
checklist</li>
<li><strong>Référence</strong> : Schemas dObject ID, intégrations,
compatibilité</li>
</ul>
<p>Un développeur peut suivre ce guide étape par étape pour créer une
nouvelle source musicale compatible avec lécosystème PMOMusic, en
sinspirant des patterns éprouvés de Radio Paradise et Qobuz.</p>
</article>
</body>
</html>

View File

@@ -1,120 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pmoserver_ext</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<h1 id="rapport-documentation-du-pattern-pmoserver_ext">Rapport :
Documentation du pattern pmoserver_ext</h1>
<h2 id="contexte">Contexte</h2>
<p>Documentation du pattern dextension du PMOServer à travers plusieurs
itérations basées sur les retours utilisateur.</p>
<h2 id="travail-réalisé">Travail réalisé</h2>
<h3 id="analyse-des-fichiers-sources">Analyse des fichiers sources</h3>
<p>Les fichiers suivants ont été analysés pour extraire le pattern :</p>
<ul>
<li><code>pmoapp/src/lib.rs</code> : Pattern SPA avec RustEmbed</li>
<li><code>pmocontrol/src/pmoserver_ext.rs</code> : API REST avec Control
Point (1506+ lignes)</li>
<li><code>pmoparadise/src/pmoserver_ext.rs</code> : API REST simple avec
client externe</li>
<li><code>pmoaudiocache/src/lib.rs</code> : Extension avec cache et
fichiers</li>
<li><code>pmomediaserver/src/paradise_streaming.rs</code> : Extension
complexe avec streaming</li>
</ul>
<h3 id="round-1-document-initial">Round 1 : Document initial</h3>
<p>Premier jet documentant exhaustivement tous les aspects des
extensions (~850 lignes).</p>
<h3 id="round-2-recentrage-sur-le-pattern">Round 2 : Recentrage sur le
pattern</h3>
<p><strong>Annotation</strong> : “se recentrer sur le sujet
principal”</p>
<p><strong>Actions</strong> : - Réduction de ~850 à ~400 lignes -
Suppression des digressions (OpenAPI détaillé, handlers spécifiques) -
Focus sur lanatomie du pattern en 5 étapes - Ajout dune checklist et
dun exemple minimal</p>
<p><strong>Résultat</strong> : Document focalisé sur limplémentation du
pattern uniquement.</p>
<h3 id="round-3-réintégration-openapi">Round 3 : Réintégration
OpenAPI</h3>
<p><strong>Annotation</strong> : “Je trouve que le fait de devoir
déclarer et documenter les URL dans OpenAPI / utopia était quelque chose
dimportant. Remets le.”</p>
<p><strong>Actions</strong> : - Ajout dune section complète
“Documentation OpenAPI avec utoipa” (~260 lignes) - 5 sous-sections
détaillées : 1. Configuration de base (dépendances Cargo) 2. Définition
des schémas avec <code>#[derive(ToSchema)]</code> 3. Annotation des
handlers avec <code>#[utoipa::path]</code> 4. Création de la structure
<code>#[derive(OpenApi)]</code> 5. Exemple complet extrait de Radio
Paradise - Mise à jour de la checklist avec section “Documentation
OpenAPI” - Ajout des dépendances <code>utoipa</code> et
<code>serde</code> dans la section références</p>
<p><strong>Positionnement</strong> : Section insérée après “Méthodes
disponibles du serveur” et avant “Patterns courants”, car elle fait
partie intégrante de limplémentation.</p>
<h2 id="structure-finale-du-document">Structure finale du document</h2>
<ol type="1">
<li><strong>Vue densemble</strong> : Principe du pattern</li>
<li><strong>Anatomie dune extension</strong> : 5 étapes détaillées</li>
<li><strong>Méthodes disponibles du serveur</strong> : API de
<code>pmoserver::Server</code></li>
<li><strong>Documentation OpenAPI avec utoipa</strong> : Guide complet
en 5 étapes ⭐ <em>Ajouté au Round 3</em></li>
<li><strong>Patterns courants</strong> : 3 exemples concrets</li>
<li><strong>Gestion des opérations longues</strong> : spawn_blocking,
timeouts, background tasks</li>
<li><strong>Checklist dimplémentation</strong> : Organisée par
catégories</li>
<li><strong>Exemple complet minimal</strong> : Code fonctionnel</li>
<li><strong>Références</strong> : Fichiers sources et dépendances</li>
</ol>
<h2 id="résultat-final">Résultat final</h2>
<p>Le document est maintenant :</p>
<ul>
<li><strong>Complet</strong> : Couvre tous les aspects essentiels
incluant OpenAPI</li>
<li><strong>Structuré</strong> : Progression logique de la configuration
à limplémentation</li>
<li><strong>Pratique</strong> : Exemples de code concrets extraits du
codebase</li>
<li><strong>Actionnable</strong> : Checklist détaillée en 4
catégories</li>
</ul>
<p>Taille finale : ~660 lignes (avec section OpenAPI complète)</p>
<h2 id="fichiers-modifiés">Fichiers modifiés</h2>
<ul>
<li><code>Blackboard/Architecture/pmoserver_ext.md</code> : Document
complet avec OpenAPI (660 lignes)</li>
</ul>
</article>
</body>
</html>

View File

@@ -1,54 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Pinnable_cache_item</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<p>La crâte PMOcache, implémente un system de cache qui pourrait être
étendu pour permettre une utilisation plus large. Lidée est de modifier
les règles de déletion des items. Actuellement le cache a une capacité
maximale. Et les items ont des TTL, qui peuvent être non définies.
Lorsque le cash est plein, les plus vieux items en termes dutilisation
ou ceux qui ont dépassé leur TTL peuvent être détruits. Je propose de
rajouter une fonctionnalité qui permet dépingler certains items pour
les rendre non destructibles. Ils pourraient aussi sortir du comptage
général des items pour savoir si le cache est plein.</p>
<p>Il faudra modifier la structure de la base de données. Ajouter une
colonne indiquant cette propriété. Mettre une règle métier en disant
quon ne peut pas être à la fois épinglés et avec un TTL.</p>
<p>On se moque de maintenir la compatibilité avec la base de données
actuelle, il ny a pas à prévoir de phase de transition. Nous sommes en
période de développement.</p>
</article>
</body>
</html>

View File

@@ -1,59 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pmoserver_ext</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<p>Partir des fichiers suivants:</p>
<ul>
<li>pmoapp/src/lib.rs</li>
<li>pmocontrol/src/pmoserver_ext.rs</li>
<li>pmoparadise/src/pmoserver_ext.rs</li>
<li>pmoaudiocache/src/lib.rs</li>
<li>pmomediaserver/src/paradise_streaming.rs</li>
</ul>
<p>réalise une fiche descriptive sur le pattern à réaliser pour
implémenter un trait dextension du PMO serveur.</p>
<p>Le résultat sera une documentation dimplémentation qui sera placé
dans le fichier:
<code>Blackboard/Architecture/pmoserver_ext.md</code></p>
<h2 id="round-2">Round 2</h2>
<p>Jai regardé ton document généré et je trouve que tu télargis du
sujet central documenter lecture dune extension PMOserver. Peux-tu te
recentrer sur le sujet principal.</p>
<h2 id="round-3">Round 3</h2>
<p>Je trouve que le fait de devoir déclarer et documenter les URL dans
OpenAPI / utopia était quelque chose dimportant. Remets le.</p>
</article>
</body>
</html>

View File

@@ -1,563 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MusicBoxSource</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<h1 id="musicboxsource-bibliothèque-musicale-universelle">MusicBoxSource
: Bibliothèque musicale universelle</h1>
<p>Créer une <strong>“boîte à musique”</strong> personnelle : un
catalogue unifié de morceaux provenant de nimporte quelle source
(Qobuz, URLs, fichiers locaux, Radio Paradise, etc.), avec taxonomie de
tags et playlists intelligentes.</p>
<hr />
<h2 id="vision">🎯 Vision</h2>
<h3 id="concept">Concept</h3>
<p><strong>MusicBoxSource</strong> est une bibliothèque musicale
curatoriale qui permet de : - <strong>Collecter</strong> : Ajouter des
morceaux depuis nimporte quelle source PMOMusic ou URL -
<strong>Organiser</strong> : Classifier avec une taxonomie de tags
extensible - <strong>Requêter</strong> : Créer des playlists statiques
et smart playlists (requêtes dynamiques) - <strong>Exposer</strong> :
Servir via UPnP/DIDL-Lite avec navigation multi-axes</p>
<h3 id="différence-avec-pmoplaylist">Différence avec
<code>pmoplaylist</code></h3>
<ul>
<li><strong><code>pmoplaylist</code></strong> : Playlists FIFO
<strong>éphémères</strong> pour sources live (Radio Paradise)</li>
<li><strong><code>pmomusicbox</code></strong> : Bibliothèque
<strong>persistante</strong> cross-sources avec métadonnées
enrichies</li>
</ul>
<hr />
<h2 id="architecture-globale">🏛️ Architecture globale</h2>
<pre class="mermaid">flowchart TB
subgraph Sources[Sources PMOMusic]
QOBUZ[pmoqobuz]
PARADISE[pmoparadise]
LOCAL[pmolocal - à créer]
URL[URLs directes]
end
subgraph Import[Import Layer]
IMPORTER[MusicBox Importer]
JSPF[pmojspf - Parser playlists]
META[pmometadata - Extraction]
end
subgraph Core[pmomusicbox Core]
DB[(SQLite Database)]
TAXONOMY[Taxonomie Tags]
QUERY[Smart Query Engine]
end
subgraph Cache[Cache Layer]
AUDIO[pmoaudiocache]
COVERS[pmocovers]
end
subgraph Export[Export UPnP]
SOURCE[MusicSource Trait]
DIDL[DIDL-Lite Generator]
BROWSE[Multi-Axis Browser]
end
Sources --&gt; IMPORTER
URL --&gt; IMPORTER
JSPF --&gt; IMPORTER
META --&gt; IMPORTER
IMPORTER --&gt; DB
DB --&gt; TAXONOMY
DB --&gt; QUERY
DB &lt;--&gt; AUDIO
DB &lt;--&gt; COVERS
DB --&gt; SOURCE
TAXONOMY --&gt; BROWSE
QUERY --&gt; BROWSE
SOURCE --&gt; DIDL
BROWSE --&gt; DIDL</pre>
<hr />
<h2 id="modèle-de-données-sqlite">🗄️ Modèle de données (SQLite)</h2>
<h3 id="tables-principales">Tables principales</h3>
<pre class="mermaid">erDiagram
TAG_CATEGORIES ||--o{ TAGS : contient
TAG_CATEGORIES ||--o{ TAG_CATEGORIES : parent
TAGS ||--o{ ITEM_TAGS : associe
MUSIC_ITEMS ||--o{ ITEM_TAGS : a
MUSIC_ITEMS ||--o{ PLAYLIST_ITEMS : dans
PLAYLISTS ||--o{ PLAYLIST_ITEMS : contient
TAG_CATEGORIES {
text id PK &quot;Ex: mood, genre&quot;
text name &quot;Nom affiché&quot;
text parent_id FK &quot;Hiérarchie&quot;
text color &quot;Hex color&quot;
text icon &quot;Emoji/icon&quot;
int display_order
}
TAGS {
text id PK &quot;Ex: mood:energetic&quot;
text category_id FK
text name &quot;energetic, chill&quot;
text description
text color &quot;Override&quot;
}
MUSIC_ITEMS {
text id PK &quot;UUID&quot;
text source_type &quot;qobuz, url, local&quot;
text source_id &quot;ID source&quot;
text original_uri &quot;URI source&quot;
text cache_audio_pk FK &quot;pmoaudiocache&quot;
text cache_cover_pk FK &quot;pmocovers&quot;
text title
text artist
text album
int year
int rating &quot;1-5 étoiles&quot;
int play_count
}
ITEM_TAGS {
text item_id PK,FK
text tag_id PK,FK
int added_at
text source &quot;user, auto&quot;
}
PLAYLISTS {
text id PK
text name
bool is_smart
text smart_query &quot;JSON&quot;
}
PLAYLIST_ITEMS {
text playlist_id PK,FK
text item_id FK
int position PK
}</pre>
<h3 id="tables-dassociation">Tables dassociation</h3>
<ul>
<li><strong><code>item_tags</code></strong> : Liens items ↔︎ tags
(N:M)</li>
<li><strong><code>playlist_items</code></strong> : Items dans playlists
statiques (position, ordre)</li>
<li><strong><code>tag_synonyms</code></strong> : Synonymes pour
recherche (ex: “jazz” → “swing”)</li>
</ul>
<h3 id="index-recherche">Index &amp; Recherche</h3>
<ul>
<li><strong>Indexes B-tree</strong> : artist, album, genre, year,
rating, play_count</li>
<li><strong>FTS5 (Full-Text Search)</strong> : title, artist, album,
comment</li>
<li><strong>Triggers</strong> : Maintien des tables FTS en sync avec
<code>music_items</code></li>
</ul>
<hr />
<h2 id="taxonomie-par-défaut">🎨 Taxonomie par défaut</h2>
<p>Catégories préchargées à linitialisation :</p>
<table>
<colgroup>
<col style="width: 14%" />
<col style="width: 37%" />
<col style="width: 48%" />
</colgroup>
<thead>
<tr>
<th>Catégorie</th>
<th>Description</th>
<th>Exemples de tags</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Mood</strong></td>
<td>État desprit, émotion</td>
<td>energetic, chill, melancholic, happy</td>
</tr>
<tr>
<td><strong>Genre</strong></td>
<td>Style musical</td>
<td>rock, jazz, classical, electronic, metal</td>
</tr>
<tr>
<td><strong>Era</strong></td>
<td>Période, décennie</td>
<td>60s, 70s, 80s, 90s, contemporary</td>
</tr>
<tr>
<td><strong>Occasion</strong></td>
<td>Contexte découte</td>
<td>workout, focus, party, driving, sleep</td>
</tr>
<tr>
<td><strong>Tempo</strong></td>
<td>Vitesse</td>
<td>slow, medium, fast</td>
</tr>
<tr>
<td><strong>Instrument</strong></td>
<td>Instrument dominant</td>
<td>piano, guitar, vocal, synthesizer</td>
</tr>
<tr>
<td><strong>Quality</strong></td>
<td>Qualité audio</td>
<td>lossless, high-res, remastered, live</td>
</tr>
<tr>
<td><strong>Origin</strong></td>
<td>Origine géographique</td>
<td>usa, uk, france, japan, latin, africa</td>
</tr>
</tbody>
</table>
<p><strong>Extensibilité</strong> : Lutilisateur peut créer ses propres
catégories et tags.</p>
<hr />
<h2 id="crates-architecture">📦 Crates architecture</h2>
<h3 id="pmojspf---parser-de-playlists-utilitaire">1.
<strong><code>pmojspf</code></strong> - Parser de playlists
(utilitaire)</h3>
<p><strong>But</strong> : Parser/écrire différents formats de playlists
vers/depuis un format pivot JSPF (JSON).</p>
<pre><code>pmojspf/
├── model.rs # Structures JSPF (Playlist, Track, Meta)
├── reader/
│ ├── jspf.rs # JSON natif
│ ├── xspf.rs # XML (via quick-xml ou crate xspf)
│ ├── m3u.rs # M3U/M3U8 (parsing ligne par ligne)
│ └── pls.rs # PLS (format INI-like)
└── writer.rs # Export JSPF</pre>
<p><strong>Dépendances</strong> : <code>serde</code>,
<code>serde_json</code>, <code>quick-xml</code> (ou <code>xspf</code>
crate)</p>
<p><strong>Usage</strong> : Réutilisé par <code>pmomusicbox</code> pour
import/export</p>
<hr />
<h3 id="pmomusicbox---bibliothèque-musicale-core">2.
<strong><code>pmomusicbox</code></strong> - Bibliothèque musicale
core</h3>
<p><strong>Responsabilités</strong> : - Gestion base SQLite (CRUD items,
tags, playlists) - Import depuis sources PMO (Qobuz, Paradise, Local,
URLs) - Smart playlists (query builder + exécution SQL) - Implémentation
<code>MusicSource</code> trait (exposition UPnP) - Intégration caches
audio/covers</p>
<pre><code>pmomusicbox/
├── db/
│ ├── schema.rs # DDL SQLite + migrations
│ ├── items.rs # CRUD music_items
│ ├── tags.rs # CRUD tags + taxonomie
│ ├── playlists.rs # CRUD playlists statiques
│ ├── smart.rs # Smart playlists
│ └── search.rs # Full-text search (FTS5)
├── import/
│ ├── url.rs # Import URL directe
│ ├── source.rs # Import depuis MusicSource
│ ├── local.rs # Import fichiers locaux (via pmometadata)
│ └── playlist.rs # Import JSPF/M3U8 (via pmojspf)
├── export/
│ └── playlist.rs # Export playlists (JSPF, M3U8)
├── query/
│ ├── builder.rs # SmartPlaylistQuery (DSL)
│ └── executor.rs # Génération + exécution SQL
├── didl/
│ └── generator.rs # Conversion items → DIDL-Lite
├── source.rs # Impl MusicSource trait
├── taxonomy.rs # Taxonomie par défaut + CRUD
└── config_ext.rs # Extension pmoconfig</pre>
<p><strong>Dépendances</strong> : - <code>pmosource</code>,
<code>pmoaudiocache</code>, <code>pmocovers</code>,
<code>pmodidl</code>, <code>pmometadata</code> - <code>pmojspf</code>
(import/export playlists) - <code>rusqlite</code> (features:
<code>bundled</code>, <code>serde_json</code>) - <code>uuid</code>,
<code>serde</code>, <code>tokio</code>, <code>async-trait</code></p>
<hr />
<h3 id="pmolocal---source-fichiers-locaux-à-créer">3.
<strong><code>pmolocal</code></strong> - Source fichiers locaux (à
créer)</h3>
<p><strong>But</strong> : Scanner des répertoires locaux et exposer les
fichiers audio via <code>MusicSource</code>.</p>
<pre><code>pmolocal/
├── scanner.rs # Scan récursif de répertoires
├── watcher.rs # Hot reload (notify)
├── source.rs # Impl MusicSource
└── config_ext.rs # Extension pmoconfig</pre>
<p><strong>Workflow</strong> : 1. <code>pmolocal</code> scanne
<code>/home/user/Music</code> 2. <code>pmomusicbox</code> importe les
items découverts 3. Tags automatiques basés sur métadonnées (genre,
année)</p>
<hr />
<h2 id="flux-dimport">🔄 Flux dimport</h2>
<h3 id="import-depuis-une-source-pmo-ex-qobuz">Import depuis une source
PMO (ex: Qobuz)</h3>
<pre class="mermaid">sequenceDiagram
participant QS as Qobuz Source
participant MB as MusicBox Importer
participant DB as SQLite DB
participant AC as pmoaudiocache
participant CC as pmocovers
QS-&gt;&gt;MB: get_item(object_id)
MB-&gt;&gt;QS: resolve_uri(object_id)
Note over MB: 1. Extraire métadonnées DIDL-Lite&lt;br/&gt;2. Générer UUID
MB-&gt;&gt;DB: INSERT INTO music_items
opt Auto-cache activé
MB-&gt;&gt;AC: Cache audio
MB-&gt;&gt;CC: Cache cover
AC--&gt;&gt;DB: Retourner cache_audio_pk
CC--&gt;&gt;DB: Retourner cache_cover_pk
end
MB--&gt;&gt;QS: item_id (UUID)</pre>
<h3 id="import-url-directe">Import URL directe</h3>
<pre class="mermaid">flowchart LR
URL[URL simple] --&gt; META[&quot;pmometadata&lt;br/&gt;Extraction&quot;]
META --&gt; UUID[Générer UUID]
UUID --&gt; DB[(&quot;music_items&quot;)]
DB --&gt; CACHE{&quot;Auto-cache?&quot;}
CACHE --&gt;|Oui| AC[pmoaudiocache]
CACHE --&gt;|Non| END[Fin]
AC --&gt; END</pre>
<h3 id="import-playlist-jspfm3u8">Import playlist JSPF/M3U8</h3>
<pre class="mermaid">flowchart LR
FILE[Fichier playlist] --&gt; JSPF[&quot;pmojspf&lt;br/&gt;Parser&quot;]
JSPF --&gt; STRUCT[Structure JSPF]
STRUCT --&gt; LOOP{&quot;Pour chaque track&quot;}
LOOP --&gt; IMPORT[Import comme URL]
IMPORT --&gt; DB[(&quot;music_items&quot;)]
DB --&gt; PLAYLIST[Créer playlist statique]
PLAYLIST --&gt; LINK[Lier tracks à playlist]</pre>
<hr />
<h2 id="smart-playlists-query-dsl">🔍 Smart Playlists (Query DSL)</h2>
<h3 id="concept-1">Concept</h3>
<p>Les smart playlists sont des <strong>requêtes sauvegardées</strong>
qui génèrent dynamiquement une liste de tracks.</p>
<h3 id="structure-de-requête-json">Structure de requête (JSON)</h3>
<div class="sourceCode" id="cb9"><pre
class="sourceCode json"><code class="sourceCode json"><span id="cb9-1"><a href="#cb9-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
<span id="cb9-2"><a href="#cb9-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;include_all_tags&quot;</span><span class="fu">:</span> <span class="ot">[</span><span class="st">&quot;mood:energetic&quot;</span><span class="ot">,</span> <span class="st">&quot;genre:rock&quot;</span><span class="ot">]</span><span class="fu">,</span></span>
<span id="cb9-3"><a href="#cb9-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;exclude_tags&quot;</span><span class="fu">:</span> <span class="ot">[</span><span class="st">&quot;mood:melancholic&quot;</span><span class="ot">]</span><span class="fu">,</span></span>
<span id="cb9-4"><a href="#cb9-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;year_min&quot;</span><span class="fu">:</span> <span class="dv">1980</span><span class="fu">,</span></span>
<span id="cb9-5"><a href="#cb9-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;year_max&quot;</span><span class="fu">:</span> <span class="dv">1989</span><span class="fu">,</span></span>
<span id="cb9-6"><a href="#cb9-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;min_rating&quot;</span><span class="fu">:</span> <span class="dv">4</span><span class="fu">,</span></span>
<span id="cb9-7"><a href="#cb9-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;lossless_only&quot;</span><span class="fu">:</span> <span class="kw">true</span><span class="fu">,</span></span>
<span id="cb9-8"><a href="#cb9-8" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;order_by&quot;</span><span class="fu">:</span> <span class="st">&quot;play_count&quot;</span><span class="fu">,</span></span>
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;order&quot;</span><span class="fu">:</span> <span class="st">&quot;desc&quot;</span><span class="fu">,</span></span>
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;limit&quot;</span><span class="fu">:</span> <span class="dv">50</span></span>
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
<h3 id="traduction-sql">Traduction SQL</h3>
<div class="sourceCode" id="cb10"><pre
class="sourceCode sql"><code class="sourceCode sql"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="kw">SELECT</span> <span class="op">*</span> <span class="kw">FROM</span> music_items</span>
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a><span class="kw">WHERE</span> <span class="kw">id</span> <span class="kw">IN</span> (</span>
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a> <span class="kw">SELECT</span> item_id <span class="kw">FROM</span> item_tags <span class="kw">WHERE</span> tag_id <span class="kw">IN</span> (<span class="st">&#39;mood:energetic&#39;</span>, <span class="st">&#39;genre:rock&#39;</span>)</span>
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">GROUP</span> <span class="kw">BY</span> item_id <span class="kw">HAVING</span> <span class="fu">COUNT</span>(<span class="kw">DISTINCT</span> tag_id) <span class="op">=</span> <span class="dv">2</span> <span class="co">-- ALL tags</span></span>
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a>)</span>
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> <span class="kw">id</span> <span class="kw">NOT</span> <span class="kw">IN</span> (</span>
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">SELECT</span> item_id <span class="kw">FROM</span> item_tags <span class="kw">WHERE</span> tag_id <span class="op">=</span> <span class="st">&#39;mood:melancholic&#39;</span></span>
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a>)</span>
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> <span class="dt">year</span> <span class="kw">BETWEEN</span> <span class="dv">1980</span> <span class="kw">AND</span> <span class="dv">1989</span></span>
<span id="cb10-10"><a href="#cb10-10" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> rating <span class="op">&gt;=</span> <span class="dv">4</span></span>
<span id="cb10-11"><a href="#cb10-11" aria-hidden="true" tabindex="-1"></a><span class="kw">AND</span> codec <span class="kw">IN</span> (<span class="st">&#39;flac&#39;</span>, <span class="st">&#39;alac&#39;</span>)</span>
<span id="cb10-12"><a href="#cb10-12" aria-hidden="true" tabindex="-1"></a><span class="kw">ORDER</span> <span class="kw">BY</span> play_count <span class="kw">DESC</span></span>
<span id="cb10-13"><a href="#cb10-13" aria-hidden="true" tabindex="-1"></a><span class="kw">LIMIT</span> <span class="dv">50</span>;</span></pre></div>
<hr />
<h2 id="exposition-upnp-musicsource">🎭 Exposition UPnP
(MusicSource)</h2>
<h3 id="structure-de-navigation">Structure de navigation</h3>
<pre class="mermaid">graph TB
ROOT[musicbox/] --&gt; ARTIST[by-artist/]
ROOT --&gt; ALBUM[by-album/]
ROOT --&gt; GENRE[by-genre/]
ROOT --&gt; TAG[by-tag/]
ROOT --&gt; PLAYLISTS[playlists/]
ROOT --&gt; SMART[smart-playlists/]
ROOT --&gt; FAV[favorites/]
ROOT --&gt; RECENT[recent/]
ARTIST --&gt; PF[Pink Floyd/]
ARTIST --&gt; Q[Queen/]
PF --&gt; WALL[The Wall/]
PF --&gt; WYWH[Wish You Were Here/]
WALL --&gt; ITEM1[Another Brick... 🎵]
TAG --&gt; MOOD[mood/]
TAG --&gt; OCC[occasion/]
TAG --&gt; ERA[era/]
MOOD --&gt; ENRG[energetic/]
MOOD --&gt; CHILL[chill/]
ENRG --&gt; ITEMS1[items taggués 🎵]
OCC --&gt; WORK[workout/]
OCC --&gt; FOCUS[focus/]
ERA --&gt; E80[80s/]
ERA --&gt; E90[90s/]
PLAYLISTS --&gt; PL1[My Favorites/]
PLAYLISTS --&gt; PL2[Summer 2024/]
SMART --&gt; SP1[80s Rock Workout/]
SMART --&gt; SP2[Jazz Dinner/]
style ITEM1 fill:#e1f5ff
style ITEMS1 fill:#e1f5ff</pre>
<h3 id="object-ids">Object IDs</h3>
<pre><code>musicbox:by-artist:{artist_name}
musicbox:by-album:{album_id}
musicbox:by-tag:{category}:{tag_name}
musicbox:playlist:{playlist_id}
musicbox:smart:{smart_playlist_id}
musicbox:item:{item_id}</pre>
<hr />
<h2 id="intégration-avec-lécosystème-pmomusic">🔌 Intégration avec
lécosystème PMOMusic</h2>
<h3 id="avec-pmoaudiocache">Avec pmoaudiocache</h3>
<ul>
<li>Import → Déclencher cache automatique (si
<code>auto_cache: true</code>)</li>
<li><code>resolve_uri()</code> → Retourner URI cachée si disponible</li>
</ul>
<h3 id="avec-pmocovers">Avec pmocovers</h3>
<ul>
<li>Import → Télécharger cover art</li>
<li>Browse → Inclure <code>album_art</code> dans DIDL-Lite</li>
</ul>
<h3 id="avec-pmoserver-feature-server">Avec pmoserver (feature
<code>server</code>)</h3>
<ul>
<li>API REST pour manipulation (CRUD items, tags, playlists)</li>
<li>SSE pour notifications de changements</li>
<li>Endpoints OpenAPI (utoipa)</li>
</ul>
<hr />
<h2 id="plan-dimplémentation-phases">📝 Plan dimplémentation
(Phases)</h2>
<h3 id="phase-1-fondations">Phase 1 : Fondations</h3>
<ul>
<li>Schéma SQLite complet</li>
<li>Crate <code>pmojspf</code> (parser playlists)</li>
<li>CRUD basique dans <code>pmomusicbox</code> (items, tags)</li>
<li>Taxonomie par défaut</li>
<li>Import URL simple</li>
<li>Extension pmoconfig</li>
</ul>
<h3 id="phase-2-import-cross-sources">Phase 2 : Import
cross-sources</h3>
<ul>
<li>Import depuis MusicSource (Qobuz, Paradise)</li>
<li>Import playlists (JSPF/M3U8)</li>
<li>Intégration caches (audio, covers)</li>
<li>Crate <code>pmolocal</code> (fichiers locaux)</li>
</ul>
<h3 id="phase-3-smart-playlists">Phase 3 : Smart Playlists</h3>
<ul>
<li>Query builder (DSL)</li>
<li>Exécuteur SQL</li>
<li>CRUD smart playlists</li>
<li>Export JSPF</li>
</ul>
<h3 id="phase-4-musicsource-upnp">Phase 4 : MusicSource UPnP</h3>
<ul>
<li>Implémentation trait <code>MusicSource</code></li>
<li>Génération DIDL-Lite</li>
<li>Browse multi-axes (artist, album, tag)</li>
<li>Recherche full-text (FTS5)</li>
</ul>
<h3 id="phase-5-fonctionnalités-avancées">Phase 5 : Fonctionnalités
avancées</h3>
<ul>
<li>Statistiques découte (play_count, last_played)</li>
<li>Auto-tagging (genre depuis métadonnées)</li>
<li>API REST (feature <code>server</code>)</li>
<li>Recommandations (items similaires)</li>
</ul>
<hr />
<h2 id="cas-dusage">🎯 Cas dusage</h2>
<h3 id="workflow-typique">Workflow typique</h3>
<ol type="1">
<li><strong>Découverte</strong> : Écouter Radio Paradise, tomber sur un
morceau génial</li>
<li><strong>Ajout</strong> :
<code>musicbox.import_from_source(&amp;paradise, "track-123")</code></li>
<li><strong>Organisation</strong> : Ajouter tags
<code>mood:chill</code>, <code>occasion:focus</code></li>
<li><strong>Playlist</strong> : Smart playlist “Focus Music” avec
requête <code>mood:chill + occasion:focus</code></li>
<li><strong>Écoute</strong> : Naviguer dans UPnP →
<code>musicbox/smart-playlists/Focus Music/</code></li>
</ol>
<h3 id="scénario-bibliothèque-mixte">Scénario : Bibliothèque mixte</h3>
<ul>
<li>Albums Qobuz haute résolution</li>
<li>Playlists M3U8 importées depuis iTunes</li>
<li>Fichiers FLAC locaux scannés</li>
<li>URLs de SoundCloud</li>
<li>Tracks Radio Paradise capturés</li>
</ul>
<p><strong>Tout unifié dans MusicBox, accessible via UPnP, organisé par
tags.</strong></p>
<hr />
<h2 id="références">📚 Références</h2>
<h3 id="standards">Standards</h3>
<ul>
<li><a href="https://www.xspf.org/jspf">JSPF Spec</a></li>
<li><a href="https://www.xspf.org/spec">XSPF Spec</a></li>
<li><a href="https://www.sqlite.org/fts5.html">SQLite FTS5</a></li>
</ul>
<h3 id="inspirations">Inspirations</h3>
<ul>
<li><a href="https://beets.io/">Beets</a> - Music library manager</li>
<li><a href="https://www.navidrome.org/">Navidrome</a> - Music
server</li>
<li><a href="https://picard.musicbrainz.org/">MusicBrainz Picard</a> -
Tagger</li>
</ul>
</article>
</body>
</html>

View File

@@ -1,539 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PlayListSource</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<h1 id="playlistsource-musicsource-pour-playlists">PlaylistSource :
MusicSource pour playlists</h1>
<p>Implémenter une source PMOMusic capable de servir un catalogue de
playlists hiérarchisé via UPnP.</p>
<hr />
<h2 id="décisions-de-conception">📋 Décisions de conception</h2>
<h3 id="format-pivot-jspf-json">Format pivot : JSPF (JSON)</h3>
<p><strong>Choix</strong> : JSPF comme format interne central -
Métadonnées riches (title, creator, album, annotation, image, duration,
etc.) - JSON natif avec serde (Rust-friendly) - Standard ouvert
(Xiph.Org) - Extensible via champ <code>meta</code></p>
<p><strong>Formats supportés</strong> : - ✅ <strong>JSPF</strong>
(.jspf) - JSON, format natif - ✅ <strong>XSPF</strong> (.xspf) - XML,
conversion vers JSPF - ✅ <strong>M3U8</strong> (.m3u8) - Texte,
métadonnées limitées - ✅ <strong>PLS</strong> (.pls) - INI-like, très
basique</p>
<p><strong>Architecture</strong> : 1 Writer (JSPF) + 4 Readers (JSPF,
XSPF, M3U8, PLS) → Structure JSPF centrale</p>
<pre class="mermaid">flowchart LR
JSPF[JSPF JSON] --&gt; JR[JspfReader]
XSPF[XSPF XML] --&gt; XR[XspfReader]
M3U8[M3U8 Text] --&gt; MR[M3uReader]
PLS[PLS INI] --&gt; PR[PlsReader]
JR --&gt; CORE[JSPF Structure]
XR --&gt; CORE
MR --&gt; CORE
PR --&gt; CORE
CORE --&gt; W[JspfWriter]
W --&gt; OUT[.jspf]</pre>
<hr />
<h2 id="structure-du-répertoire">🗂️ Structure du répertoire</h2>
<pre><code>playlists/
├── metadata.json # Métadonnées du conteneur racine
├── Jazz/
│ ├── metadata.json # Métadonnées catégorie Jazz
│ ├── standards.jspf
│ ├── bebop.jspf
│ └── covers/
│ └── standards.webp
├── Classical/
│ ├── metadata.json
│ ├── baroque.jspf
│ └── romantic.jspf
└── Rock/
├── metadata.json
└── 70s.jspf</pre>
<h3 id="fichier-metadata.json-conteneur">Fichier
<code>metadata.json</code> (conteneur)</h3>
<div class="sourceCode" id="cb3"><pre
class="sourceCode json"><code class="sourceCode json"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span>
<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;container&quot;</span><span class="fu">:</span> <span class="fu">{</span></span>
<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;title&quot;</span><span class="fu">:</span> <span class="st">&quot;Collection Jazz&quot;</span><span class="fu">,</span></span>
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;description&quot;</span><span class="fu">:</span> <span class="st">&quot;Mes playlists jazz favorites&quot;</span><span class="fu">,</span></span>
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;creator&quot;</span><span class="fu">:</span> <span class="st">&quot;John Doe&quot;</span><span class="fu">,</span></span>
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;image&quot;</span><span class="fu">:</span> <span class="st">&quot;covers/jazz-collection.webp&quot;</span><span class="fu">,</span></span>
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;date&quot;</span><span class="fu">:</span> <span class="st">&quot;2026-01-15&quot;</span><span class="fu">,</span></span>
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> <span class="dt">&quot;meta&quot;</span><span class="fu">:</span> <span class="ot">[</span></span>
<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a> <span class="fu">{</span><span class="dt">&quot;rel&quot;</span><span class="fu">:</span> <span class="st">&quot;genre&quot;</span><span class="fu">,</span> <span class="dt">&quot;content&quot;</span><span class="fu">:</span> <span class="st">&quot;Jazz&quot;</span><span class="fu">}</span><span class="ot">,</span></span>
<span id="cb3-10"><a href="#cb3-10" aria-hidden="true" tabindex="-1"></a> <span class="fu">{</span><span class="dt">&quot;rel&quot;</span><span class="fu">:</span> <span class="st">&quot;mood&quot;</span><span class="fu">,</span> <span class="dt">&quot;content&quot;</span><span class="fu">:</span> <span class="st">&quot;Relaxing&quot;</span><span class="fu">}</span></span>
<span id="cb3-11"><a href="#cb3-11" aria-hidden="true" tabindex="-1"></a> <span class="ot">]</span></span>
<span id="cb3-12"><a href="#cb3-12" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span>
<span id="cb3-13"><a href="#cb3-13" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></pre></div>
<hr />
<h2 id="composants-à-implémenter">🏗️ Composants à implémenter</h2>
<h3 id="crate-pmojspf-parsing-playlists">1. Crate <code>pmojspf</code>
(parsing playlists)</h3>
<p><strong>Responsabilité</strong> : Parser différents formats de
playlist vers structure JSPF unifiée</p>
<h4 id="structure">Structure</h4>
<pre><code>pmojspf/
├── Cargo.toml
├── src/
│ ├── lib.rs # API publique
│ ├── model.rs # Structures JSPF
│ ├── writer.rs # JspfWriter
│ ├── reader/
│ │ ├── mod.rs # Trait PlaylistReader
│ │ ├── jspf.rs # Reader JSON natif (serde_json)
│ │ ├── xspf.rs # Reader XML (xml-rs)
│ │ ├── m3u.rs # Reader M3U8 (parsing ligne par ligne)
│ │ └── pls.rs # Reader PLS (format INI-like)
│ └── error.rs
└── tests/
└── fixtures/</pre>
<h4 id="modèle-de-données">Modèle de données</h4>
<p><strong>Inspiré de la crate <a
href="https://crates.io/crates/xspf">xspf</a> v0.4.2</strong></p>
<div class="sourceCode" id="cb5"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">serde::</span><span class="op">{</span>Deserialize<span class="op">,</span> Serialize<span class="op">};</span></span>
<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> Jspf <span class="op">{</span></span>
<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> playlist<span class="op">:</span> JspfPlaylist<span class="op">,</span></span>
<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> <span class="bu">Default</span><span class="at">)]</span></span>
<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>rename_all <span class="op">=</span> <span class="st">&quot;camelCase&quot;</span><span class="at">)]</span></span>
<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfPlaylist <span class="op">{</span></span>
<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-14"><a href="#cb5-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-15"><a href="#cb5-15" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-16"><a href="#cb5-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> annotation<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-17"><a href="#cb5-17" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-18"><a href="#cb5-18" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> info<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-19"><a href="#cb5-19" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-20"><a href="#cb5-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> location<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-21"><a href="#cb5-21" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-22"><a href="#cb5-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> identifier<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-23"><a href="#cb5-23" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-24"><a href="#cb5-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-25"><a href="#cb5-25" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-26"><a href="#cb5-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> date<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-27"><a href="#cb5-27" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-28"><a href="#cb5-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> license<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-29"><a href="#cb5-29" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Vec::is_empty&quot;</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-30"><a href="#cb5-30" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> attribution<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>JspfAttribution<span class="op">&gt;,</span></span>
<span id="cb5-31"><a href="#cb5-31" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Vec::is_empty&quot;</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-32"><a href="#cb5-32" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>JspfMeta<span class="op">&gt;,</span></span>
<span id="cb5-33"><a href="#cb5-33" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span><span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-34"><a href="#cb5-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> track<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>JspfTrack<span class="op">&gt;,</span></span>
<span id="cb5-35"><a href="#cb5-35" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb5-36"><a href="#cb5-36" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-37"><a href="#cb5-37" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="op">,</span> <span class="bu">Default</span><span class="at">)]</span></span>
<span id="cb5-38"><a href="#cb5-38" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>rename_all <span class="op">=</span> <span class="st">&quot;camelCase&quot;</span><span class="at">)]</span></span>
<span id="cb5-39"><a href="#cb5-39" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfTrack <span class="op">{</span></span>
<span id="cb5-40"><a href="#cb5-40" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Vec::is_empty&quot;</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-41"><a href="#cb5-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> location<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-42"><a href="#cb5-42" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Vec::is_empty&quot;</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-43"><a href="#cb5-43" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> identifier<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-44"><a href="#cb5-44" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-45"><a href="#cb5-45" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-46"><a href="#cb5-46" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-47"><a href="#cb5-47" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-48"><a href="#cb5-48" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-49"><a href="#cb5-49" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> annotation<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-50"><a href="#cb5-50" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-51"><a href="#cb5-51" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> info<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-52"><a href="#cb5-52" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-53"><a href="#cb5-53" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-54"><a href="#cb5-54" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-55"><a href="#cb5-55" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> album<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb5-56"><a href="#cb5-56" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-57"><a href="#cb5-57" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> track_num<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;,</span></span>
<span id="cb5-58"><a href="#cb5-58" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Option::is_none&quot;</span><span class="at">)]</span></span>
<span id="cb5-59"><a href="#cb5-59" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> duration<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">u64</span><span class="op">&gt;,</span> <span class="co">// millisecondes</span></span>
<span id="cb5-60"><a href="#cb5-60" aria-hidden="true" tabindex="-1"></a> <span class="at">#[</span>serde<span class="at">(</span>skip_serializing_if <span class="op">=</span> <span class="st">&quot;Vec::is_empty&quot;</span><span class="op">,</span> <span class="kw">default</span><span class="at">)]</span></span>
<span id="cb5-61"><a href="#cb5-61" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>JspfMeta<span class="op">&gt;,</span></span>
<span id="cb5-62"><a href="#cb5-62" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb5-63"><a href="#cb5-63" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-64"><a href="#cb5-64" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
<span id="cb5-65"><a href="#cb5-65" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>serde<span class="at">(</span>untagged<span class="at">)]</span></span>
<span id="cb5-66"><a href="#cb5-66" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> JspfAttribution <span class="op">{</span></span>
<span id="cb5-67"><a href="#cb5-67" aria-hidden="true" tabindex="-1"></a> Location <span class="op">{</span> location<span class="op">:</span> <span class="dt">String</span> <span class="op">},</span></span>
<span id="cb5-68"><a href="#cb5-68" aria-hidden="true" tabindex="-1"></a> Identifier <span class="op">{</span> identifier<span class="op">:</span> <span class="dt">String</span> <span class="op">},</span></span>
<span id="cb5-69"><a href="#cb5-69" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb5-70"><a href="#cb5-70" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb5-71"><a href="#cb5-71" aria-hidden="true" tabindex="-1"></a><span class="at">#[</span>derive<span class="at">(</span><span class="bu">Debug</span><span class="op">,</span> <span class="bu">Clone</span><span class="op">,</span> Serialize<span class="op">,</span> Deserialize<span class="at">)]</span></span>
<span id="cb5-72"><a href="#cb5-72" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfMeta <span class="op">{</span></span>
<span id="cb5-73"><a href="#cb5-73" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> rel<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb5-74"><a href="#cb5-74" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> content<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb5-75"><a href="#cb5-75" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="trait-playlistreader">Trait PlaylistReader</h4>
<div class="sourceCode" id="cb6"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb6-1"><a href="#cb6-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::io::</span><span class="bu">Read</span><span class="op">;</span></span>
<span id="cb6-2"><a href="#cb6-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb6-3"><a href="#cb6-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> PlaylistReader <span class="op">{</span></span>
<span id="cb6-4"><a href="#cb6-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;;</span></span>
<span id="cb6-5"><a href="#cb6-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> from_str(s<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;;</span></span>
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> from_file<span class="op">&lt;</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op">&lt;</span><span class="dt">Path</span><span class="op">&gt;&gt;</span>(path<span class="op">:</span> P) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;;</span></span>
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="implémentations-des-readers">Implémentations des Readers</h4>
<h5 id="jspfreader-simple---serde_json">JspfReader (✅ Simple -
serde_json)</h5>
<div class="sourceCode" id="cb7"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb7-1"><a href="#cb7-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfReader<span class="op">;</span></span>
<span id="cb7-2"><a href="#cb7-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb7-3"><a href="#cb7-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> JspfReader <span class="op">{</span></span>
<span id="cb7-4"><a href="#cb7-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb7-5"><a href="#cb7-5" aria-hidden="true" tabindex="-1"></a> <span class="pp">serde_json::</span>from_reader(reader)</span>
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>map_err(<span class="op">|</span>e<span class="op">|</span> <span class="bu">Error</span><span class="pp">::</span>ParseError(<span class="pp">format!</span>(<span class="st">&quot;JSON: {}&quot;</span><span class="op">,</span> e)))</span>
<span id="cb7-7"><a href="#cb7-7" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb7-8"><a href="#cb7-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Dépendances</strong> : <code>serde_json</code></p>
<h5 id="xspfreader-complexe---xml-rs">XspfReader (⚠️ Complexe -
xml-rs)</h5>
<p><strong>Approche</strong> : Machine à états XML pour parser
<code>&lt;playlist&gt;</code>, <code>&lt;track&gt;</code>, etc.</p>
<p><strong>Alternative</strong> : Utiliser la crate <code>xspf</code>
existante puis convertir → JSPF</p>
<div class="sourceCode" id="cb8"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb8-1"><a href="#cb8-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> XspfReader<span class="op">;</span></span>
<span id="cb8-2"><a href="#cb8-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb8-3"><a href="#cb8-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> XspfReader <span class="op">{</span></span>
<span id="cb8-4"><a href="#cb8-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb8-5"><a href="#cb8-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser XML avec EventReader</span></span>
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// État : in_playlist, in_track, current_element</span></span>
<span id="cb8-7"><a href="#cb8-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Mapping: &lt;title&gt; → playlist.title, &lt;track&gt; → JspfTrack</span></span>
<span id="cb8-8"><a href="#cb8-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb8-9"><a href="#cb8-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Dépendances</strong> : <code>xml-rs</code> ou réutiliser
<code>xspf</code> crate</p>
<h5 id="m3ureader-modéré---ligne-par-ligne">M3uReader (⚙️ Modéré - ligne
par ligne)</h5>
<p><strong>Format</strong> :</p>
<pre class="m3u"><code>#EXTM3U
#PLAYLIST:Ma Playlist Jazz
#EXTINF:284,John Coltrane - Giant Steps
#EXTART:John Coltrane
#EXTALB:Giant Steps
file:///music/coltrane.flac</pre>
<div class="sourceCode" id="cb10"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb10-1"><a href="#cb10-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> M3uReader<span class="op">;</span></span>
<span id="cb10-2"><a href="#cb10-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb10-3"><a href="#cb10-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> M3uReader <span class="op">{</span></span>
<span id="cb10-4"><a href="#cb10-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb10-5"><a href="#cb10-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// BufReader ligne par ligne</span></span>
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser #EXTINF:duration,artist - title</span></span>
<span id="cb10-7"><a href="#cb10-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Gérer extensions non-standard (#EXTART, #EXTALB, #EXTIMG)</span></span>
<span id="cb10-8"><a href="#cb10-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb10-9"><a href="#cb10-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Dépendances</strong> : stdlib uniquement</p>
<p><strong>Limitations</strong> : Métadonnées pauvres, beaucoup de
champs <code>None</code></p>
<h5 id="plsreader-modéré---format-ini">PlsReader (⚙️ Modéré - format
INI)</h5>
<p><strong>Format</strong> :</p>
<div class="sourceCode" id="cb11"><pre
class="sourceCode ini"><code class="sourceCode ini"><span id="cb11-1"><a href="#cb11-1" aria-hidden="true" tabindex="-1"></a><span class="kw">[playlist]</span></span>
<span id="cb11-2"><a href="#cb11-2" aria-hidden="true" tabindex="-1"></a><span class="dt">NumberOfEntries</span><span class="ot">=</span><span class="dv">2</span></span>
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a><span class="dt">File1</span><span class="ot">=</span><span class="st">file:///music/coltrane.flac</span></span>
<span id="cb11-4"><a href="#cb11-4" aria-hidden="true" tabindex="-1"></a><span class="dt">Title1</span><span class="ot">=</span><span class="st">John Coltrane - Giant Steps</span></span>
<span id="cb11-5"><a href="#cb11-5" aria-hidden="true" tabindex="-1"></a><span class="dt">Length1</span><span class="ot">=</span><span class="dv">284</span></span></pre></div>
<div class="sourceCode" id="cb12"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb12-1"><a href="#cb12-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> PlsReader<span class="op">;</span></span>
<span id="cb12-2"><a href="#cb12-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb12-3"><a href="#cb12-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistReader <span class="cf">for</span> PlsReader <span class="op">{</span></span>
<span id="cb12-4"><a href="#cb12-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> read<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// HashMap&lt;index, (file, title, duration)&gt;</span></span>
<span id="cb12-6"><a href="#cb12-6" aria-hidden="true" tabindex="-1"></a> <span class="co">// Parser FileN=..., TitleN=..., LengthN=...</span></span>
<span id="cb12-7"><a href="#cb12-7" aria-hidden="true" tabindex="-1"></a> <span class="co">// Trier par index et convertir en JspfTrack</span></span>
<span id="cb12-8"><a href="#cb12-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb12-9"><a href="#cb12-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Dépendances</strong> : stdlib uniquement</p>
<p><strong>Limitations</strong> : File, Title, Length seulement</p>
<h4 id="jspfwriter">JspfWriter</h4>
<div class="sourceCode" id="cb13"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb13-1"><a href="#cb13-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> JspfWriter<span class="op">;</span></span>
<span id="cb13-2"><a href="#cb13-2" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb13-3"><a href="#cb13-3" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> JspfWriter <span class="op">{</span></span>
<span id="cb13-4"><a href="#cb13-4" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> write<span class="op">&lt;</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">&gt;</span>(jspf<span class="op">:</span> <span class="op">&amp;</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;;</span></span>
<span id="cb13-5"><a href="#cb13-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> write_pretty<span class="op">&lt;</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">&gt;</span>(jspf<span class="op">:</span> <span class="op">&amp;</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;;</span></span>
<span id="cb13-6"><a href="#cb13-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> to_string(jspf<span class="op">:</span> <span class="op">&amp;</span>Jspf) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;;</span></span>
<span id="cb13-7"><a href="#cb13-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> to_string_pretty(jspf<span class="op">:</span> <span class="op">&amp;</span>Jspf) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;;</span></span>
<span id="cb13-8"><a href="#cb13-8" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="api-publique">API publique</h4>
<div class="sourceCode" id="cb14"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb14-1"><a href="#cb14-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">model::</span><span class="op">{</span>Jspf<span class="op">,</span> JspfPlaylist<span class="op">,</span> JspfTrack<span class="op">,</span> JspfMeta<span class="op">,</span> JspfAttribution<span class="op">};</span></span>
<span id="cb14-2"><a href="#cb14-2" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">reader::</span><span class="op">{</span>PlaylistReader<span class="op">,</span> JspfReader<span class="op">,</span> XspfReader<span class="op">,</span> M3uReader<span class="op">,</span> PlsReader<span class="op">};</span></span>
<span id="cb14-3"><a href="#cb14-3" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">use</span> <span class="pp">writer::</span>JspfWriter<span class="op">;</span></span>
<span id="cb14-4"><a href="#cb14-4" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-5"><a href="#cb14-5" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">enum</span> PlaylistFormat <span class="op">{</span></span>
<span id="cb14-6"><a href="#cb14-6" aria-hidden="true" tabindex="-1"></a> Jspf<span class="op">,</span></span>
<span id="cb14-7"><a href="#cb14-7" aria-hidden="true" tabindex="-1"></a> Xspf<span class="op">,</span></span>
<span id="cb14-8"><a href="#cb14-8" aria-hidden="true" tabindex="-1"></a> M3u8<span class="op">,</span></span>
<span id="cb14-9"><a href="#cb14-9" aria-hidden="true" tabindex="-1"></a> Pls<span class="op">,</span></span>
<span id="cb14-10"><a href="#cb14-10" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb14-11"><a href="#cb14-11" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-12"><a href="#cb14-12" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistFormat <span class="op">{</span></span>
<span id="cb14-13"><a href="#cb14-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> <span class="kw">fn</span> from_extension(ext<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span>) <span class="op">-&gt;</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">Self</span><span class="op">&gt;;</span></span>
<span id="cb14-14"><a href="#cb14-14" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb14-15"><a href="#cb14-15" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb14-16"><a href="#cb14-16" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">fn</span> read_playlist<span class="op">&lt;</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">&gt;</span>(reader<span class="op">:</span> R<span class="op">,</span> format<span class="op">:</span> PlaylistFormat) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>Jspf<span class="op">&gt;;</span></span></pre></div>
<hr />
<h3 id="crate-pmoplaylists-playlistsource">2. Crate
<code>pmoplaylists</code> (PlaylistSource)</h3>
<p><strong>Responsabilité</strong> : Implémenter
<code>MusicSource</code> pour servir playlists via UPnP</p>
<h4 id="structures-principales">Structures principales</h4>
<div class="sourceCode" id="cb15"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb15-1"><a href="#cb15-1" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> PlaylistSource <span class="op">{</span></span>
<span id="cb15-2"><a href="#cb15-2" aria-hidden="true" tabindex="-1"></a> root_path<span class="op">:</span> <span class="dt">PathBuf</span><span class="op">,</span></span>
<span id="cb15-3"><a href="#cb15-3" aria-hidden="true" tabindex="-1"></a> playlists<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>HashMap<span class="op">&lt;</span><span class="dt">String</span><span class="op">,</span> ParsedPlaylist<span class="op">&gt;&gt;&gt;,</span></span>
<span id="cb15-4"><a href="#cb15-4" aria-hidden="true" tabindex="-1"></a> containers<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>HashMap<span class="op">&lt;</span><span class="dt">PathBuf</span><span class="op">,</span> ContainerMetadata<span class="op">&gt;&gt;&gt;,</span></span>
<span id="cb15-5"><a href="#cb15-5" aria-hidden="true" tabindex="-1"></a> watcher<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="pp">notify::</span>RecommendedWatcher<span class="op">&gt;,</span></span>
<span id="cb15-6"><a href="#cb15-6" aria-hidden="true" tabindex="-1"></a> base_url<span class="op">:</span> <span class="dt">String</span><span class="op">,</span></span>
<span id="cb15-7"><a href="#cb15-7" aria-hidden="true" tabindex="-1"></a> update_counter<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span><span class="dt">u32</span><span class="op">&gt;&gt;,</span></span>
<span id="cb15-8"><a href="#cb15-8" aria-hidden="true" tabindex="-1"></a> last_change<span class="op">:</span> Arc<span class="op">&lt;</span>RwLock<span class="op">&lt;</span>SystemTime<span class="op">&gt;&gt;,</span></span>
<span id="cb15-9"><a href="#cb15-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb15-10"><a href="#cb15-10" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-11"><a href="#cb15-11" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ParsedPlaylist <span class="op">{</span></span>
<span id="cb15-12"><a href="#cb15-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> metadata<span class="op">:</span> PlaylistMetadata<span class="op">,</span></span>
<span id="cb15-13"><a href="#cb15-13" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> tracks<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>PlaylistTrack<span class="op">&gt;,</span></span>
<span id="cb15-14"><a href="#cb15-14" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> source_path<span class="op">:</span> <span class="dt">PathBuf</span><span class="op">,</span></span>
<span id="cb15-15"><a href="#cb15-15" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> format<span class="op">:</span> PlaylistFormat<span class="op">,</span></span>
<span id="cb15-16"><a href="#cb15-16" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb15-17"><a href="#cb15-17" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-18"><a href="#cb15-18" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ContainerMetadata <span class="op">{</span></span>
<span id="cb15-19"><a href="#cb15-19" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> title<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb15-20"><a href="#cb15-20" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> description<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb15-21"><a href="#cb15-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> creator<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb15-22"><a href="#cb15-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> image<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb15-23"><a href="#cb15-23" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> date<span class="op">:</span> <span class="dt">Option</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;,</span></span>
<span id="cb15-24"><a href="#cb15-24" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> meta<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span>MetaEntry<span class="op">&gt;,</span></span>
<span id="cb15-25"><a href="#cb15-25" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb15-26"><a href="#cb15-26" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb15-27"><a href="#cb15-27" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">struct</span> ContainerMetadataFile <span class="op">{</span></span>
<span id="cb15-28"><a href="#cb15-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">pub</span> container<span class="op">:</span> ContainerMetadata<span class="op">,</span></span>
<span id="cb15-29"><a href="#cb15-29" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<h4 id="fonctionnalités">Fonctionnalités</h4>
<ol type="1">
<li><strong>Scan hiérarchique</strong> : Parser récursivement dossiers +
<code>metadata.json</code> + playlists</li>
<li><strong>Cache</strong> : Éviter re-parsing (playlists +
conteneurs)</li>
<li><strong>Hot reload</strong> : <code>notify</code> pour détecter
changements</li>
<li><strong>Browse UPnP</strong> : Générer DIDL-Lite avec métadonnées
conteneurs</li>
<li><strong>Content resolution</strong> : Résoudre URIs via
<code>SourceCacheManager</code></li>
<li><strong>Cover art</strong> : Servir images playlists, tracks,
conteneurs</li>
</ol>
<h4 id="object-ids">Object IDs</h4>
<pre><code>playlists # Racine
playlists:category:{path} # Catégorie (dossier)
playlists:playlist:{id} # Playlist
playlists:playlist:{id}:track:{index} # Track dans playlist</pre>
<h4 id="gestion-metadata.json">Gestion <code>metadata.json</code></h4>
<div class="sourceCode" id="cb17"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb17-1"><a href="#cb17-1" aria-hidden="true" tabindex="-1"></a><span class="kw">fn</span> load_container_metadata(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> dir_path<span class="op">:</span> <span class="op">&amp;</span><span class="dt">Path</span>) <span class="op">-&gt;</span> <span class="dt">Result</span><span class="op">&lt;</span>ContainerMetadata<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb17-2"><a href="#cb17-2" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> metadata_path <span class="op">=</span> dir_path<span class="op">.</span>join(<span class="st">&quot;metadata.json&quot;</span>)<span class="op">;</span></span>
<span id="cb17-3"><a href="#cb17-3" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb17-4"><a href="#cb17-4" aria-hidden="true" tabindex="-1"></a> <span class="cf">if</span> metadata_path<span class="op">.</span>exists() <span class="op">{</span></span>
<span id="cb17-5"><a href="#cb17-5" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> content <span class="op">=</span> <span class="pp">fs::</span>read_to_string(<span class="op">&amp;</span>metadata_path)<span class="op">?;</span></span>
<span id="cb17-6"><a href="#cb17-6" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> file<span class="op">:</span> ContainerMetadataFile <span class="op">=</span> <span class="pp">serde_json::</span>from_str(<span class="op">&amp;</span>content)<span class="op">?;</span></span>
<span id="cb17-7"><a href="#cb17-7" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(file<span class="op">.</span>container)</span>
<span id="cb17-8"><a href="#cb17-8" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span> <span class="cf">else</span> <span class="op">{</span></span>
<span id="cb17-9"><a href="#cb17-9" aria-hidden="true" tabindex="-1"></a> <span class="co">// Fallback : nom du répertoire</span></span>
<span id="cb17-10"><a href="#cb17-10" aria-hidden="true" tabindex="-1"></a> <span class="cn">Ok</span>(ContainerMetadata <span class="op">{</span></span>
<span id="cb17-11"><a href="#cb17-11" aria-hidden="true" tabindex="-1"></a> title<span class="op">:</span> <span class="cn">Some</span>(dir_path<span class="op">.</span>file_name()<span class="op">?.</span>to_str()<span class="op">?.</span>to_string())<span class="op">,</span></span>
<span id="cb17-12"><a href="#cb17-12" aria-hidden="true" tabindex="-1"></a> <span class="op">..</span><span class="bu">Default</span><span class="pp">::</span><span class="kw">default</span>()</span>
<span id="cb17-13"><a href="#cb17-13" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb17-14"><a href="#cb17-14" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb17-15"><a href="#cb17-15" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<hr />
<h3 id="extension-pmoconfig">3. Extension pmoconfig</h3>
<p><strong>Fichier</strong> :
<code>pmoplaylists/src/config_ext.rs</code></p>
<p><strong>Pattern</strong> : <a
href="../Architecture/pmoconfig_ext.md">pmoconfig_ext.md</a></p>
<div class="sourceCode" id="cb18"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb18-1"><a href="#cb18-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoconfig::</span>Config<span class="op">;</span></span>
<span id="cb18-2"><a href="#cb18-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">std::path::</span><span class="op">{</span><span class="dt">Path</span><span class="op">,</span> <span class="dt">PathBuf</span><span class="op">};</span></span>
<span id="cb18-3"><a href="#cb18-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb18-4"><a href="#cb18-4" aria-hidden="true" tabindex="-1"></a><span class="kw">const</span> DEFAULT_PLAYLISTS_DIR<span class="op">:</span> <span class="op">&amp;</span><span class="dt">str</span> <span class="op">=</span> <span class="st">&quot;playlists&quot;</span><span class="op">;</span></span>
<span id="cb18-5"><a href="#cb18-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb18-6"><a href="#cb18-6" aria-hidden="true" tabindex="-1"></a><span class="kw">pub</span> <span class="kw">trait</span> PlaylistSourceConfigExt <span class="op">{</span></span>
<span id="cb18-7"><a href="#cb18-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_dir(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">PathBuf</span><span class="op">;</span></span>
<span id="cb18-8"><a href="#cb18-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_dir<span class="op">&lt;</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op">&lt;</span><span class="dt">Path</span><span class="op">&gt;&gt;</span>(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;;</span></span>
<span id="cb18-9"><a href="#cb18-9" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_enabled(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">bool</span><span class="op">;</span></span>
<span id="cb18-10"><a href="#cb18-10" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_enabled(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;;</span></span>
<span id="cb18-11"><a href="#cb18-11" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_supported_formats(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;;</span></span>
<span id="cb18-12"><a href="#cb18-12" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_supported_formats(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;;</span></span>
<span id="cb18-13"><a href="#cb18-13" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span>
<span id="cb18-14"><a href="#cb18-14" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb18-15"><a href="#cb18-15" aria-hidden="true" tabindex="-1"></a><span class="kw">impl</span> PlaylistSourceConfigExt <span class="cf">for</span> Config <span class="op">{</span></span>
<span id="cb18-16"><a href="#cb18-16" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_dir(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">PathBuf</span> <span class="op">{</span></span>
<span id="cb18-17"><a href="#cb18-17" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_managed_dir(<span class="st">&quot;sources.playlists.directory&quot;</span><span class="op">,</span> DEFAULT_PLAYLISTS_DIR)</span>
<span id="cb18-18"><a href="#cb18-18" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>expect(<span class="st">&quot;Failed to get playlists directory&quot;</span>)</span>
<span id="cb18-19"><a href="#cb18-19" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-20"><a href="#cb18-20" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-21"><a href="#cb18-21" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_dir<span class="op">&lt;</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op">&lt;</span><span class="dt">Path</span><span class="op">&gt;&gt;</span>(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb18-22"><a href="#cb18-22" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_managed_dir(<span class="st">&quot;sources.playlists.directory&quot;</span><span class="op">,</span> path)</span>
<span id="cb18-23"><a href="#cb18-23" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-24"><a href="#cb18-24" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-25"><a href="#cb18-25" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_enabled(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">bool</span> <span class="op">{</span></span>
<span id="cb18-26"><a href="#cb18-26" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_value(<span class="st">&quot;sources.playlists.enabled&quot;</span>)</span>
<span id="cb18-27"><a href="#cb18-27" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_else(<span class="op">|</span>_<span class="op">|</span> <span class="op">{</span></span>
<span id="cb18-28"><a href="#cb18-28" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> _ <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">&quot;sources.playlists.enabled&quot;</span><span class="op">,</span> <span class="cn">true</span>)<span class="op">;</span></span>
<span id="cb18-29"><a href="#cb18-29" aria-hidden="true" tabindex="-1"></a> <span class="cn">true</span></span>
<span id="cb18-30"><a href="#cb18-30" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb18-31"><a href="#cb18-31" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-32"><a href="#cb18-32" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-33"><a href="#cb18-33" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_enabled(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb18-34"><a href="#cb18-34" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">&quot;sources.playlists.enabled&quot;</span><span class="op">,</span> enabled)</span>
<span id="cb18-35"><a href="#cb18-35" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-36"><a href="#cb18-36" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-37"><a href="#cb18-37" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> get_playlists_supported_formats(<span class="op">&amp;</span><span class="kw">self</span>) <span class="op">-&gt;</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb18-38"><a href="#cb18-38" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>get_value(<span class="st">&quot;sources.playlists.formats&quot;</span>)</span>
<span id="cb18-39"><a href="#cb18-39" aria-hidden="true" tabindex="-1"></a> <span class="op">.</span>unwrap_or_else(<span class="op">|</span>_<span class="op">|</span> <span class="op">{</span></span>
<span id="cb18-40"><a href="#cb18-40" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> <span class="kw">default</span> <span class="op">=</span> <span class="pp">vec!</span>[<span class="st">&quot;jspf&quot;</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">&quot;xspf&quot;</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">&quot;m3u8&quot;</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">&quot;pls&quot;</span><span class="op">.</span>into()]<span class="op">;</span></span>
<span id="cb18-41"><a href="#cb18-41" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> _ <span class="op">=</span> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">&quot;sources.playlists.formats&quot;</span><span class="op">,</span> <span class="op">&amp;</span><span class="kw">default</span>)<span class="op">;</span></span>
<span id="cb18-42"><a href="#cb18-42" aria-hidden="true" tabindex="-1"></a> <span class="kw">default</span></span>
<span id="cb18-43"><a href="#cb18-43" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span>)</span>
<span id="cb18-44"><a href="#cb18-44" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-45"><a href="#cb18-45" aria-hidden="true" tabindex="-1"></a> </span>
<span id="cb18-46"><a href="#cb18-46" aria-hidden="true" tabindex="-1"></a> <span class="kw">fn</span> set_playlists_supported_formats(<span class="op">&amp;</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op">&lt;</span><span class="dt">String</span><span class="op">&gt;</span>) <span class="op">-&gt;</span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op">&lt;</span>()<span class="op">&gt;</span> <span class="op">{</span></span>
<span id="cb18-47"><a href="#cb18-47" aria-hidden="true" tabindex="-1"></a> <span class="kw">self</span><span class="op">.</span>set_value(<span class="st">&quot;sources.playlists.formats&quot;</span><span class="op">,</span> formats)</span>
<span id="cb18-48"><a href="#cb18-48" aria-hidden="true" tabindex="-1"></a> <span class="op">}</span></span>
<span id="cb18-49"><a href="#cb18-49" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<p><strong>Config YAML</strong> :</p>
<div class="sourceCode" id="cb19"><pre
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb19-1"><a href="#cb19-1" aria-hidden="true" tabindex="-1"></a><span class="fu">sources</span><span class="kw">:</span></span>
<span id="cb19-2"><a href="#cb19-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">playlists</span><span class="kw">:</span></span>
<span id="cb19-3"><a href="#cb19-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enabled</span><span class="kw">:</span><span class="at"> </span><span class="ch">true</span></span>
<span id="cb19-4"><a href="#cb19-4" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">directory</span><span class="kw">:</span><span class="at"> </span><span class="st">&quot;playlists&quot;</span></span>
<span id="cb19-5"><a href="#cb19-5" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">formats</span><span class="kw">:</span></span>
<span id="cb19-6"><a href="#cb19-6" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> jspf</span></span>
<span id="cb19-7"><a href="#cb19-7" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> xspf</span></span>
<span id="cb19-8"><a href="#cb19-8" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> m3u8</span></span>
<span id="cb19-9"><a href="#cb19-9" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="kw">-</span><span class="at"> pls</span></span></pre></div>
<p><strong>Utilisation</strong> :</p>
<div class="sourceCode" id="cb20"><pre
class="sourceCode rust"><code class="sourceCode rust"><span id="cb20-1"><a href="#cb20-1" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoconfig::</span>Config<span class="op">;</span></span>
<span id="cb20-2"><a href="#cb20-2" aria-hidden="true" tabindex="-1"></a><span class="kw">use</span> <span class="pp">pmoplaylists::config_ext::</span>PlaylistSourceConfigExt<span class="op">;</span></span>
<span id="cb20-3"><a href="#cb20-3" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb20-4"><a href="#cb20-4" aria-hidden="true" tabindex="-1"></a><span class="kw">let</span> config <span class="op">=</span> <span class="pp">Config::</span>load()<span class="op">?;</span></span>
<span id="cb20-5"><a href="#cb20-5" aria-hidden="true" tabindex="-1"></a></span>
<span id="cb20-6"><a href="#cb20-6" aria-hidden="true" tabindex="-1"></a><span class="cf">if</span> config<span class="op">.</span>get_playlists_enabled() <span class="op">{</span></span>
<span id="cb20-7"><a href="#cb20-7" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlists_dir <span class="op">=</span> config<span class="op">.</span>get_playlists_dir()<span class="op">;</span></span>
<span id="cb20-8"><a href="#cb20-8" aria-hidden="true" tabindex="-1"></a> <span class="kw">let</span> playlist_source <span class="op">=</span> <span class="pp">PlaylistSource::</span>new(playlists_dir<span class="op">,</span> config<span class="op">.</span>clone())<span class="op">?;</span></span>
<span id="cb20-9"><a href="#cb20-9" aria-hidden="true" tabindex="-1"></a><span class="op">}</span></span></pre></div>
<hr />
<h2 id="intégration-musicbrainz-optionnelle---phase-2">🔌 Intégration
MusicBrainz (optionnelle - Phase 2)</h2>
<h3 id="crate-recommandée-musicbrainz_rs">Crate recommandée :
<code>musicbrainz_rs</code></h3>
<p><a href="https://crates.io/crates/musicbrainz_rs">musicbrainz_rs</a>
v0.5+ - Client async/blocking - Rate limiting automatique (1 req/sec) -
Support CoverArt Archive - MSRV: Rust 1.71.1</p>
<h3 id="cas-dusage">Cas dusage</h3>
<ol type="1">
<li><p><strong>Résolution didentifiants</strong> :</p>
<div class="sourceCode" id="cb21"><pre
class="sourceCode json"><code class="sourceCode json"><span id="cb21-1"><a href="#cb21-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span><span class="dt">&quot;identifier&quot;</span><span class="fu">:</span> <span class="ot">[</span><span class="st">&quot;musicbrainz://recording/abc123&quot;</span><span class="ot">]</span><span class="fu">,</span> <span class="dt">&quot;title&quot;</span><span class="fu">:</span> <span class="kw">null</span><span class="fu">}</span></span></pre></div>
<p>→ Récupérer métadonnées depuis MusicBrainz</p></li>
<li><p><strong>Enrichissement playlists pauvres</strong> : M3U8/PLS →
MusicBrainz → métadonnées complètes</p></li>
<li><p><strong>Cover art</strong> : CoverArt Archive</p></li>
</ol>
<h3 id="configuration">Configuration</h3>
<div class="sourceCode" id="cb22"><pre
class="sourceCode yaml"><code class="sourceCode yaml"><span id="cb22-1"><a href="#cb22-1" aria-hidden="true" tabindex="-1"></a><span class="fu">sources</span><span class="kw">:</span></span>
<span id="cb22-2"><a href="#cb22-2" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">playlists</span><span class="kw">:</span></span>
<span id="cb22-3"><a href="#cb22-3" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">musicbrainz</span><span class="kw">:</span></span>
<span id="cb22-4"><a href="#cb22-4" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enabled</span><span class="kw">:</span><span class="at"> </span><span class="ch">false</span></span>
<span id="cb22-5"><a href="#cb22-5" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">enrich_metadata</span><span class="kw">:</span><span class="at"> </span><span class="ch">false</span></span>
<span id="cb22-6"><a href="#cb22-6" aria-hidden="true" tabindex="-1"></a><span class="at"> </span><span class="fu">rate_limit_per_sec</span><span class="kw">:</span><span class="at"> </span><span class="dv">1</span></span></pre></div>
<p><strong>Stratégie</strong> : - <strong>Phase 1 (MVP)</strong> : Ne
pas implémenter, stocker identifiants tel quel - <strong>Phase
2</strong> : Dépendance optionnelle, service asynchrone,
configurable</p>
<hr />
<h2 id="prochaines-étapes">📝 Prochaines étapes</h2>
<ol type="1">
<li>✅ Choix format : JSPF central</li>
<li>✅ Modèle données : Structures JSPF</li>
<li>✅ Extension pmoconfig : Trait défini</li>
<li><strong>Implémenter <code>pmojspf</code></strong> :
<ul>
<li><code>JspfReader</code> (serde_json)</li>
<li><code>XspfReader</code> (xml-rs ou crate xspf)</li>
<li><code>M3uReader</code> (parsing ligne par ligne)</li>
<li><code>PlsReader</code> (format INI)</li>
<li><code>JspfWriter</code> (serde_json)</li>
</ul></li>
<li><strong>Implémenter <code>pmoplaylists</code></strong> :
<ul>
<li><code>PlaylistSource</code> (trait <code>MusicSource</code>)</li>
<li>Scan hiérarchique + cache</li>
<li>Hot reload (notify)</li>
<li>Browse UPnP (DIDL-Lite)</li>
<li>Gestion <code>metadata.json</code></li>
</ul></li>
<li>⏳ Tests avec clients UPnP</li>
</ol>
<hr />
<h2 id="sources">📚 Sources</h2>
<h3 id="spécifications">Spécifications</h3>
<ul>
<li><a href="https://www.xspf.org/spec">XSPF Spec</a></li>
<li><a href="https://www.xspf.org/jspf">JSPF Spec</a></li>
<li><a href="https://en.wikipedia.org/wiki/M3U">M3U - Wikipedia</a></li>
<li><a href="https://en.wikipedia.org/wiki/PLS_(file_format)">PLS -
Wikipedia</a></li>
</ul>
<h3 id="crates-rust">Crates Rust</h3>
<ul>
<li><a href="https://crates.io/crates/xspf">xspf</a> - Parser XML
XSPF</li>
<li><a href="https://crates.io/crates/musicbrainz_rs">musicbrainz_rs</a>
- API MusicBrainz</li>
<li><a href="https://musicbrainz.org/doc/MusicBrainz_API">MusicBrainz
API Docs</a></li>
</ul>
</article>
</body>
</html>

View File

@@ -1,55 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>config_ext</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<p>Partir des fichiers suivants:</p>
<ul>
<li>pmocovers/src/config_ext.rs</li>
<li>pmoaudiocache/src/config_ext.rs</li>
<li>pmoqobuz/src/config_ext.rs</li>
<li>pmocache/src/config_ext.rs</li>
<li>pmoconfig/PASSWORD_ENCRYPTION.md</li>
<li>pmoupnp/src/config_ext.rs</li>
<li>pmoparadise/src/config_ext.rs</li>
</ul>
<p>réalise une fiche descriptive sur le pattern à réaliser pour
implémenter un trait dextension de PMOConfig (pmoconfig::Config).</p>
<p>Le résultat sera une documentation dimplémentation qui sera placé
dans le fichier:
<code>Blackboard/Architecture/pmoconfig_ext.md</code></p>
<p>Reste bien focalisé sur lobjectif principal.</p>
</article>
</body>
</html>

View File

@@ -1,52 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>music_source</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
<p><strong>Il faut suivre les instructions générales placées dans le
fichier : Blackboard/Rules.md</strong></p>
<p>Partir des fichiers suivants:</p>
<ul>
<li>pmoparadise/src/source.rs</li>
<li>pmoqobuz/src/source.rs</li>
<li>pmosource/README.md</li>
<li>pmosource/ARCHITECTURE.md</li>
</ul>
<p>Décrire dans un fichier darchitecture Limplémentation dune
nouvelle MusicSource.</p>
<p>Le résultat sera une documentation dimplémentation qui sera placé
dans le fichier:
<code>Blackboard/Architecture/music_source.md</code></p>
<p>Reste bien focalisé sur lobjectif principal.</p>
</article>
</body>
</html>

View File

@@ -1,40 +0,0 @@
<!DOCTYPE html>
<html lang="fr"><head><meta charset="utf-8">
<title>PMOMusic Blackboard</title>
<style>
body{font-family:sans-serif;margin:20px;background:#f5f5f5}
h1{color:#2c3e50}ul{list-style:none;padding:0}
li{margin:10px 0}a{color:#3498db;text-decoration:none}
a:hover{text-decoration:underline}.category{margin-top:30px}
.category h2{color:#e74c3c;border-bottom:2px solid #e74c3c;padding-bottom:5px}
</style></head><body>
<h1>📋 PMOMusic Blackboard</h1>
<div class='category'><h2>Architecture</h2><ul>
<li><a href='Architecture_music_source.html'>music_source</a></li>
<li><a href='Architecture_pmoconfig_ext.html'>pmoconfig_ext</a></li>
<li><a href='Architecture_pmoserver_ext.html'>pmoserver_ext</a></li>
</ul></div>
<div class='category'><h2>ToThinkAbout</h2><ul>
<li><a href='ToThinkAbout_MusicBoxSource.html'>MusicBoxSource</a></li>
<li><a href='ToThinkAbout_PlayListSource.html'>PlayListSource</a></li>
</ul></div>
<div class='category'><h2>ToDiscuss</h2><ul>
<li><a href='ToDiscuss_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
<li><a href='ToDiscuss_pmoserver_ext.html'>pmoserver_ext</a></li>
</ul></div>
<div class='category'><h2>Todo</h2><ul>
<li><a href='Todo_config_ext.html'>config_ext</a></li>
<li><a href='Todo_music_source.html'>music_source</a></li>
</ul></div>
<div class='category'><h2>Done</h2><ul>
<li><a href='Done_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
<li><a href='Done_WeabApp_debouncingSSE.html'>WeabApp_debouncingSSE</a></li>
</ul></div>
<div class='category'><h2>Report</h2><ul>
<li><a href='Report_Pinnable_cache_item.html'>Pinnable_cache_item</a></li>
<li><a href='Report_WeabApp_debouncingSSE.html'>WeabApp_debouncingSSE</a></li>
<li><a href='Report_config_ext.html'>config_ext</a></li>
<li><a href='Report_music_source.html'>music_source</a></li>
<li><a href='Report_pmoserver_ext.html'>pmoserver_ext</a></li>
</ul></div>
</body></html>

4897
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,56 +1,3 @@
[workspace]
resolver = "3"
members = [
"PMOMusic",
"pmoupnp",
"pmomediarenderer",
"pmomediaserver",
"pmoconfig",
"pmoutils",
"pmodidl",
"pmoserver",
"pmoapp",
"pmocache",
"pmocovers",
"pmoaudiocache",
"pmoaudio",
"pmoqobuz",
"pmoparadise",
"pmosource",
"pmoplaylist",
"pmoflac",
"pmometadata", "pmocontrol",
]
[workspace.dependencies]
# Core
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.9"
# Async runtime
tokio = { version = "1.35", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] }
async-trait = "0.1"
# Error handling
anyhow = "1.0"
thiserror = "2.0" # ⚠️ Unifier sur 2.0 (vous avez 1.0 et 2.0)
# Logging
tracing = "0.1.41"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
# HTTP/XML
reqwest = { version = "0.12", default-features = false }
ureq = "3.1"
quick-xml = { version = "0.38", features = ["serialize"] } # ⚠️ Unifier 0.37→0.38
# Utilities
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1.18", features = ["v4"] }
crossbeam-channel = "0.5"
rand = "0.9"
# Testing
tokio-test = "0.4"
members = ["PMOMusic", "pmoupnp","pmoconfig", "pmoutils", "pmodidl", "pmoserver", "pmoapp"]

View File

@@ -1,72 +0,0 @@
## Diagramme des dépendances PMOMusic (crates internes)
```mermaid
graph TD
PMOMusic --> pmoapp
PMOMusic --> pmoaudio_ext
PMOMusic --> pmoaudiocache
PMOMusic --> pmocovers
PMOMusic --> pmoconfig
PMOMusic --> pmoserver
PMOMusic --> pmosource
PMOMusic --> pmoupnp
PMOMusic --> pmomediaserver
PMOMusic --> pmomediarenderer
PMOMusic --> pmoqobuz
PMOMusic --> pmoparadise
pmoaudio_ext --> pmoaudio
pmoaudio_ext --> pmocovers
pmoaudio_ext --> pmocache
pmoaudio_ext --> pmoaudiocache
pmoaudio_ext --> pmometadata
pmoaudio_ext --> pmoplaylist
pmoaudiocache --> pmocache
pmoaudiocache --> pmometadata
pmocovers --> pmocache
pmoplaylist --> pmocache
pmoplaylist --> pmoaudiocache
pmoplaylist --> pmometadata
pmoplaylist --> pmodidl
pmosource --> pmoaudiocache
pmosource --> pmocovers
pmosource --> pmocache
pmosource --> pmoplaylist
pmosource --> pmodidl
pmosource --> pmoconfig
pmosource --> pmoserver
pmosource --> pmoupnp
pmoparadise --> pmosource
pmoparadise --> pmoaudiocache
pmoparadise --> pmoplaylist
pmoparadise --> pmoserver
pmoparadise --> pmoconfig
pmoqobuz --> pmosource
pmoqobuz --> pmoaudiocache
pmoqobuz --> pmocovers
pmoqobuz --> pmoserver
pmoqobuz --> pmoconfig
pmomediaserver --> pmoserver
pmomediaserver --> pmosource
pmomediaserver --> pmoconfig
pmomediaserver --> pmocovers
pmomediaserver --> pmoaudiocache
pmomediaserver --> pmoplaylist
pmoupnp --> pmoserver
pmoupnp --> pmocovers
pmoupnp --> pmoaudiocache
pmoupnp --> pmoplaylist
pmoupnp --> pmocache
pmoupnp --> pmoconfig
```
> Flèches = “dépend de”. Dépendances externes non représentées. Cette vue correspond aux features activées par défaut dans la workspace.

302
DOCKER.md
View File

@@ -1,302 +0,0 @@
# Docker Deployment Guide for PMOMusic
Ce guide explique comment construire et déployer PMOMusic avec Docker.
## Architecture
Le Dockerfile utilise une approche multi-stage pour créer une image minimale :
1. **Stage 1 (webapp-builder)** : Compile l'application Vue.js avec Node.js
2. **Stage 2 (rust-builder)** : Compile le binaire Rust avec toutes ses dépendances
3. **Stage 3 (runtime)** : Image finale minimale Debian Slim avec uniquement le binaire et les bibliothèques runtime
### Avantages
- **Binaire auto-contenu** : L'application web est embarquée dans le binaire Rust
- **Image minimale** : ~200-300MB (vs plusieurs GB pour les images de build)
- **Sécurité** : Exécution en tant qu'utilisateur non-root
- **Reproductibilité** : Build complet et déterministe
## Build de l'image
### Option 1 : Build manuel avec Docker
```bash
# Build l'image
docker build -t pmomusic:latest .
# Le build prend environ 10-15 minutes selon votre machine
```
### Option 2 : Build avec docker-compose
```bash
# Build et démarre le conteneur
docker-compose up --build
# Ou juste build
docker-compose build
```
### Build optimisé avec cache
Pour accélérer les builds successifs, Docker réutilise les couches en cache :
```bash
# Build avec cache
docker build -t pmomusic:latest .
# Build sans cache (force rebuild complet)
docker build --no-cache -t pmomusic:latest .
```
## Exécution du conteneur
### Option 1 : Avec docker-compose (recommandé)
```bash
# Démarrer en arrière-plan
docker-compose up -d
# Voir les logs
docker-compose logs -f
# Arrêter
docker-compose down
# Redémarrer
docker-compose restart
```
### Option 2 : Avec docker run
```bash
# Run en mode interactif
docker run -it --rm \
--name pmomusic \
--network host \
-v $(pwd)/config:/home/pmomusic/.pmomusic \
-v $(pwd)/cache:/home/pmomusic/cache \
pmomusic:latest
# Run en mode détaché
docker run -d \
--name pmomusic \
--network host \
--restart unless-stopped \
-v $(pwd)/config:/home/pmomusic/.pmomusic \
-v $(pwd)/cache:/home/pmomusic/cache \
pmomusic:latest
```
## Configuration
### Ports
Par défaut, PMOMusic écoute sur le port **8080**. Vous pouvez modifier cela :
- Dans `docker-compose.yml` : modifier la section `ports`
- Avec `docker run` : utiliser `-p 8080:8080`
### Volumes
Deux volumes sont recommandés pour la persistance :
- **Configuration** : `/home/pmomusic/.pmomusic` - Fichiers de configuration
- **Cache** : `/home/pmomusic/cache` - Cache audio et métadonnées
### Variables d'environnement
Configurable via `docker-compose.yml` ou `-e` avec `docker run` :
```bash
# Niveau de logs Rust
RUST_LOG=debug
# Autres variables (selon votre configuration)
# ...
```
### Réseau
Pour UPnP/DLNA, utilisez **network_mode: host** pour permettre :
- La découverte multicast
- La communication avec les devices UPnP sur le réseau local
**Note** : Le mode `host` ne fonctionne que sur Linux. Sur macOS/Windows avec Docker Desktop, utilisez le mapping de ports standard.
## Gestion de l'image
### Taille de l'image
```bash
# Voir la taille de l'image
docker images pmomusic:latest
# Résultat attendu : ~200-300MB
```
### Nettoyage
```bash
# Supprimer l'image
docker rmi pmomusic:latest
# Nettoyer les images de build intermédiaires
docker builder prune
# Nettoyer tous les caches Docker (libère beaucoup d'espace)
docker system prune -a
```
## Build multi-plateforme
Pour builder pour différentes architectures (ARM64, AMD64) :
```bash
# Créer un builder multi-plateforme
docker buildx create --name multiarch --use
# Build pour AMD64 et ARM64
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t pmomusic:latest \
--push \
.
# Note : nécessite un registry Docker (Docker Hub, GHCR, etc.)
```
## Déploiement en production
### 1. Avec docker-compose (simple)
```bash
# Sur le serveur de production
git clone <votre-repo>
cd pmomusic
docker-compose up -d
```
### 2. Avec un registry Docker (recommandé)
```bash
# Sur votre machine de dev
docker build -t yourregistry.com/pmomusic:v1.0.0 .
docker push yourregistry.com/pmomusic:v1.0.0
# Sur le serveur de production
docker pull yourregistry.com/pmomusic:v1.0.0
docker run -d ... yourregistry.com/pmomusic:v1.0.0
```
### 3. Avec un orchestrateur (Kubernetes, Docker Swarm)
Créer un fichier de déploiement approprié selon votre orchestrateur.
## Debugging
### Logs du conteneur
```bash
# Logs en temps réel
docker logs -f pmomusic
# Logs avec docker-compose
docker-compose logs -f
```
### Entrer dans le conteneur
```bash
# Shell interactif (bash n'est pas disponible, utiliser sh)
docker exec -it pmomusic sh
# Vérifier les processus
docker exec -it pmomusic ps aux
# Vérifier les fichiers
docker exec -it pmomusic ls -la /home/pmomusic
```
### Health check
Le conteneur inclut un health check. Vérifier l'état :
```bash
# Voir l'état de santé
docker inspect --format='{{.State.Health.Status}}' pmomusic
```
## Troubleshooting
### Le build échoue
1. **Erreur de dépendances npm** :
- Vérifier que `pmoapp/webapp/package.json` est correct
- Essayer `docker build --no-cache`
2. **Erreur de compilation Rust** :
- Vérifier que tous les fichiers Cargo.toml sont présents
- Vérifier les dépendances système (libsoxr, libasound2)
3. **Out of memory** :
- Augmenter la mémoire allouée à Docker Desktop (settings)
- Utiliser `--memory` pour limiter la mémoire du build
### Le conteneur ne démarre pas
1. **Port déjà utilisé** :
```bash
# Vérifier quel processus utilise le port 8080
sudo lsof -i :8080
```
2. **Permissions** :
- Vérifier les permissions des volumes montés
- Le conteneur s'exécute en tant qu'utilisateur `pmomusic` (UID 1000)
3. **Configuration manquante** :
- Créer les répertoires de configuration avant de démarrer :
```bash
mkdir -p config cache
```
### UPnP ne fonctionne pas
1. **Network mode** :
- Sur Linux : utiliser `network_mode: host`
- Sur macOS/Windows : UPnP peut ne pas fonctionner correctement avec Docker Desktop
2. **Firewall** :
- Vérifier que les ports UPnP ne sont pas bloqués
- Autoriser le multicast sur le réseau
## Performance
### Optimisations du build
1. **Build cache** : Docker réutilise les couches en cache
2. **Multi-stage build** : Réduit la taille de l'image finale
3. **Strip des symboles** : Le binaire est strippé pour réduire sa taille
### Optimisations runtime
1. **Resource limits** : Définir des limites CPU/mémoire dans docker-compose.yml
2. **Volumes** : Utiliser des volumes pour les données persistantes
3. **Logs** : Configurer la rotation des logs Docker
## Sécurité
- ✅ Exécution en tant qu'utilisateur non-root
- ✅ Image minimale (surface d'attaque réduite)
- ✅ Pas de secrets dans l'image
- ✅ Health checks activés
- ✅ Certificats CA inclus pour HTTPS
## Références
- [Dockerfile](./Dockerfile)
- [docker-compose.yml](./docker-compose.yml)
- [.dockerignore](./.dockerignore)
- [Documentation Rust](./Readme.md)

View File

@@ -1,117 +0,0 @@
# ===================================
# Stage 1: Build Vue.js webapp
# ===================================
FROM node:22-alpine AS webapp-builder
WORKDIR /webapp
# Copy webapp package files
COPY pmoapp/webapp/package*.json ./
# Install dependencies
RUN npm ci --production=false
# Copy webapp source
COPY pmoapp/webapp/ ./
# Build the webapp
RUN npm run build
# ===================================
# Stage 2: Build Rust binary
# ===================================
FROM rustlang/rust:nightly-bookworm AS rust-builder
WORKDIR /build
# Install system dependencies for building
RUN apt-get update && apt-get install -y \
libsoxr-dev \
libasound2-dev \
pkg-config \
cmake \
&& rm -rf /var/lib/apt/lists/*
# Copy Cargo workspace files
COPY Cargo.toml Cargo.lock ./
# Copy all crates
COPY PMOMusic/ ./PMOMusic/
COPY pmoupnp/ ./pmoupnp/
COPY pmomediarenderer/ ./pmomediarenderer/
COPY pmomediaserver/ ./pmomediaserver/
COPY pmoconfig/ ./pmoconfig/
COPY pmoutils/ ./pmoutils/
COPY pmodidl/ ./pmodidl/
COPY pmoserver/ ./pmoserver/
COPY pmocache/ ./pmocache/
COPY pmocovers/ ./pmocovers/
COPY pmoaudiocache/ ./pmoaudiocache/
COPY pmoaudio/ ./pmoaudio/
COPY pmoqobuz/ ./pmoqobuz/
COPY pmoparadise/ ./pmoparadise/
COPY pmosource/ ./pmosource/
COPY pmoplaylist/ ./pmoplaylist/
COPY pmoflac/ ./pmoflac/
COPY pmometadata/ ./pmometadata/
COPY pmocontrol/ ./pmocontrol/
COPY pmoaudio-ext/ ./pmoaudio-ext/
COPY pmoapp/ ./pmoapp/
# Copy the webapp dist from previous stage
COPY --from=webapp-builder /webapp/dist ./pmoapp/webapp/dist/
# Build the Rust binary in release mode
RUN cargo build --release --bin PMOMusic
# Strip debug symbols to reduce binary size
RUN strip /build/target/release/PMOMusic
# ===================================
# Stage 3: Minimal runtime image
# ===================================
FROM debian:bookworm-slim
ARG BUILD_DATE
LABEL org.opencontainers.image.created=$BUILD_DATE
LABEL org.opencontainers.image.title="PMOMusic UPNP Music Server"
LABEL org.opencontainers.image.version="0.1"
LABEL org.opencontainers.image.authors="eric@coissac.eu"
LABEL org.opencontainers.image.licenses="CeCILL-2.0"
LABEL org.opencontainers.image.url="https://gargoton.petite-maison-orange.fr/eric/pmomusic"
LABEL org.opencontainers.image.description="PMOMusic est une application Rust proposant en un binaire unique une Serveur de Musique, et une point de controle UPNP controlable via une application web."
# Install only runtime dependencies
RUN apt-get update && apt-get install -y \
libsoxr0 \
libasound2 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -m -u 1000 pmomusic
# Copy the binary from builder
COPY --from=rust-builder /build/target/release/PMOMusic /usr/local/bin/PMOMusic
# Set ownership
RUN chown pmomusic:pmomusic /usr/local/bin/PMOMusic
# Switch to non-root user
USER pmomusic
# Create directories for configuration and cache
RUN mkdir -p /home/pmomusic/.pmomusic
# Set working directory
WORKDIR /home/pmomusic
# Expose default port (adjust if needed)
EXPOSE 8080
# Health check (adjust the URL if needed)
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD ["/usr/local/bin/PMOMusic", "--help"] || exit 1
# Run the binary
ENTRYPOINT ["/usr/local/bin/PMOMusic"]

View File

@@ -1,296 +0,0 @@
# Installation des dépendances système sans droits sudo
Ce document explique comment installer les dépendances système de `pmoaudio` localement sans privilèges administrateur.
## Dépendances requises
1. **libsoxr** - Nécessaire pour `ResamplingNode` (resampling audio haute qualité)
2. **libasound2** (ALSA) - Nécessaire pour `AudioSink` via cpal (lecture audio sur Linux)
## Contexte
Les crates `soxr` et `cpal` nécessitent des bibliothèques système. Dans un environnement sans droits sudo (comme Claude Code), voici comment les installer localement.
## Méthode : Installation locale via apt-get download
### 1. Télécharger les packages .deb
```bash
cd ~/.local
# Pour libsoxr (ResamplingNode)
apt-get download libsoxr-dev libsoxr0
# Pour ALSA (AudioSink)
# Note: libasound2t64 contient la bibliothèque partagée, libasound2-dev les headers
apt-get download libasound2-dev libasound2t64
```
Cela télécharge les fichiers `.deb` sans les installer système-wide.
### 2. Extraire les packages
```bash
# Extraire libsoxr
dpkg -x libsoxr-dev_*.deb .
dpkg -x libsoxr0_*.deb .
# Extraire ALSA
dpkg -x libasound2-dev_*.deb .
dpkg -x libasound2t64_*.deb .
```
Les fichiers sont extraits dans `~/.local/usr/lib/x86_64-linux-gnu/` et `~/.local/usr/include/`.
### 3. Configurer les variables d'environnement
Ajouter à votre `~/.bashrc` ou exporter dans votre session :
```bash
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
```
**IMPORTANT:** Ces variables doivent être définies dans chaque session où vous compilez le projet.
### 4. Vérifier l'installation
```bash
# Vérifier libsoxr
pkg-config --libs --cflags soxr
# Vérifier ALSA
pkg-config --libs --cflags alsa
```
Devrait retourner quelque chose comme :
```
# soxr
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
# alsa
-I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
```
## Utilisation avec Cargo
### Pour les builds réguliers
Les variables d'environnement suffisent pour `cargo build` et `cargo run`.
### Pour les tests
Les tests nécessitent également la configuration du linker. Deux options :
#### Option A : Configuration locale du projet (NON RECOMMANDÉ pour le versioning)
Créer `.cargo/config.toml` dans chaque crate :
```toml
[build]
rustflags = ["-L", "/root/.local/usr/lib/x86_64-linux-gnu"]
```
**⚠️ NE PAS committer ces fichiers** - ils contiennent des chemins spécifiques à votre installation.
#### Option B : Variables d'environnement pour cargo test
```bash
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
cargo test
```
## Pour d'autres distributions
### macOS (avec Homebrew)
```bash
brew install libsoxr
# Note: ALSA n'est pas nécessaire sur macOS (rodio utilise CoreAudio)
```
### Debian/Ubuntu (avec sudo)
```bash
sudo apt-get install libsoxr-dev libasound2-dev
```
### Fedora/RHEL
```bash
sudo dnf install soxr-devel
```
## Troubleshooting
### Erreur : "Package 'soxr' was not found" ou "Package 'alsa' was not found"
- Vérifier que `PKG_CONFIG_PATH` contient le bon chemin
- Vérifier que les fichiers `soxr.pc` et `alsa.pc` existent dans ce répertoire
### Erreur de link : "unable to find library -lsoxr" ou "-lasound"
- Pour `cargo build` : vérifier `LD_LIBRARY_PATH`
- Pour `cargo test` : utiliser la configuration rustflags (Option A ci-dessus)
### Le test compile mais échoue au runtime
```
error while loading shared libraries: libsoxr.so.0: cannot open shared object file
```
Solution : Ajouter `LD_LIBRARY_PATH` également pour l'exécution :
```bash
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
cargo test
```
## Guide complet pour environnement Claude Code
### Configuration initiale (à faire une seule fois)
Dans une session Claude Code (https://claude.ai/code), vous n'avez pas de droits sudo.
**🚀 Méthode rapide (recommandée) :**
```bash
# 1. Installation automatique des dépendances (une seule fois)
./setup-deps.sh
# 2. Configuration des variables d'environnement (à chaque session)
source setup-env.sh
# 3. Compilation
cargo build
```
**📋 Méthode manuelle (si les scripts ne fonctionnent pas) :**
#### 1. Installation des dépendances
```bash
# Créer le répertoire local
mkdir -p ~/.local
cd ~/.local
# Télécharger tous les packages nécessaires
apt-get download libsoxr-dev libsoxr0 libasound2-dev libasound2t64
# Extraire tous les packages
dpkg -x libsoxr-dev_*.deb .
dpkg -x libsoxr0_*.deb .
dpkg -x libasound2-dev_*.deb .
dpkg -x libasound2t64_*.deb .
# Retourner au projet
cd /home/user/pmomusic
```
#### 2. Configuration des variables d'environnement
**IMPORTANT:** Ces variables doivent être exportées dans CHAQUE session Claude Code avant de compiler :
```bash
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
```
**Astuce :** Créez un fichier `setup-env.sh` pour ne pas avoir à retaper ces commandes à chaque session :
```bash
cat > setup-env.sh << 'EOF'
#!/bin/bash
# Script de configuration des variables d'environnement pour PMOMusic
# Usage: source setup-env.sh
# Configuration des chemins pour libsoxr et libasound2
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
echo "Variables d'environnement configurées pour PMOMusic"
echo " PKG_CONFIG_PATH=$PKG_CONFIG_PATH"
echo " LD_LIBRARY_PATH=$LD_LIBRARY_PATH"
echo " RUSTFLAGS=$RUSTFLAGS"
echo ""
echo "Vous pouvez maintenant compiler avec: cargo build"
EOF
chmod +x setup-env.sh
```
Puis dans chaque session :
```bash
source setup-env.sh
```
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` (configuration locale), vous devez le créer vous-même avec le contenu ci-dessus.
#### 3. Vérifier l'installation
```bash
# Vérifier que pkg-config trouve les bibliothèques
pkg-config --libs --cflags soxr
pkg-config --libs --cflags alsa
# Devrait afficher quelque chose comme :
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lsoxr
# -I/root/.local/usr/include -L/root/.local/usr/lib/x86_64-linux-gnu -lasound
```
#### 4. Compiler et tester
```bash
# Compiler le workspace complet
cargo build
# Tester l'exemple play_and_cache de pmoparadise
cargo run --package pmoparadise --example play_and_cache --features full -- 0
```
### Workflow pour chaque nouvelle session
À chaque fois que vous démarrez une nouvelle session Claude Code :
```bash
# 1. Configuration de l'environnement
source setup-env.sh
# 2. Compilation
cargo build
# 3. Exécution des exemples
cargo run --package pmoparadise --example play_and_cache --features full -- 0
```
**IMPORTANT :** Si vous oubliez d'exporter les variables, vous obtiendrez des erreurs comme :
```
error: failed to run custom build command for `soxr-sys`
Package 'soxr' was not found in the pkg-config search path
```
ou
```
rust-lld: error: unable to find library -lasound
```
**Solution :** Exécutez `source setup-env.sh` et recompilez.
### Notes importantes
- ✅ Les dépendances installées dans `~/.local` persistent entre les sessions
- ✅ Les variables d'environnement doivent être réexportées à chaque nouvelle session
- ❌ NE JAMAIS créer de fichiers `.cargo/config.toml` dans le projet (chemins spécifiques)
- ❌ NE JAMAIS committer `setup-env.sh` (configuration locale)
- 💡 Sur macOS (via Homebrew) : seul `libsoxr` est nécessaire (pas d'ALSA)
## Références
- libsoxr GitHub: https://github.com/chirlu/soxr
- Documentation pkg-config: https://www.freedesktop.org/wiki/Software/pkg-config/

View File

@@ -1,89 +0,0 @@
# Notes d'installation pour PMOMusic
## Prérequis système
### libsoxr (obligatoire pour pmoaudio - resampling)
La bibliothèque `libsoxr` est requise pour le resampling audio dans `pmoaudio`.
### libasound2/ALSA (obligatoire pour pmoaudio - lecture audio sur Linux)
La bibliothèque ALSA est requise pour `AudioSink` via `cpal` sur Linux. Sur macOS et Windows, aucune dépendance externe n'est nécessaire (CoreAudio et WASAPI sont utilisés).
**Installation** :
```bash
# Debian/Ubuntu
sudo apt-get install libsoxr-dev libasound2-dev
# Fedora/RHEL
sudo dnf install libsoxr-devel alsa-lib-devel
# Arch Linux
sudo pacman -S libsoxr alsa-lib
# macOS (Homebrew) - ALSA non nécessaire sur macOS
brew install libsoxr
# Alpine Linux
apk add soxr-dev alsa-lib-dev
```
**Sans privilèges root (Claude Code, environnements sans sudo)** :
🚀 **Installation automatique** :
```bash
# 1. Installation des dépendances (une seule fois)
./setup-deps.sh
# 2. Configuration de l'environnement (à chaque session)
source setup-env.sh
# 3. Compilation
cargo build
```
Pour plus de détails, consultez `INSTALL_LIBSOXR.md`.
---
## Nouveaux composants
### PlaylistSource (pmoaudio-ext)
Source audio qui lit une playlist `pmoplaylist` et diffuse les pistes en continu.
**Feature** : `playlist`
```bash
# Compiler avec la feature playlist
cargo build --package pmoaudio-ext --features playlist
```
**⚠️ Important** : Cette source émet du PCM avec sample_rate et bit_depth **variables**. Pour un flux homogène, ajoutez dans le pipeline :
- `ResamplingNode` (normalise le sample_rate)
- `ToI24Node` / `ToI16Node` (normalise la profondeur de bits)
### ResamplingNode (pmoaudio)
Nœud générique qui normalise le sample_rate vers une valeur cible fixe.
**Usage** :
```rust
let mut resampler = ResamplingNode::new(48000); // Force 48kHz
```
---
## Compilation
```bash
# Compiler tout le workspace (nécessite libsoxr)
cargo build
# Compiler sans pmoaudio (si libsoxr manque)
cargo build --package pmoplaylist
cargo build --package pmoaudiocache
# etc.
```

113
Makefile
View File

@@ -1,8 +1,6 @@
# Makefile pour projet Rust + Vue.js
# Variables de configuration
CARGO = cargo
CARGO_NIGHTLY = rustup run nightly cargo
FEATURES ?=
NPM = npm
WEBAPP_DIR = pmoapp/webapp
DIST_DIR = $(WEBAPP_DIR)/dist
@@ -13,13 +11,10 @@ BINARY_NAME = PMOMusic
# Couleurs pour l'affichage
GREEN = \033[0;32m
YELLOW = \033[1;33m
BLUE = \033[1;34m
RED = \033[0;31m
NC = \033[0m # No Color
.DEFAULT_GOAL := simd
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch simd scalar
.PHONY: all help build release debug test doc webapp clean install dev check fmt clippy watch
# Cible par défaut
all: build
@@ -36,13 +31,13 @@ build: webapp release
## release: Compile le binaire Rust en mode release
release: webapp
@echo "$(YELLOW)→ Compilation Rust (release)...$(NC)"
$(CARGO) build --release $(FEATURES)
$(CARGO) build --release
@echo "$(GREEN)✓ Binaire disponible : $(RUST_TARGET)/$(BINARY_NAME)$(NC)"
## debug: Compile le binaire Rust en mode debug
debug: webapp
@echo "$(YELLOW)→ Compilation Rust (debug)...$(NC)"
$(CARGO) build $(FEATURES)
$(CARGO) build
@echo "$(GREEN)✓ Binaire disponible : target/debug/$(BINARY_NAME)$(NC)"
## test: Exécute tous les tests Rust
@@ -51,18 +46,6 @@ test:
$(CARGO) test --all
@echo "$(GREEN)✓ Tests terminés$(NC)"
## simd: Compile l'application en mode SIMD (nightly requis)
simd:
@echo "$(YELLOW)→ Build SIMD (nightly)...$(NC)"
$(MAKE) release CARGO="$(CARGO_NIGHTLY)" FEATURES="--features simd"
@echo "$(GREEN)✓ Build SIMD terminé$(NC)"
## scalar: Compile l'application en mode scalaire
scalar:
@echo "$(YELLOW)→ Build scalaire...$(NC)"
$(MAKE) release FEATURES=""
@echo "$(GREEN)✓ Build scalaire terminé$(NC)"
## test-doc: Teste les exemples dans la documentation
test-doc:
@echo "$(YELLOW)→ Test des exemples de documentation...$(NC)"
@@ -194,28 +177,6 @@ update:
cd $(WEBAPP_DIR) && $(NPM) update
@echo "$(GREEN)✓ Dépendances mises à jour$(NC)"
## bump-version: Incrémente le numéro de version patch (x.y.z -> x.y.z+1)
bump-version:
@echo "$(YELLOW)→ Incrémentation de la version...$(NC)"
@current=$$(grep '^version = ' PMOMusic/Cargo.toml | head -n 1 | sed 's/version = "\(.*\)"/\1/'); \
echo " Version actuelle: $$current"; \
major=$$(echo $$current | cut -d. -f1); \
minor=$$(echo $$current | cut -d. -f2); \
patch=$$(echo $$current | cut -d. -f3); \
new_patch=$$((patch + 1)); \
new_version="$$major.$$minor.$$new_patch"; \
echo " Nouvelle version: $$new_version"; \
sed -i.bak "s/^version = \"$$current\"/version = \"$$new_version\"/" PMOMusic/Cargo.toml && \
rm PMOMusic/Cargo.toml.bak && \
echo "$$new_version" > version.txt
@echo "$(GREEN)✓ Version mise à jour dans PMOMusic/Cargo.toml et version.txt$(NC)"
## sync-version: Synchronise version.txt depuis PMOMusic/Cargo.toml
version.txt: PMOMusic/Cargo.toml
@echo "$(YELLOW)→ Synchronisation de version.txt...$(NC)"
@grep '^version = ' PMOMusic/Cargo.toml | head -n 1 | sed 's/version = "\(.*\)"/\1/' > version.txt
@echo "$(GREEN)✓ version.txt synchronisé: $$(cat version.txt)$(NC)"
## bench: Exécute les benchmarks
bench:
@echo "$(YELLOW)→ Exécution des benchmarks...$(NC)"
@@ -226,70 +187,4 @@ coverage:
@echo "$(YELLOW)→ Génération du rapport de couverture...$(NC)"
$(CARGO) tarpaulin --out Html --output-dir target/coverage
@echo "$(GREEN)✓ Rapport disponible dans target/coverage/index.html$(NC)"
jjnew:
@echo "$(YELLOW)→ Création d'un nouveau commit...$(NC)"
@echo "$(BLUE)→ Documentation du commit courrant...$(NC)"
@jj auto-describe
@echo "$(BLUE)→ C'est fait.$(NC)"
@jj new
@echo "$(GREEN)✓ nouveau commit créé$(NC)"
jjpush: bump-version
@echo "$(YELLOW)→ Push du commit sur le dépôt...$(NC)"
@echo "$(BLUE)→ Documentation du commit courrant...$(NC)"
@jj auto-describe
@echo "$(BLUE)→ C'est fait.$(NC)"
@jj git push --change @
@echo "$(GREEN)✓ Commit pushé sur le dépôt$(NC)"
jjfetch:
@echo "$(YELLOW)→ Pull des derniers commits...$(NC)"
@jj git fetch
@jj new main@origin
@echo "$(GREEN)✓ Derniers commits pullés$(NC)"
## blackboard-html: Génère les fichiers HTML du Blackboard avec support Mermaid
blackboard-html:
@echo "$(YELLOW)→ Génération des fichiers HTML du Blackboard...$(NC)"
@mkdir -p Blackboard_HTML
@echo "<!DOCTYPE html>" > Blackboard_HTML/index.html
@echo '<html lang="fr"><head><meta charset="utf-8">' >> Blackboard_HTML/index.html
@echo "<title>PMOMusic Blackboard</title>" >> Blackboard_HTML/index.html
@echo "<style>" >> Blackboard_HTML/index.html
@echo "body{font-family:sans-serif;margin:20px;background:#f5f5f5}" >> Blackboard_HTML/index.html
@echo "h1{color:#2c3e50}ul{list-style:none;padding:0}" >> Blackboard_HTML/index.html
@echo "li{margin:10px 0}a{color:#3498db;text-decoration:none}" >> Blackboard_HTML/index.html
@echo "a:hover{text-decoration:underline}.category{margin-top:30px}" >> Blackboard_HTML/index.html
@echo ".category h2{color:#e74c3c;border-bottom:2px solid #e74c3c;padding-bottom:5px}" >> Blackboard_HTML/index.html
@echo "</style></head><body>" >> Blackboard_HTML/index.html
@echo '<h1>📋 PMOMusic Blackboard</h1>' >> Blackboard_HTML/index.html
@for category in Architecture ToThinkAbout ToDiscuss Todo Done Report; do \
if [ -d "Blackboard/$$category" ]; then \
echo "<div class='category'><h2>$$category</h2><ul>" >> Blackboard_HTML/index.html; \
find "Blackboard/$$category" -name "*.md" -type f | sort | while read -r file; do \
basename=$$(basename "$$file" .md); \
relpath=$$(echo "$$file" | sed 's|Blackboard/||'); \
htmlfile=$$(echo "$$relpath" | sed 's|/|_|g' | sed 's|\.md$$|.html|'); \
echo "<li><a href='$$htmlfile'>$$basename</a></li>" >> Blackboard_HTML/index.html; \
echo " → Conversion: $$relpath → $$htmlfile"; \
/opt/homebrew/bin/pandoc "$$file" -o "Blackboard_HTML/$$htmlfile" \
--standalone \
--template=blackboard-template.html \
--metadata title="$$basename" \
--from markdown \
--to html; \
./fix-mermaid.sh "Blackboard_HTML/$$htmlfile"; \
done; \
echo "</ul></div>" >> Blackboard_HTML/index.html; \
fi; \
done
@echo "</body></html>" >> Blackboard_HTML/index.html
@echo "$(GREEN)✓ Fichiers HTML générés dans Blackboard_HTML/$(NC)"
@echo "$(BLUE) Ouvrir: open Blackboard_HTML/index.html$(NC)"
## blackboard-clean: Nettoie les fichiers HTML générés
blackboard-clean:
@echo "$(YELLOW)→ Nettoyage des fichiers HTML du Blackboard...$(NC)"
@rm -rf Blackboard_HTML
@echo "$(GREEN)✓ Fichiers HTML supprimés$(NC)"

View File

@@ -1,485 +0,0 @@
# Optimisation: Réduction du délai prebuffer → playlist (19s → 1s)
## Contexte
Le système de progressive caching fonctionne correctement, mais il y a un délai non optimal entre le moment où le prebuffer est atteint et le moment où la track est ajoutée à la playlist.
### État actuel (branche `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`)
**Timing mesuré:**
```
t=0.6s : Prebuffer complete (512KB téléchargés) ✅
t=19.2s : tokio::join!() complete (pump_future finit)
t=19.2s : Track added to playlist
t=19.7s : Playback starts
```
**Délai total: ~19 secondes**
### Code actuel problématique
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:167-178`
```rust
// Exécuter pump et add_from_reader en parallèle
let pump_future = pump_track_segments(
first_segment,
&mut rx, // ← emprunte muablement rx
pcm_tx,
bits_per_sample,
sample_rate,
&stop_token,
);
// Attendre les deux tâches en parallèle
let (cache_result, pump_result) = tokio::join!(cache_future, pump_future);
let pk = cache_result.map_err(|e| {
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
})?;
let (_chunks, _samples, _duration_sec, stop_reason) = pump_result?;
```
**Le problème:** `tokio::join!()` attend que **LES DEUX** futures se terminent:
- `cache_future` retourne après prebuffer (~0.6s) ✅
- `pump_future` lit **toute** la première track du RadioParadiseStreamSource (~19s) ⏱️
Donc même si le prebuffer est atteint en 0.6s, on attend 19s avant de push à la playlist!
## Objectif
Réduire le délai à **~1 seconde** en pushant à la playlist **immédiatement après le prebuffer**, sans attendre que `pump_future` se termine.
**Timing visé:**
```
t=0.6s : Prebuffer complete ✅
t=0.7s : Track added to playlist ← IMMÉDIAT!
t=1.2s : Playback starts ← ~1 seconde!
t=19.2s : pump_future finit en arrière-plan
```
## Contraintes techniques
### 1. Problème du borrow checker
`pump_future` emprunte muablement `rx`:
```rust
async fn pump_track_segments(
first_segment: Arc<AudioSegment>,
rx: &mut mpsc::Receiver<Arc<AudioSegment>>, // ← &mut borrow
// ...
)
```
On ne peut pas faire:
```rust
tokio::pin!(cache_future);
tokio::pin!(pump_future); // ← pump_future contient un &mut rx
let pk = cache_future.await; // cache_future termine
// ❌ ERREUR: on a toujours un borrow mutable de rx dans pump_future
// On ne peut pas continuer à utiliser rx (ou l'objet qui le contient)
playlist_handle.push(pk.clone()).await;
let result = pump_future.await; // pump_future continue
```
Le borrow checker nous empêche d'attendre `cache_future` seul, puis de faire d'autres opérations, puis d'attendre `pump_future`, car `pump_future` garde un borrow mutable de `rx` pendant toute sa durée de vie.
### 2. Contraintes de l'API
- `pump_track_segments()` doit lire `rx` pour recevoir les segments du RadioParadiseStreamSource
- Le FlacCacheSinkLogic doit garder ownership de `rx` pour traiter les tracks suivantes
- `pump_future` ne peut pas être spawné dans un tokio::spawn car il retourne un `StopReason` nécessaire pour la logique métier
## Solutions possibles
### Solution A: Refactoriser pump_track_segments pour prendre ownership de rx
**Approche:**
1. Créer `pump_track_segments_owned` qui prend ownership de `rx`
2. Cette fonction retourne `(result, rx)` - elle rend ownership de `rx`
3. Spawner cette future dans tokio::spawn
4. Attendre cache_future seul, push immédiatement
5. Attendre la task spawnée plus tard
**Signature:**
```rust
async fn pump_track_segments_owned(
first_segment: Arc<AudioSegment>,
rx: mpsc::Receiver<Arc<AudioSegment>>, // ownership!
pcm_tx: mpsc::Sender<Vec<u8>>,
bits_per_sample: u8,
expected_rate: u32,
stop_token: CancellationToken,
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError>
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ rend rx
```
**Utilisation:**
```rust
let pump_handle = tokio::spawn(pump_track_segments_owned(
first_segment,
rx, // move ownership
pcm_tx,
bits_per_sample,
sample_rate,
stop_token.clone(),
));
// Attendre SEULEMENT le prebuffer
let pk = cache_future.await?;
// Push IMMÉDIATEMENT à la playlist
#[cfg(feature = "playlist")]
if let Some(ref playlist_handle) = self.playlist_handle {
playlist_handle.push(pk.clone()).await?;
}
// MAINTENANT attendre que pump finisse
let (result, rx_returned) = pump_handle.await.unwrap()?;
rx = rx_returned; // récupérer rx pour la prochaine track
```
**Avantages:**
- ✅ Pas de problème de borrow checker
- ✅ Push immédiat après prebuffer
- ✅ Délai réduit à ~1s
**Inconvénients:**
- ⚠️ Nécessite de modifier la signature de `pump_track_segments`
- ⚠️ Plus complexe (ownership passé puis rendu)
### Solution B: Utiliser un channel pour signaler le prebuffer
**Approche:**
1. Créer un oneshot channel `(prebuffer_tx, prebuffer_rx)`
2. `cache_future` envoie le pk via `prebuffer_tx` dès le prebuffer atteint
3. Le code principal attend `prebuffer_rx`, push immédiatement
4. Puis attend `tokio::join!()` normalement
**Code:**
```rust
let (prebuffer_tx, prebuffer_rx) = tokio::sync::oneshot::channel();
let cache_future = async {
let pk = self.cache.add_from_reader(...).await?;
let _ = prebuffer_tx.send(pk.clone()); // Signal prebuffer!
Ok(pk)
};
let pump_future = pump_track_segments(...);
// Spawner les deux en parallèle
let cache_handle = tokio::spawn(cache_future);
let pump_handle = tokio::spawn(pump_future);
// Attendre SEULEMENT le signal de prebuffer
let pk = prebuffer_rx.await.unwrap();
// Push IMMÉDIATEMENT à la playlist
playlist_handle.push(pk.clone()).await?;
// Puis attendre que tout finisse
let (cache_result, pump_result) = tokio::join!(cache_handle, pump_handle);
```
**Avantages:**
- ✅ Pas besoin de changer les signatures
- ✅ Push immédiat après prebuffer
**Inconvénients:**
- ⚠️ Nécessite de wrapper cache_future pour envoyer le signal
- ⚠️ Ajoute un oneshot channel
### Solution C: Modifier l'API du cache pour avoir un callback
**Approche:**
1. Ajouter un paramètre callback à `add_from_reader()`
2. Le cache appelle ce callback dès le prebuffer atteint
3. Le callback push à la playlist
**Signature:**
```rust
pub async fn add_from_reader_with_callback<R, F>(
&self,
source_uri: Option<&str>,
reader: R,
length: Option<u64>,
collection: Option<&str>,
on_prebuffer: F, // ← nouveau callback
) -> Result<String>
where
R: AsyncRead + Send + Unpin + 'static,
F: FnOnce(String) + Send + 'static, // F reçoit le pk
```
**Avantages:**
- ✅ API propre et réutilisable
- ✅ Pas de problème de borrow checker
**Inconvénients:**
- ⚠️ Nécessite de modifier l'API du cache (impact sur autres parties du code)
- ⚠️ Ajoute de la complexité à l'API
## Recommandation
**Je recommande la Solution A** (refactoriser `pump_track_segments_owned`):
- Plus explicite et claire
- Pas d'impact sur l'API du cache
- Ownership bien défini (passage puis retour de rx)
- Testable indépendamment
## Plan d'implémentation
### Étape 1: Créer pump_track_segments_owned
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs`
```rust
/// Pompe les segments pour une seule track (s'arrête au TrackBoundary).
///
/// Version qui prend ownership de rx pour permettre un await séparé du cache.
/// Retourne rx à la fin pour permettre le traitement des tracks suivantes.
async fn pump_track_segments_owned(
first_segment: Arc<AudioSegment>,
mut rx: mpsc::Receiver<Arc<AudioSegment>>,
pcm_tx: mpsc::Sender<Vec<u8>>,
bits_per_sample: u8,
expected_rate: u32,
stop_token: CancellationToken,
) -> Result<(u64, u64, f64, StopReason, mpsc::Receiver<Arc<AudioSegment>>), AudioError> {
let mut chunks = 0u64;
let mut samples = 0u64;
let mut duration_sec = 0.0f64;
// Traiter le premier segment
if let Some(chunk) = first_segment.as_chunk() {
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
if !pcm_bytes.is_empty() {
if pcm_tx.send(pcm_bytes).await.is_err() {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
}
chunks += 1;
samples += chunk.len() as u64;
duration_sec += chunk.len() as f64 / expected_rate as f64;
}
}
// Loop pour le reste des segments...
loop {
let segment = tokio::select! {
result = rx.recv() => {
match result {
Some(seg) => seg,
None => {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
}
}
}
_ = stop_token.cancelled() => {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
}
};
match &segment.segment {
_AudioSegment::Chunk(chunk) => {
let pcm_bytes = chunk_to_pcm_bytes(chunk, bits_per_sample)?;
if !pcm_bytes.is_empty() {
if pcm_tx.send(pcm_bytes).await.is_err() {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::ChannelClosed, rx));
}
chunks += 1;
samples += chunk.len() as u64;
duration_sec += chunk.len() as f64 / expected_rate as f64;
}
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata, .. } => {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::TrackBoundary(metadata.clone()), rx));
}
SyncMarker::EndOfStream => {
drop(pcm_tx);
return Ok((chunks, samples, duration_sec, StopReason::EndOfStream, rx));
}
_ => continue,
},
}
}
}
```
### Étape 2: Modifier FlacCacheSinkLogic::process
Location: `pmoaudio-ext/src/sinks/flac_cache_sink.rs:~167`
```rust
let collection_ref = self.collection.as_deref();
let cache_future = self.cache.add_from_reader(
None,
flac_stream,
None,
collection_ref,
);
// Spawner pump_future avec ownership de rx
let pump_handle = tokio::spawn(pump_track_segments_owned(
first_segment,
rx, // move ownership!
pcm_tx,
bits_per_sample,
sample_rate,
stop_token.clone(),
));
// Attendre SEULEMENT le prebuffer (cache retourne après 512KB)
tracing::debug!("FlacCacheSink: Waiting for cache prebuffer to complete");
let pk = cache_future.await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to add to cache: {}", e))
})?;
tracing::debug!("FlacCacheSink: Prebuffer complete with pk {}, pushing to playlist NOW", pk);
// Copier les métadonnées AVANT push
if let Some(src_metadata) = track_metadata.clone() {
let dest_metadata = self.cache.track_metadata(&pk);
pmometadata::copy_metadata_into(&src_metadata, &dest_metadata)
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to copy metadata to cache: {}", e))
})?;
}
// Push IMMÉDIATEMENT à la playlist (après prebuffer, avant pump complet!)
#[cfg(feature = "playlist")]
if let Some(ref playlist_handle) = self.playlist_handle {
tracing::debug!("FlacCacheSink: Pushing pk {} to playlist", pk);
playlist_handle.push(pk.clone()).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to add to playlist: {}", e))
})?;
tracing::debug!("FlacCacheSink: Successfully pushed to playlist");
}
// MAINTENANT attendre que pump finisse (il continue en arrière-plan)
tracing::debug!("FlacCacheSink: Waiting for pump to complete");
let pump_result = pump_handle.await.map_err(|e| {
AudioError::ProcessingError(format!("Pump task panicked: {}", e))
})?;
let (_chunks, _samples, _duration_sec, stop_reason, rx_returned) = pump_result?;
rx = rx_returned; // récupérer rx pour la prochaine track
tracing::debug!("FlacCacheSink: Pump completed");
// Continuer avec download des covers en arrière-plan...
```
### Étape 3: Tester
```bash
# Nettoyer et rebuild
rm -rf /tmp/pmomusic_test
source setup-env.sh
cargo build --example play_and_cache --features full
# Tester avec logs de timing
RUST_LOG=debug target/debug/examples/play_and_cache 0 --null-audio 2>&1 | \
grep -E "Prebuffer complete|Pushing pk.*to playlist|popped track" | \
head -20
```
**Résultats attendus:**
```
[TIME_A] FlacCacheSink: Prebuffer complete with pk XXX, pushing to playlist NOW
[TIME_B] FlacCacheSink: Successfully pushed to playlist
[TIME_C] PlaylistSourceLogic: popped track from playlist
Délai (TIME_C - TIME_A) devrait être < 1 seconde!
```
### Étape 4: Valider le comportement
Vérifier que:
1. ✅ Le prebuffer est atteint rapidement (~0.6s)
2. ✅ Le push à la playlist est immédiat (~0.1s après prebuffer)
3. ✅ La lecture démarre rapidement (~1s total)
4. ✅ Toutes les tracks se suivent correctement
5. ✅ Les completion markers sont créés
6. ✅ Les tracks suivantes fonctionnent (rx est bien récupéré)
7. ✅ Pas de panic ou deadlock
## Debugging
### Si le borrow checker proteste
Vérifier que:
- `pump_track_segments_owned` prend bien ownership de `rx` (pas `&mut`)
- `rx` est bien retourné dans le tuple de retour
- `rx = rx_returned;` récupère bien ownership après await
### Si les tracks suivantes ne fonctionnent pas
Vérifier que:
- `rx` est bien réassigné après le pump: `rx = rx_returned;`
- La loop dans `process()` continue correctement avec le nouveau `rx`
### Si le timing n'est pas amélioré
Ajouter des logs avec timestamps:
```rust
let start = std::time::Instant::now();
let pk = cache_future.await?;
tracing::info!("Prebuffer took {:?}", start.elapsed());
let start2 = std::time::Instant::now();
playlist_handle.push(pk.clone()).await?;
tracing::info!("Playlist push took {:?}", start2.elapsed());
```
## Fichiers à modifier
1. **pmoaudio-ext/src/sinks/flac_cache_sink.rs**
- Ajouter `pump_track_segments_owned()` (~ligne 432)
- Modifier `FlacCacheSinkLogic::process()` (~ligne 167)
## Tests de régression
Après l'implémentation, tester:
```bash
# Test 1: Premier download (cache vide)
rm -rf /tmp/pmomusic_test
target/debug/examples/play_and_cache 0 --null-audio
# Test 2: Deuxième download (fichier déjà en cache)
# Ne pas supprimer /tmp/pmomusic_test
target/debug/examples/play_and_cache 0 --null-audio
# Test 3: Download interrompu (Ctrl+C)
target/debug/examples/play_and_cache 0 --null-audio
# Appuyer Ctrl+C après 2 secondes
# Test 4: Plusieurs tracks consécutives
# Laisser tourner 1 minute pour voir plusieurs tracks
timeout 60 target/debug/examples/play_and_cache 0 --null-audio
```
## Métriques de succès
- ✅ Délai prebuffer → playlist: **< 1 seconde** (actuellement ~19s)
- ✅ Délai prebuffer → lecture: **< 2 secondes** (actuellement ~19.5s)
- ✅ Pas de régression fonctionnelle
- ✅ Toutes les tracks se suivent correctement
- ✅ Les completion markers sont créés
## Références
- Branche actuelle: `claude/fix-play-and-cache-streaming-011CUsMBxH4fsgoadgkiPdoK`
- Code de référence: commit `ed0bbfb` (Add FlacCacheSink debug logs - system now works!)
- Issue originale: "play_and_cache n'a pas le comportement souhaité"

View File

@@ -1,285 +0,0 @@
# Player Générique PMO Music
## Vue d'ensemble
Ce document décrit l'implémentation d'un nouveau player web générique qui utilise **uniquement** l'API du trait `pmosource` sans dépendre d'aucune implémentation spécifique (comme `pmoparadise`).
## Objectifs
L'objectif principal est de **tester l'API `pmosource` dans un cas d'application concret** afin d'identifier ce qui manque ou pourrait être amélioré dans l'API générique.
## Architecture
### 1. Service API TypeScript (`pmoapp/webapp/src/services/pmosource.ts`)
Service qui encapsule toutes les interactions avec l'API REST de pmosource :
```typescript
// Endpoints utilisés
GET /api/sources // Liste les sources
GET /api/sources/{id} // Info sur une source
GET /api/sources/{id}/root // Container racine
GET /api/sources/{id}/browse // Parcourt un container
GET /api/sources/{id}/resolve // Résout l'URI d'un item
GET /api/sources/{id}/image // Image de la source
GET /api/sources/{id}/capabilities // Capacités de la source
```
**Fonctions implémentées :**
- `listSources()` - Liste toutes les sources enregistrées
- `getSource(id)` - Récupère une source spécifique
- `getSourceRoot(id)` - Récupère le container racine
- `browseSource(id, objectId?, pagination?)` - Navigation dans les containers
- `resolveUri(sourceId, objectId)` - Résout l'URI de streaming
- `getSourceImageUrl(id)` - URL de l'image de la source
### 2. Composant Player (`pmoapp/webapp/src/components/GenericMusicPlayer.vue`)
Composant Vue.js qui implémente :
#### Fonctionnalités implémentées
1. **Sélection de sources**
- Affichage de toutes les sources disponibles
- Affichage du logo de chaque source
- Affichage des capacités (FIFO, Search, Favorites)
2. **Navigation dans les containers**
- Breadcrumb pour remonter dans la hiérarchie
- Affichage des sous-containers (dossiers)
- Navigation par clic dans les containers
3. **Liste des morceaux**
- Affichage de tous les items audio d'un container
- Métadonnées : titre, artiste, album, cover art
- Numérotation des morceaux
4. **Lecteur audio**
- Lecture d'un morceau via résolution d'URI
- Contrôles audio natifs HTML5
- Section "Now Playing" avec métadonnées
- Gestion des erreurs de lecture
5. **Interface utilisateur**
- Design moderne avec dégradés et animations
- Responsive design
- Indicateurs visuels (morceau actif, en cours de lecture)
- Messages d'erreur clairs
### 3. Intégration
Le player a été configuré comme **page d'accueil par défaut** de l'application web PMO :
```typescript
// router/index.ts
const routes = [
{ path: "/", name: "home", component: GenericMusicPlayer },
// ... autres routes
]
```
## Ce qui fonctionne
**Complètement fonctionnel avec l'API actuelle de pmosource :**
1. Découverte des sources disponibles
2. Navigation complète dans la hiérarchie des containers
3. Affichage des métadonnées des morceaux
4. Résolution des URIs et lecture audio
5. Affichage des images de sources
6. **Métadonnées temps réel via Server-Sent Events (SSE)** 🆕
- Mise à jour automatique toutes les 3 secondes
- Pas de polling, push serveur
- Reconnexion automatique
## Limitations identifiées et améliorations possibles
### 1. Métadonnées de couverture d'album
**Problème :** Le trait `MusicSource` n'expose pas directement de méthode pour résoudre les URIs de couvertures d'album.
**État actuel :**
- Le champ `album_art` dans `Item` contient parfois une URI
- Le champ `album_art_pk` contient une clé primaire mais pas d'URL exploitable directement
- Certaines implémentations (pmoparadise) utilisent `/cache/cover/{pk}` mais ce n'est pas standardisé
**Proposition :**
```rust
/// Résout l'URI de la couverture d'album pour un item
async fn resolve_cover_uri(&self, object_id: &str) -> Result<Option<String>>;
```
### 2. Recherche globale
**Problème :** La méthode `search()` existe mais retourne `SearchNotSupported` par défaut.
**État actuel :**
- Pas d'interface standardisée pour la recherche dans l'UI
- Pas de retour clair sur les capacités de recherche
**Proposition :**
- Utiliser `capabilities().supports_search` pour afficher/masquer l'UI de recherche
- Documenter clairement le format attendu des requêtes de recherche
### 3. Pagination
**Problème :** L'API supporte la pagination mais les métadonnées ne permettent pas de connaître le nombre total d'items.
**État actuel :**
- `BrowseResponse.total` retourne le nombre d'items retournés, pas le total disponible
- Pas de méthode `get_total_count(object_id)` dans le trait
**Proposition :**
```rust
/// Retourne le nombre total d'items dans un container
async fn get_total_count(&self, object_id: &str) -> Result<usize>;
```
Ou ajouter `total_available` dans `BrowseResponse` :
```rust
pub struct SourceBrowseResponse {
// ... champs existants
pub total_available: Option<usize>, // Total disponible (pas juste retourné)
}
```
### 4. Métadonnées de stream en temps réel ✅ **IMPLÉMENTÉ**
**Solution implémentée :**
- ✅ Méthode `get_item(object_id)` dans le trait `MusicSource`
- ✅ Endpoint REST `GET /api/sources/{id}/item?object_id={id}` pour récupérer les métadonnées d'un item
- ✅ Endpoint SSE `GET /api/sources/{id}/item/stream?object_id={id}` pour recevoir les mises à jour en temps réel
- ✅ Le player web utilise Server-Sent Events (SSE) pour les métadonnées temps réel
**Comment ça fonctionne :**
1. Le serveur envoie automatiquement les métadonnées à jour toutes les 3 secondes via SSE
2. Le client se connecte avec `EventSource` (API browser native)
3. Les métadonnées sont automatiquement mises à jour dans l'interface sans polling
**Pour RadioParadise :**
- La méthode `get_item()` pour les live streams récupère les métadonnées depuis `/radioparadise/metadata/{slug}`
- Le SSE permet d'avoir les métadonnées à jour en moins de 3 secondes (au lieu de 10 secondes avec le polling)
### 5. Playlists utilisateur
**Problème :** Les méthodes existent (`get_user_playlists()`, `add_to_playlist()`) mais retournent `NotSupported` par défaut.
**État actuel :**
- Pas encore testé dans le player
- Nécessiterait une UI dédiée
**Proposition :**
- Créer une section "Playlists" dans le player
- Tester l'API avec une implémentation qui supporte les playlists (ex: Qobuz)
### 6. Favoris
**Problème :** Similaire aux playlists, l'API existe mais n'est pas testée.
**Proposition :**
- Ajouter un bouton "⭐ Favoris" sur chaque morceau
- Afficher visuellement les morceaux favoris
- Créer une section "Mes Favoris"
### 7. Auto-play / Queue
**Problème :** Il n'y a pas de méthode pour gérer une file d'attente de lecture.
**Proposition :**
```rust
/// Interface pour gérer une queue de lecture
pub trait Playable: MusicSource {
async fn get_next_track(&self) -> Result<Option<Item>>;
async fn get_previous_track(&self) -> Result<Option<Item>>;
async fn add_to_queue(&self, item: Item) -> Result<()>;
async fn clear_queue(&self) -> Result<()>;
async fn get_queue(&self) -> Result<Vec<Item>>;
}
```
### 8. Durée totale d'un container
**Problème :** Pour afficher "Album: 45:32 min, 12 morceaux", il faut parcourir tous les items.
**Proposition :**
```rust
/// Statistiques d'un container spécifique
async fn get_container_stats(&self, object_id: &str) -> Result<ContainerStats>;
pub struct ContainerStats {
pub item_count: usize,
pub total_duration_ms: Option<u64>,
pub total_size_bytes: Option<u64>,
}
```
### 9. Formats audio disponibles
**Problème :** La méthode `get_available_formats()` existe mais n'est pas exploitée dans l'UI.
**Proposition :**
- Ajouter un sélecteur de qualité dans le player
- Afficher les formats disponibles (FLAC 24/96, MP3 320, etc.)
### 10. État du cache
**Problème :** Les méthodes existent (`get_cache_status()`, `cache_item()`) mais ne sont pas intégrées.
**Proposition :**
- Afficher un indicateur de cache sur chaque morceau
- Bouton "📥 Télécharger" pour mettre en cache
- Barre de progression pour le téléchargement
## Prochaines étapes
### Court terme
1. ✅ Tester le player avec `pmoparadise` (déjà implémenté)
2. 🔄 Identifier les bugs et limitations pratiques
3. 🔄 Tester avec une deuxième source (ex: `pmoqobuz`) pour valider la généricité
### Moyen terme
1. Implémenter les fonctionnalités manquantes identifiées ci-dessus
2. Ajouter la gestion de queue et auto-play
3. Ajouter la recherche si supportée
4. Intégrer la gestion du cache
### Long terme
1. Support des playlists utilisateur
2. Support des favoris
3. Égaliseur et effets audio
4. Visualisations audio
5. Mode hors-ligne avec cache
## Conclusion
Le player générique démontre que **l'API `pmosource` est déjà très utilisable** pour créer une application musicale fonctionnelle. Les principales limitations concernent :
1. **Les métadonnées de couvertures** (pas d'URL standardisée)
2. **La pagination avancée** (pas de compte total)
3. **Les métadonnées temps réel** (pour les streams live)
4. **La gestion de queue** (pas d'API dédiée)
Ces limitations ne sont pas bloquantes mais leur résolution améliorerait significativement l'expérience utilisateur et la complétude de l'API.
## Utilisation
Pour tester le player :
1. Lancer le serveur backend avec au moins une source enregistrée :
```bash
cargo run --example single_channel_server --features full
```
2. Accéder à l'application web :
```
http://localhost:8080/app/
```
3. Le player devrait afficher automatiquement les sources disponibles et permettre la navigation et la lecture.
## Remarques importantes
- ✅ Le player **n'utilise QUE l'API pmosource générique**
- ✅ Aucune dépendance sur `pmoparadise` ou toute autre implémentation spécifique
- ✅ Tout est basé sur les endpoints REST de `pmosource::api`
- ✅ Le code est totalement réutilisable pour toute nouvelle source (Qobuz, Spotify, etc.)

View File

@@ -1,21 +0,0 @@
host:
http_port: '8080'
cover_cache:
directory: cache_covers
size: 2000
audio_cache:
directory: cache_audio
size: 500
logger:
buffer_capacity: 200
enable_console: true
min_level: INFO
playlists:
directory: playlists
devices:
mediarenderer:
pmo_mediarenderer:
udn: f77de90b-3a4a-408c-8462-3308ad500744
mediaserver:
pmo_mediaserver:
udn: 88b84e76-4de0-4ee6-b794-99cc4a278cc9

View File

@@ -1,25 +1,17 @@
[package]
name = "PMOMusic"
version = "0.3.5"
version = "0.1.0"
edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmoupnp = { path = "../pmoupnp"}
pmomediarenderer = { path = "../pmomediarenderer" }
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
pmosource = { path = "../pmosource", features = ["server"] }
pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
pmoapp = { path = "../pmoapp" }
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
tracing = { workspace = true }
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
axum = "0.8.4"
serde_json = "1.0.145"
utoipa = "5.4"
console-subscriber = "0.4.1"

View File

@@ -1,25 +0,0 @@
[package]
name = "PMOMusic"
version = "0.1.0"
edition = "2024"
[dependencies]
pmoconfig = { path = "../pmoconfig" }
pmoupnp = { path = "../pmoupnp"}
pmomediarenderer = { path = "../pmomediarenderer" }
pmomediaserver = { path = "../pmomediaserver", features = ["qobuz", "paradise", "paradise-api", "api"] }
pmosource = { path = "../pmosource", features = ["server"] }
pmoserver = { path = "../pmoserver" }
pmocovers = { path = "../pmocovers", features = ["pmoserver"] }
pmoaudiocache = { path = "../pmoaudiocache", features = ["pmoserver"]}
pmoaudio-ext = { path = "../pmoaudio-ext", features = ["all"] }
pmoapp = { path = "../pmoapp", features = ["pmoserver"] }
pmocontrol = { path = "../pmocontrol", features = ["pmoserver"] }
tokio = { version = "1.35", features = ["rt-multi-thread", "macros", "sync", "time","signal"] }
tracing = "0.1.41"
tracing-subscriber = "0.3.20"
axum = "0.8.4"
serde_json = "1.0.145"
utoipa = "5.4"
console-subscriber = "0.4.1"

View File

@@ -1,135 +1,58 @@
use pmoapp::{WebAppExt, Webapp};
use pmocontrol::ControlPointExt;
use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
use pmoupnp::{mediarenderer::avtransport::AVTTRANSPORT, UpnpObject};
use pmoserver::{
logs::{log_dump, log_sse, LogState, SseLayer},
ServerBuilder
};
use pmoserver::Server;
use pmosource::MusicSourceExt;
use pmoupnp::UpnpServerExt;
use pmoapp::Webapp;
use tracing_subscriber::Registry;
use tracing_subscriber::prelude::*;
use tracing::info;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// ========== PHASE 1 : Infrastructure UPnP ==========
// #[cfg(tokio_unstable)]
// console_subscriber::init();
async fn main() {
// Charger la config
let mut server = ServerBuilder::new_configured().build();
// Ajouter des routes
server
.add_route("/hello", || async {
serde_json::json!({"message": "Hello World"})
})
.await;
let server = Server::create_upnp_server().await?; // Routes personnalisées de l'application
server
.write()
.await
.add_route("/info", || async {
serde_json::json!({"version": "1.0.0"})
})
.await;
// Initialiser le système de gestion des sources musicales avec API REST
info!("📡 Initializing music sources management system...");
server.add_spa::<Webapp>("/app").await;
// Gère la sortie des logs et sur le serveur SSE pour l'interface web et sur la console
let log_state = LogState::new(1000);
let subscriber = Registry::default()
.with(
tracing_subscriber::fmt::layer()
.with_target(true)
.with_level(true)
.with_ansi(true), // Couleurs dans le terminal
)
.with(SseLayer::new(log_state.clone()));
tracing::subscriber::set_global_default(subscriber).unwrap();
server
.write()
.await
.init_music_sources()
.await
.expect("Failed to initialize music sources API");
// ========== PHASE 2 : Configuration métier ==========
// Enregistrer les sources musicales
info!("🎵 Registering music sources...");
// Enregistrer Qobuz pour activer les lazy providers (QOBUZ:PK)
if let Err(e) = server.write().await.register_qobuz().await {
tracing::warn!("⚠️ Failed to register Qobuz source: {}", e);
}
// Initialiser les canaux de streaming Radio Paradise (pipelines + routes HTTP)
info!("📻 Initializing Radio Paradise streaming channels...");
if let Err(e) = server.write().await.init_paradise_streaming().await {
tracing::warn!("⚠️ Failed to initialize Paradise streaming: {}", e);
} else {
// Enregistrer la source Radio Paradise UPnP (inclut l'initialisation de l'API)
if let Err(e) = server.write().await.register_paradise().await {
tracing::warn!("⚠️ Failed to register Radio Paradise source: {}", e);
}
}
// Lister toutes les sources enregistrées
let sources = server.read().await.list_music_sources().await;
info!("✅ {} music source(s) registered", sources.len());
for source in sources {
info!(" - {} ({})", source.name(), source.id());
}
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
info!("📡 Registering UPnP devices...");
let renderer_instance = server
.write()
.await
.register_device(MEDIA_RENDERER.clone())
.await
.expect("Failed to register MediaRenderer");
let base_url = renderer_instance.base_url();
let desc_route = renderer_instance.description_route();
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
let server_instance = server
.write()
.await
.register_device(MEDIA_SERVER.clone())
.await
.expect("Failed to register MediaServer");
// Enregistrer l'instance ContentDirectory pour les notifications GENA
if let Some(cd_service) = server_instance.get_service("ContentDirectory") {
pmomediaserver::contentdirectory::state::register_instance(&cd_service);
}
// Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
info!(
"✅ MediaServer ready at {}{}",
server_instance.base_url(),
server_instance.description_route()
);
// Enregistrer le Control Point (découverte renderers/serveurs + API REST + SSE)
info!("🎛️ Registering Control Point...");
let _control_point = server
.write()
.await
.register_control_point(5)
.await
.expect("Failed to register Control Point");
// Ajouter la webapp via le trait WebAppExt
info!("📡 Registering Web application...");
.add_handler_with_state("/log-sse", log_sse, log_state.clone())
.await;
server
.write()
.await
.add_webapp_with_redirect::<Webapp>("/app")
.add_handler_with_state("/log-dump", log_dump, log_state.clone())
.await;
// ========== PHASE 3 : Démarrage du serveur ==========
server.add_redirect("/", "/app").await;
info!("🌐 Starting HTTP server...");
server.write().await.start().await;
info!("{}",AVTTRANSPORT.to_markdown());
info!("{}",AVTTRANSPORT.scpd_xml());
info!("✅ PMOMusic is ready!");
info!("Press Ctrl+C to stop...");
// Attendre le signal Ctrl+C et l'arrêt du serveur HTTP
server.write().await.wait().await;
// Le serveur HTTP est arrêté, mais des threads (ControlPoint, etc.) peuvent encore tourner
// Attendre 2 secondes pour laisser le temps aux threads de se terminer
info!("Waiting for background threads to finish...");
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
// Forcer l'arrêt du processus (les threads du ControlPoint tournent en boucle infinie)
info!("✅ PMOMusic stopped");
std::process::exit(0);
server.start().await;
server.wait().await;
}

View File

@@ -1,447 +0,0 @@
# PMOControl WebUI - Design Recommendations & Implementation Plan
## Executive Summary
Based on my analysis of the existing codebase, I'm providing comprehensive recommendations for implementing a Vue.js WebUI for PMOControl. The system already has:
- A complete REST API with OpenAPI documentation (`/api/control/*`)
- SSE endpoints for real-time updates (`/api/control/events/*`)
- Vue 3 + TypeScript + Vite setup
- Existing components (GenericMusicPlayer, UpnpExplorer, Cache Managers, LogView)
---
## Design Decisions & Recommendations
### 1. State Management: **Use Pinia**
**Recommendation: Pinia (Vue 3's official state management)**
**Rationale:**
- **Centralized real-time state**: Essential for managing SSE updates from multiple sources (renderers, media servers)
- **Multi-client synchronization**: Single source of truth for renderer states, volumes, playback positions
- **TypeScript native**: Better type inference than Vuex
- **DevTools integration**: Built-in debugging for SSE event flows
- **Composition API friendly**: Matches existing Vue 3 patterns in codebase
- **Performance**: Lightweight (~1KB), modular stores
- **Official Vue 3 recommendation**: Future-proof choice
**Store Architecture:**
```typescript
// stores/renderers.ts - Renderer state (SSE updates)
// stores/mediaServers.ts - Media server state (SSE updates)
// stores/playback.ts - Current playback session
// stores/ui.ts - UI state (selected renderer, view preferences)
```
**Benefits for your use case:**
- Handle 20+ concurrent clients with shared state
- Real-time SSE event synchronization across all views
- Easy to scale with multi-renderer, multi-server, multi-session architecture
---
### 2. UI Component Library: **Headless UI + Custom Components**
**Recommendation: Hybrid approach - Headless UI components + custom styling**
**Component Library: Shadcn-vue (Headless UI primitives)**
**Rationale:**
- **Lightweight & performant**: Only import what you need
- **Full style control**: Match "carte uniforme, responsive, colorée selon statut" spec exactly
- **TypeScript-first**: Perfect type safety
- **Accessibility built-in**: ARIA compliance out of the box
- **No theme lock-in**: Complete CSS freedom
- **Composable primitives**: Card, Dialog, Dropdown, Slider components
**Why NOT a full framework (Vuetify, Element Plus)?**
- Heavy bundle size (100-500KB vs ~10KB for headless)
- Theme customization overhead
- Your spec requires custom status-based coloring
- Performance critical with 20 concurrent clients
**Alternative if you prefer pre-styled:** PrimeVue
- Good performance
- Customizable themes
- Strong TypeScript support
- But: 150KB+ bundle size
**Custom Components to Build:**
- `RendererCard` - Status-colored cards for each renderer
- `TransportControls` - Play/Pause/Stop/Next buttons
- `VolumeControl` - Slider with mute toggle
- `QueueViewer` - Playlist display with drag-drop
- `MediaServerBrowser` - Container navigation
---
### 3. Existing Components: **Reorganize into Debug Section**
**Recommendation: Keep existing components, create new PMOControl home**
**Structure:**
```
/app (root) → PMOControl Dashboard (NEW)
/app/debug → Dropdown menu
├─ /logs → LogView
├─ /upnp → UpnpExplorer
├─ /covers-cache → CoverCacheManager
├─ /audio-cache → AudioCacheManager
├─ /api-dashboard → APIDashboard
└─ /radio-paradise → RadioParadiseExplorer
```
**Rationale:**
- Existing components are valuable for development/debugging
- Don't break existing functionality
- PMOControl becomes primary interface as specified
- Debug tools remain accessible but not prominent
- Matches current App.vue dropdown pattern
**Home Screen (/) - PMOControl Dashboard:**
- Grid of renderer cards (status-colored)
- Active playback session viewer
- Quick controls (play/pause/volume)
- Media server browser panel
---
### 4. Responsive Design: **Mobile-first with 3 breakpoints**
**Recommendation: Follow existing 768px pattern + add tablet/desktop**
**Breakpoints:**
```css
/* Mobile: < 768px (existing pattern) */
- Single column layout
- Stacked renderer cards
- Bottom-fixed playback controls
- Collapsible media browser
/* Tablet: 768px - 1024px */
- Two column layout
- Grid of renderer cards (2 columns)
- Side panel for media browser
- Floating playback controls
/* Desktop: > 1024px */
- Three column layout
- Renderer cards grid (3-4 columns)
- Persistent media browser sidebar
- Always-visible playback controls
```
**Target Devices:**
- **Primary**: Desktop browsers (control station)
- **Secondary**: Tablets (remote control)
- **Tertiary**: Mobile phones (quick controls)
**Performance considerations:**
- Virtualized lists for 20+ renderers (use vue-virtual-scroller)
- Lazy load album art
- Throttle SSE position updates (max 1/sec per renderer)
---
### 5. Icons: **Lucide Icons (SVG library)**
**Recommendation: Lucide Icons (NOT emoji)**
**Rationale:**
- **Professional appearance**: Emojis inconsistent across platforms
- **Customizable**: Size, color, stroke width
- **Lightweight**: Tree-shakeable SVG imports (~1KB per icon)
- **Status coloring**: Icons can match card status colors
- **Accessibility**: Proper ARIA labels
- **Vue components**: `lucide-vue-next` package
**Icon mapping:**
```typescript
Play PlayCircle
Pause PauseCircle
Stop StopCircle
Next SkipForward
Volume Volume2 / VolumeX (muted)
Renderer Speaker / MonitorSpeaker
Server Server / Database
Queue ListMusic
```
**Alternative if you prefer minimal bundle:** Heroicons
- Smaller set (fewer icons)
- Tailwind CSS integration
- But: less comprehensive for music player needs
**Why NOT emoji:**
- Platform inconsistencies (iOS ≠ Android ≠ Windows)
- No color control
- Accessibility issues
- Unprofessional for production UI
---
## Technical Architecture
### Real-time SSE Integration
**SSE Event Handling:**
```typescript
// services/controlPointSSE.ts
class ControlPointSSE {
private eventSource: EventSource
private renderersStore: ReturnType<typeof useRenderersStore>
connect() {
this.eventSource = new EventSource('/api/control/events')
this.eventSource.addEventListener('control', (e) => {
const event = JSON.parse(e.data)
if (event.category === 'renderer') {
this.handleRendererEvent(event)
} else if (event.category === 'media_server') {
this.handleServerEvent(event)
}
})
}
handleRendererEvent(event: RendererEventPayload) {
switch (event.type) {
case 'state_changed':
this.renderersStore.updateState(event.renderer_id, event.state)
break
case 'volume_changed':
this.renderersStore.updateVolume(event.renderer_id, event.volume)
break
// ... handle all event types
}
}
}
```
**Store Integration:**
```typescript
// stores/renderers.ts
export const useRenderersStore = defineStore('renderers', () => {
const renderers = ref<Map<string, RendererState>>(new Map())
// SSE updates
function updateState(id: string, state: string) {
const renderer = renderers.value.get(id)
if (renderer) {
renderer.transport_state = state
}
}
// REST API calls
async function play(id: string) {
await fetch(`/api/control/renderers/${id}/play`, { method: 'POST' })
// SSE will update state automatically
}
return { renderers, updateState, play }
})
```
### Performance Optimizations
**For 20+ concurrent clients:**
1. **Throttle position updates**:
```typescript
const throttledPositionUpdate = throttle((id, pos) => {
store.updatePosition(id, pos)
}, 1000) // Max 1 update/second
```
2. **Virtual scrolling** for renderer lists:
```bash
npm install vue-virtual-scroller
```
3. **Lazy load album art**:
```vue
<img :src="albumArt" loading="lazy" />
```
4. **Debounce volume sliders**:
```typescript
const debouncedVolumeChange = debounce((id, vol) => {
api.setVolume(id, vol)
}, 300)
```
5. **Memoize computed properties**:
```typescript
const activeRenderers = computed(() =>
renderers.value.filter(r => r.online)
)
```
---
## Implementation Roadmap
### Phase 1: Core Infrastructure (Week 1)
1. Install Pinia + configure stores
2. Install Lucide Icons
3. Create SSE service layer
4. Setup store structure (renderers, servers, playback, ui)
5. Connect SSE events to stores
### Phase 2: UI Components (Week 2)
5. Build RendererCard component (status-colored)
6. Build TransportControls component
7. Build VolumeControl component
8. Build QueueViewer component
9. Create responsive grid layouts
### Phase 3: Dashboard Assembly (Week 3)
10. Create PMOControl home view
11. Integrate all components
12. Add media server browser panel
13. Implement responsive breakpoints
14. Add loading states & error handling
### Phase 4: Polish & Testing (Week 4)
15. Test with 20+ concurrent clients
16. Performance profiling & optimization
17. Accessibility audit (ARIA, keyboard nav)
18. Cross-browser testing
19. Mobile/tablet testing
20. Documentation
---
## Dependencies to Install
```json
{
"dependencies": {
"pinia": "^2.2.8",
"lucide-vue-next": "^0.470.0",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
"devDependencies": {
// Already installed: vue, vue-router, typescript, vite
}
}
```
**Total bundle size estimate:** +15KB gzipped (Pinia + Lucide + Virtual Scroller)
---
## Status-based Coloring Scheme
Based on "carte uniforme, responsive, colorée selon statut" spec:
```css
/* Renderer Card Status Colors */
.renderer-card.playing {
border-color: #22c55e; /* green */
background: linear-gradient(135deg, #22c55e10, transparent);
}
.renderer-card.paused {
border-color: #f59e0b; /* amber */
background: linear-gradient(135deg, #f59e0b10, transparent);
}
.renderer-card.stopped {
border-color: #6b7280; /* gray */
background: linear-gradient(135deg, #6b728010, transparent);
}
.renderer-card.offline {
border-color: #ef4444; /* red */
background: linear-gradient(135deg, #ef444410, transparent);
opacity: 0.6;
}
.renderer-card.transitioning {
border-color: #3b82f6; /* blue */
background: linear-gradient(135deg, #3b82f610, transparent);
animation: pulse 2s infinite;
}
```
---
## Answers to Your Specific Questions
### 1. State Management?
**Answer: Pinia** - Vue 3 official, perfect for SSE real-time updates, TypeScript native, lightweight
### 2. UI Component Library?
**Answer: Headless UI (Shadcn-vue) + Custom Components** - Full control over status-based styling, lightweight, no theme lock-in
### 3. Keep existing components?
**Answer: Yes, reorganize into Debug section** - Keep valuable dev tools, make PMOControl the new home screen
### 4. Responsive breakpoints?
**Answer: Mobile-first with 3 breakpoints** - <768px (mobile), 768-1024px (tablet), >1024px (desktop)
### 5. Icons?
**Answer: Lucide Icons (SVG library)** - Professional, customizable, status-colored, NOT emoji
---
## Risk Mitigation
**Potential challenges:**
1. **SSE connection management across tabs**
- Solution: Use BroadcastChannel API for cross-tab sync
- Fallback: LocalStorage events
2. **20+ renderers performance**
- Solution: Virtual scrolling + throttled updates
- Monitor: Chrome DevTools Performance profiler
3. **Network reliability (SSE reconnection)**
- Solution: Exponential backoff reconnection
- UI indicator for connection status
4. **Album art loading (CORS, 404s)**
- Solution: Proxy through backend
- Fallback: Default placeholder image
5. **Browser compatibility (SSE support)**
- Chrome/Edge: Native support ✅
- Firefox: Native support ✅
- Safari: Native support ✅
- IE11: Use EventSource polyfill
---
## Success Metrics
**Performance targets:**
- Initial load: <2s (FCP)
- SSE event latency: <100ms
- UI interaction: <16ms (60fps)
- Memory usage: <50MB with 20 renderers
- Bundle size: <250KB gzipped
**Functionality checklist:**
- [ ] Display all discovered renderers in real-time
- [ ] Show accurate playback state (play/pause/stop)
- [ ] Volume control works across all renderer types
- [ ] Queue display syncs with server
- [ ] Media server browsing functional
- [ ] Playlist attachment working
- [ ] Responsive on mobile/tablet/desktop
- [ ] Accessible (WCAG AA compliance)
- [ ] 20+ concurrent clients supported
---
## Next Steps
1. **Review & approve** this plan with stakeholders
2. **Clarify any ambiguities** in requirements
3. **Set up development environment** (install dependencies)
4. **Begin Phase 1** (Core Infrastructure)
Would you like me to proceed with implementation, or do you have questions about any of these recommendations?

View File

@@ -1,43 +1,5 @@
# Développement de l'application PMOMusic en RUST
## 🚀 Démarrage rapide
### Installation des dépendances (environnement sans sudo)
Pour compiler PMOMusic dans un environnement sans privilèges sudo (comme Claude Code) :
```bash
# 1. Installation automatique de libsoxr et libasound2 (une seule fois)
./setup-deps.sh
# 2. Créer le fichier setup-env.sh (une seule fois, voir INSTALL_LIBSOXR.md pour le contenu)
cat > setup-env.sh << 'EOF'
#!/bin/bash
export PKG_CONFIG_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu/pkgconfig:$PKG_CONFIG_PATH"
export LD_LIBRARY_PATH="$HOME/.local/usr/lib/x86_64-linux-gnu:$LD_LIBRARY_PATH"
export RUSTFLAGS="-L $HOME/.local/usr/lib/x86_64-linux-gnu"
echo "Variables d'environnement configurées pour PMOMusic"
EOF
# 3. Configuration de l'environnement (à chaque nouvelle session)
source setup-env.sh
# 4. Compilation
cargo build
# 5. Test de l'exemple Radio Paradise
cargo run --package pmoparadise --example play_and_cache --features full -- 0
```
⚠️ **Note :** Le fichier `setup-env.sh` est dans `.gitignore` car il contient une configuration locale.
### Documentation
- **[INSTALL_NOTES.md](INSTALL_NOTES.md)** - Guide d'installation général
- **[INSTALL_LIBSOXR.md](INSTALL_LIBSOXR.md)** - Installation détaillée de libsoxr et ALSA
---
## Création de la structure
```bash
@@ -136,10 +98,3 @@ jj rebase --continue
pour résoudre les conflits
## Installer rust sur mac
```bash
brew install rustup-init
rustup-init
rustup default stable
```

View File

@@ -1,27 +0,0 @@
# Configuration Sécurisée
## Configuration de PMOMusic
Le fichier `.pmomusic.yml` contient des informations sensibles (mots de passe, identifiants).
### Installation
1. Copiez le fichier exemple :
```bash
cp .pmomusic.yml.example .pmomusic.yml
```
2. Éditez `.pmomusic.yml` et remplacez les valeurs par vos véritables identifiants :
- `accounts.qobuz.username` : votre email Qobuz
- `accounts.qobuz.password` : votre mot de passe Qobuz
3. **Important** : Ne commitez JAMAIS le fichier `.pmomusic.yml` dans git !
- Il est déjà dans `.gitignore`
- Utilisez des variables d'environnement pour la production
## Variables d'environnement (recommandé pour production)
```bash
export QOBUZ_USERNAME="votre-email@example.com"
export QOBUZ_PASSWORD="votre-mot-de-passe"
```

View File

@@ -1,243 +0,0 @@
# Rapport d'Analyse UPnP - PMO Music vs Serveurs Fonctionnels
**Date:** 2025-11-26
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
## Résumé Exécutif
Le serveur PMO Music MediaServer est correctement découvert via SSDP et répond aux requêtes SOAP, mais présente plusieurs différences avec les serveurs qui fonctionnent (comme Upmpdcli). Les problèmes identifiés sont principalement liés aux en-têtes HTTP et aux métadonnées du device.
## Découverte Réseau
### Devices UPnP Détectés
| Device | IP | USN | Status |
|--------|------|-----|---------|
| PMO Music MediaServer | 192.168.0.138:8080 | uuid:8b8e9b19-9c65-4d59-b127-b34717658085 | ✅ Découvert |
| Upmpdcli (pizzicato) | 192.168.0.200:49152 | uuid:c110358f-d885-b44a-d6d3-dca6329ead0d | ✅ Découvert |
| Freebox | 192.168.0.254:52424 | uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | ✅ Découvert |
| Jellyfin | 192.168.0.34:8096 | uuid:526dedec-fde2-4224-bac6-06f7b11711cf | ✅ Découvert |
**Conclusion SSDP:** ✅ PMO Music est correctement annoncé et découvert via SSDP
## Comparaison des Descripteurs XML
### PMO Music MediaServer
```xml
<?xml version="1.0" encoding="UTF-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>0</minor> <!-- ⚠️ Version 1.0 -->
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<friendlyName>PMOMusic Media Server</friendlyName>
<manufacturer>PMOMusic</manufacturer>
<modelName>PMOMusic Media Server</modelName>
<UDN>uuid:8b8e9b19-9c65-4d59-b127-b34717658085</UDN> <!-- ✅ Format correct -->
<!-- ❌ Pas d'iconList -->
<serviceList>
<service>
<serviceType>urn:schemas-upnp-org:service:ContentDirectory:1</serviceType>
<serviceId>urn:upnp-org:serviceId:ContentDirectory</serviceId>
<SCPDURL>/device/.../service/ContentDirectory/desc.xml</SCPDURL>
<controlURL>/device/.../service/ContentDirectory/control</controlURL>
<eventSubURL>/device/.../service/ContentDirectory/event</eventSubURL>
</service>
<service>
<serviceType>urn:schemas-upnp-org:service:ConnectionManager:1</serviceType>
...
</service>
</serviceList>
</device>
</root>
```
### Upmpdcli (Fonctionnel)
```xml
<?xml version="1.0" encoding="utf-8"?>
<root xmlns="urn:schemas-upnp-org:device-1-0">
<specVersion>
<major>1</major>
<minor>1</minor> <!-- ✅ Version 1.1 -->
</specVersion>
<device>
<deviceType>urn:schemas-upnp-org:device:MediaServer:1</deviceType>
<manufacturer>lesbonscomptes.com/upmpdcli</manufacturer>
<modelName>Upmpdcli Media Server</modelName>
<friendlyName>pizzicato-Music-mediaserver</friendlyName>
<iconList> <!-- ✅ Présence d'icônes -->
<icon>
<mimetype>image/png</mimetype>
<width>64</width>
<height>64</height>
<depth>32</depth>
<url>/uuid-.../icon.png</url>
</icon>
</iconList>
<UDN>uuid:c110358f-d885-b44a-d6d3-dca6329ead0d</UDN>
<serviceList>
<!-- Mêmes services -->
</serviceList>
</device>
</root>
```
### Différences Clés dans le Descripteur
| Élément | PMO Music | Upmpdcli | Impact |
|---------|-----------|----------|---------|
| **specVersion minor** | 0 | 1 | ⚠️ Moyen - Certains clients peuvent filtrer par version |
| **Ordre des éléments** | deviceType, friendlyName, manufacturer, modelName, UDN | deviceType, manufacturer, modelName, friendlyName, iconList, UDN | ⚠️ Faible - Ordre différent mais valide XML |
| **iconList** | ❌ Absent | ✅ Présent | ⚠️ Moyen - Requis pour certains clients |
| **UDN prefix** | ✅ uuid: | ✅ uuid: | ✅ Correct |
## Comparaison des Réponses SOAP
### Test 1: ConnectionManager::GetProtocolInfo
#### PMO Music
```http
Status: 200 OK
Content-Type: (absent) PROBLÈME CRITIQUE
<?xml version="1.0" encoding="UTF-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
<Source></Source> Vide
<Sink></Sink> Vide
</u:GetProtocolInfoResponse>
</s:Body>
</s:Envelope>
```
#### Upmpdcli
```http
Status: 200 OK
Content-Type: text/xml; charset="utf-8" Présent
<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:GetProtocolInfoResponse xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1">
<Source></Source>
<Sink>http-get:*:audio/flac:*,http-get:*:audio/mp3:*,...</Sink> Formats listés
</u:GetProtocolInfoResponse>
</s:Body>
</s:Envelope>
```
### Test 2: ContentDirectory::Browse
Les deux serveurs répondent correctement, mais PMO Music manque toujours le header `Content-Type`.
## Problèmes Identifiés par Ordre de Criticité
### 🔴 CRITIQUE
1. **Absence du header Content-Type dans les réponses SOAP**
- **Impact:** Les clients UPnP stricts (comme BubbleUPnP) peuvent rejeter les réponses sans Content-Type
- **Spec UPnP:** La spécification UPnP Device Architecture 1.0 exige `Content-Type: text/xml; charset="utf-8"`
- **Localisation probable:** Dans le code de réponse SOAP du serveur UPnP
- **Fichiers à vérifier:**
- `pmoupnp/src/services/service_instance.rs` (handler SOAP)
- `pmoupnp/src/soap/builder.rs`
2. **ProtocolInfo vide pour Source et Sink**
- **Impact:** Les clients ne savent pas quels formats audio sont supportés
- **Spec UPnP:** ConnectionManager doit annoncer les formats supportés
- **Action:** Implémenter la liste des formats dans ConnectionManager
### 🟡 MOYEN
3. **specVersion 1.0 au lieu de 1.1**
- **Impact:** Certains clients modernes peuvent filtrer les devices UPnP 1.0
- **Solution:** Passer à specVersion 1.1
4. **Absence d'iconList**
- **Impact:** Pas d'icône visible dans les clients UPnP
- **Solution:** Ajouter au moins une icône PNG 64x64
### 🟢 FAIBLE
5. **Ordre des éléments XML différent**
- **Impact:** Minimal - XML valide dans tous les cas
- **Action:** Optionnel - standardiser l'ordre
## Recommandations d'Implémentation
### Priorité 1: Corriger le Content-Type
Localiser le code qui génère les réponses SOAP et ajouter le header:
```rust
// Dans pmoupnp/src/services/service_instance.rs ou similaire
(
StatusCode::OK,
[(header::CONTENT_TYPE, "text/xml; charset=\"utf-8\"")], // ← AJOUTER
xml
)
```
### Priorité 2: Implémenter GetProtocolInfo correctement
Dans ConnectionManager, retourner la liste des formats supportés:
```rust
// Exemple de formats à supporter
let sink_protocols = vec![
"http-get:*:audio/flac:*",
"http-get:*:audio/mpeg:*",
"http-get:*:audio/mp4:*",
"http-get:*:audio/ogg:*",
// ...
];
```
### Priorité 3: Passer à UPnP 1.1
Changer la specVersion de 1.0 à 1.1 dans le device descriptor.
### Priorité 4: Ajouter une icône
Créer une icône PNG 64x64 et l'ajouter au descripteur:
```xml
<iconList>
<icon>
<mimetype>image/png</mimetype>
<width>64</width>
<height>64</height>
<depth>32</depth>
<url>/icon.png</url>
</icon>
</iconList>
```
## Fichiers à Modifier
1. **pmoupnp/src/services/service_instance.rs** - Ajouter Content-Type aux réponses SOAP
2. **pmoupnp/src/devices/device_methods.rs** - Ajouter iconList au descripteur
3. **pmoupnp/src/devices/device.rs** - Passer specVersion à 1.1
4. **pmomediaserver/src/connectionmanager/actions/getprotocolinfo.rs** - Implémenter la liste des formats
## Tests de Validation
Après les corrections, vérifier:
1.`curl` sur le descripteur montre specVersion 1.1 et iconList
2. ✅ Requête SOAP GetProtocolInfo retourne `Content-Type: text/xml`
3. ✅ GetProtocolInfo retourne les formats supportés dans Sink
4. ✅ BubbleUPnP détecte et affiche le serveur PMO Music
## Conclusion
Le serveur PMO Music est **fonctionnellement correct** au niveau de SSDP et des services SOAP, mais présente des problèmes de conformité aux standards UPnP qui peuvent causer des rejets par certains clients stricts comme BubbleUPnP.
Les corrections sont simples et localisées. La priorité absolue est d'ajouter le header `Content-Type` aux réponses SOAP.

View File

@@ -1,128 +0,0 @@
# Résolution du Problème UPnP - PMO Music MediaServer
**Date:** 2025-11-26
**Problème:** Le serveur UPnP de PMO Music n'est pas reconnu par BubbleUPnP
## Diagnostic
Après une analyse approfondie avec des outils de découverte UPnP et de tests SOAP, le problème identifié était :
**🔴 PROBLÈME CRITIQUE : `SourceProtocolInfo` vide**
Le service `ConnectionManager` du MediaServer retournait des valeurs vides pour `SourceProtocolInfo`, ce qui empêchait les clients UPnP (comme BubbleUPnP) de savoir quels formats audio le serveur pouvait fournir.
### Réponse AVANT la correction :
```xml
<u:GetProtocolInfoResponse>
<Source></Source> <!-- ❌ VIDE -->
<Sink></Sink>
</u:GetProtocolInfoResponse>
```
## Solution Implémentée
### 1. Nouveau Module : `device_ext.rs`
Création d'un trait d'extension `MediaServerDeviceExt` pour `Arc<DeviceInstance>` qui initialise automatiquement les `ProtocolInfo`.
**Fichier:** [`pmomediaserver/src/device_ext.rs`](pmomediaserver/src/device_ext.rs)
```rust
pub trait MediaServerDeviceExt {
/// Initialise les ProtocolInfo du ConnectionManager pour PMO Music.
///
/// PMO Music convertit tous les flux audio en FLAC (et OGG-FLAC).
fn init_protocol_info(&self);
}
```
### 2. Formats Supportés
PMO Music convertit tout au vol en FLAC, donc `SourceProtocolInfo` annonce :
- `http-get:*:audio/flac:*` - FLAC standard
- `http-get:*:audio/x-flac:*` - FLAC (format alternatif)
- `http-get:*:application/flac:*` - FLAC (MIME type alternatif)
- `http-get:*:application/x-flac:*` - FLAC (MIME type alternatif)
- `http-get:*:application/ogg:*` - OGG-FLAC
- `http-get:*:audio/ogg:*` - OGG-FLAC
- `http-get:*:audio/x-ogg:*` - OGG-FLAC (format alternatif)
### 3. Intégration dans `main.rs`
**Fichier:** [`PMOMusic/src/main.rs`](PMOMusic/src/main.rs)
```rust
use pmomediaserver::MediaServerDeviceExt;
let server_instance = server
.write()
.await
.register_device(MEDIA_SERVER.clone())
.await
.expect("Failed to register MediaServer");
// ✅ Initialiser les ProtocolInfo du MediaServer
server_instance.init_protocol_info();
```
### 4. Export dans `lib.rs`
**Fichier:** [`pmomediaserver/src/lib.rs`](pmomediaserver/src/lib.rs)
```rust
pub mod device_ext;
pub use device_ext::MediaServerDeviceExt;
```
## Réponse APRÈS la correction
```xml
<u:GetProtocolInfoResponse>
<Source>http-get:*:audio/flac:*,http-get:*:audio/x-flac:*,http-get:*:application/flac:*,http-get:*:application/x-flac:*,http-get:*:application/ogg:*,http-get:*:audio/ogg:*,http-get:*:audio/x-ogg:*</Source> <!-- ✅ INITIALISÉ -->
<Sink></Sink> <!-- ✅ Vide pour un MediaServer (normal) -->
</u:GetProtocolInfoResponse>
```
## Fichiers Modifiés
1.**Nouveau:** `pmomediaserver/src/device_ext.rs` - Trait d'extension pour initialiser ProtocolInfo
2.**Modifié:** `pmomediaserver/src/lib.rs` - Export du trait
3.**Modifié:** `PMOMusic/src/main.rs` - Appel à `init_protocol_info()`
## Test de Validation
Après redémarrage du serveur PMO Music, vérifier avec :
```bash
python3 tools/test_soap.py
```
Ou directement :
```bash
curl -X POST \
-H "Content-Type: text/xml" \
-H "SOAPAction: \"urn:schemas-upnp-org:service:ConnectionManager:1#GetProtocolInfo\"" \
-d '<?xml version="1.0"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
<s:Body>
<u:GetProtocolInfo xmlns:u="urn:schemas-upnp-org:service:ConnectionManager:1"/>
</s:Body>
</s:Envelope>' \
http://localhost:8080/device/.../service/ConnectionManager/control
```
## Prochaines Étapes
1. ✅ Redémarrer le serveur PMO Music
2. ⏳ Tester avec BubbleUPnP pour confirmer que le serveur est maintenant reconnu
3. ⏳ (Optionnel) Ajouter une icône pour le MediaServer (amélioration UX)
4. ⏳ (Optionnel) Passer à specVersion 1.1 (amélioration de compatibilité)
## Références
- Rapport d'analyse complet : [`UPNP_ANALYSIS_REPORT.md`](UPNP_ANALYSIS_REPORT.md)
- UPnP AV Architecture Specification :
https://openconnectivity.org/developer/specifications/upnp-resources/upnp/

View File

@@ -1,31 +0,0 @@
#!/bin/bash
# Affiche toutes les stats d'un fichier FLAC
if [ $# -eq 0 ]; then
echo "Usage: $0 <fichier.flac>"
exit 1
fi
FILE="$1"
if [ ! -f "$FILE" ]; then
echo "Error: File not found: $FILE"
exit 1
fi
echo "=== Analyzing: $(basename "$FILE") ==="
echo ""
echo "--- SoX Statistics ---"
sox "$FILE" -n stat 2>&1
echo ""
echo "--- File Info ---"
file "$FILE"
echo ""
echo "--- FLAC Metadata ---"
metaflac --list "$FILE" 2>/dev/null || echo "metaflac not installed"
echo ""
echo "--- Audio Integrity Check ---"
flac -t "$FILE" 2>&1 || echo "flac not installed"

View File

@@ -1,38 +0,0 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>$title$</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs";
mermaid.initialize({startOnLoad: true, theme: "default"});
</script>
<style>
.markdown-body {
box-sizing: border-box;
min-width: 200px;
max-width: 980px;
margin: 0 auto;
padding: 45px;
}
.back-link {
margin-bottom: 20px;
display: block;
}
pre.mermaid {
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
}
</style>
</head>
<body>
<article class="markdown-body">
<p class="back-link"><a href="index.html">← Retour à l'index</a></p>
$body$
</article>
</body>
</html>

Binary file not shown.

View File

@@ -1,90 +0,0 @@
#!/bin/bash
# Script pour vérifier la qualité audio (détection de clics via le ratio delta)
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
THRESHOLD=10.0 # Ratio Maximum delta / Mean delta acceptable
if [ ! -d "$CACHE_DIR" ]; then
echo "Error: Cache directory not found: $CACHE_DIR"
exit 1
fi
echo "=== Audio Quality Check ==="
echo "Scanning: $CACHE_DIR"
echo "Threshold: Maximum/Mean delta ratio < $THRESHOLD"
echo ""
# Vérifier que sox est installé
if ! command -v sox &> /dev/null; then
echo "Error: sox is not installed. Install it with: sudo apt install sox"
exit 1
fi
count=0
suspicious=0
good=0
for file in "$CACHE_DIR"/*.orig.flac; do
if [ ! -f "$file" ]; then
echo "No FLAC files found in $CACHE_DIR"
exit 0
fi
filename=$(basename "$file")
# Obtenir les stats delta
stats=$(sox "$file" -n stat 2>&1)
max_delta=$(echo "$stats" | grep "Maximum delta" | awk '{print $3}')
mean_delta=$(echo "$stats" | grep "Mean delta" | awk '{print $3}')
if [ -z "$max_delta" ] || [ -z "$mean_delta" ]; then
echo "$filename - Cannot parse stats"
suspicious=$((suspicious + 1))
count=$((count + 1))
continue
fi
# Éviter division par zéro
if (( $(echo "$mean_delta == 0" | bc -l) )); then
echo "$filename - Invalid mean delta (0)"
suspicious=$((suspicious + 1))
count=$((count + 1))
continue
fi
# Calculer le ratio
ratio=$(echo "scale=2; $max_delta / $mean_delta" | bc -l)
# Comparer au seuil
is_bad=$(echo "$ratio > $THRESHOLD" | bc -l)
if [ "$is_bad" = "1" ]; then
echo "⚠️ CLICKS DETECTED: $filename"
echo " Max delta: $max_delta, Mean delta: $mean_delta, Ratio: ${ratio}x (threshold: ${THRESHOLD}x)"
suspicious=$((suspicious + 1))
else
echo "✓ OK: $filename (ratio: ${ratio}x)"
good=$((good + 1))
fi
count=$((count + 1))
done
echo ""
echo "=== Summary ==="
echo "Total files scanned: $count"
echo "✓ Good quality: $good"
echo "⚠️ Clicks detected: $suspicious"
if [ $suspicious -gt 0 ]; then
echo ""
echo "⚠️ Warning: $suspicious file(s) have clicks."
echo "These files were likely encoded with the old buffer size (8)."
echo "Delete the cache and re-download to fix: rm -rf $CACHE_DIR/*.flac"
exit 1
fi
echo ""
echo "✓ All files are good quality!"
exit 0

View File

@@ -1,76 +0,0 @@
#!/bin/bash
# Script pour détecter les clics dans les fichiers FLAC du cache
CACHE_DIR="${1:-/tmp/pmomusic_test/audio_cache}"
if [ ! -d "$CACHE_DIR" ]; then
echo "Error: Cache directory not found: $CACHE_DIR"
exit 1
fi
echo "=== FLAC Click Detection Tool ==="
echo "Scanning: $CACHE_DIR"
echo ""
# Vérifier que sox est installé
if ! command -v sox &> /dev/null; then
echo "Error: sox is not installed. Install it with: sudo apt install sox"
exit 1
fi
count=0
suspicious=0
for file in "$CACHE_DIR"/*.orig.flac; do
if [ ! -f "$file" ]; then
echo "No FLAC files found in $CACHE_DIR"
exit 0
fi
filename=$(basename "$file")
echo "Analyzing: $filename"
# Obtenir toutes les stats
stats=$(sox "$file" -n stat 2>&1)
# Extraire les valeurs importantes
pk_lev=$(echo "$stats" | grep "Pk lev dB" | awk '{print $4}')
rms_lev=$(echo "$stats" | grep "RMS lev dB" | awk '{print $4}')
crest=$(echo "$stats" | grep "Crest factor" | awk '{print $3}')
echo " Peak level: ${pk_lev:-N/A} dB"
echo " RMS level: ${rms_lev:-N/A} dB"
echo " Crest factor: ${crest:-N/A} dB"
# Analyser la variance d'amplitude (détection de clics)
# On compte le nombre de pics au-dessus d'un seuil
peaks=$(sox "$file" -n stats 2>&1 | grep "Maximum amplitude" | awk '{print $3}')
if [ -n "$peaks" ]; then
# Si le peak est proche de 1.0 (clipping), c'est suspect
is_clipping=$(echo "$peaks > 0.95" | bc -l 2>/dev/null)
if [ "$is_clipping" = "1" ]; then
echo " ⚠️ WARNING: Possible clipping detected!"
suspicious=$((suspicious + 1))
else
echo " ✓ OK"
fi
else
echo " ✓ OK"
fi
echo ""
count=$((count + 1))
done
echo "=== Summary ==="
echo "Files scanned: $count"
echo "Suspicious files: $suspicious"
if [ $suspicious -gt 0 ]; then
echo ""
echo "⚠️ Some files may have issues. Listen to them carefully."
exit 1
fi
exit 0

View File

@@ -1,135 +0,0 @@
#!/bin/bash
# Script de build Docker pour PMOMusic
# Usage: ./docker-build.sh [OPTIONS]
set -e
# Couleurs pour l'affichage
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
# Configuration par défaut
IMAGE_NAME="pmomusic"
TAG="latest"
NO_CACHE=false
PUSH=true
REGISTRY="niepce.petite-maison-orange.fr/public"
# Fonction d'aide
show_help() {
echo "Usage: $0 [OPTIONS]"
echo ""
echo "Options:"
echo " -t, --tag TAG Tag de l'image (défaut: latest)"
echo " -r, --registry URL Registry Docker (ex: ghcr.io/user)"
echo " -n, --no-cache Build sans cache"
echo " -p, --push Push l'image vers le registry"
echo " -h, --help Affiche cette aide"
echo ""
echo "Exemples:"
echo " $0 # Build local avec tag 'latest'"
echo " $0 -t v1.0.0 # Build avec tag 'v1.0.0'"
echo " $0 -t v1.0.0 -r ghcr.io/user -p # Build et push vers GHCR"
echo " $0 -n # Build sans cache"
exit 0
}
# Parsing des arguments
while [[ $# -gt 0 ]]; do
case $1 in
-t|--tag)
TAG="$2"
shift 2
;;
-r|--registry)
REGISTRY="$2"
shift 2
;;
-n|--no-cache)
NO_CACHE=true
shift
;;
-p|--push)
PUSH=true
shift
;;
-h|--help)
show_help
;;
*)
echo -e "${RED}Erreur: Option inconnue '$1'${NC}"
show_help
;;
esac
done
# Construire le nom complet de l'image
if [ -n "$REGISTRY" ]; then
FULL_IMAGE_NAME="$REGISTRY/$IMAGE_NAME:$TAG"
else
FULL_IMAGE_NAME="$IMAGE_NAME:$TAG"
fi
# Afficher la configuration
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Build Docker PMOMusic${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Image: $FULL_IMAGE_NAME"
echo "No cache: $NO_CACHE"
echo "Push: $PUSH"
echo ""
# Construire la commande Docker
DOCKER_CMD="docker build"
if [ "$NO_CACHE" = true ]; then
DOCKER_CMD="$DOCKER_CMD --no-cache"
fi
DOCKER_CMD="$DOCKER_CMD --build-arg BUILD_DATE=\"$(date -u +'%Y-%m-%dT%H:%M:%SZ')\" -t $FULL_IMAGE_NAME ."
# Exécuter le build
echo -e "${YELLOW}→ Démarrage du build...${NC}"
echo "Commande: $DOCKER_CMD"
echo ""
if eval "$DOCKER_CMD"; then
echo ""
echo -e "${GREEN}✓ Build réussi !${NC}"
# Afficher la taille de l'image
IMAGE_SIZE=$(docker images "$FULL_IMAGE_NAME" --format "{{.Size}}")
echo "Taille de l'image: $IMAGE_SIZE"
# Push si demandé
if [ "$PUSH" = true ]; then
echo ""
echo -e "${YELLOW}→ Push de l'image vers le registry...${NC}"
if docker push "$FULL_IMAGE_NAME"; then
echo -e "${GREEN}✓ Image pushée avec succès !${NC}"
else
echo -e "${RED}✗ Erreur lors du push${NC}"
exit 1
fi
fi
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}Build terminé avec succès !${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Pour lancer le conteneur:"
echo " docker run -it --rm --network host $FULL_IMAGE_NAME"
echo ""
echo "Ou avec docker-compose:"
echo " docker-compose up -d"
else
echo ""
echo -e "${RED}✗ Erreur lors du build${NC}"
exit 1
fi

View File

@@ -1,39 +0,0 @@
version: '3.8'
services:
pmomusic:
build:
context: .
dockerfile: Dockerfile
image: pmomusic:latest
container_name: pmomusic
# Port mapping (adjust to your needs)
ports:
- "8080:8080"
# Volume for persistent configuration
volumes:
- ./config:/home/pmomusic/.pmomusic
- ./cache:/home/pmomusic/cache
# Environment variables (adjust as needed)
environment:
- RUST_LOG=info
# Add other environment variables here
# Network mode for UPnP/DLNA discovery (host mode for multicast)
network_mode: host
# Restart policy
restart: unless-stopped
# Resource limits (optional)
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M

View File

@@ -1,7 +0,0 @@
#!/bin/bash
# Fix Mermaid blocks in HTML generated by Pandoc
# Pandoc wraps mermaid code in <pre class="mermaid"><code>...</code></pre>
# But Mermaid.js needs <pre class="mermaid">...</pre> without the <code> tags
sed -i '' 's|<pre class="mermaid"><code>|<pre class="mermaid">|g' "$1"
sed -i '' 's|</code></pre>|</pre>|g' "$1"

View File

@@ -1,5 +0,0 @@
HTTP/1.1 200 OK
content-type: text/xml; charset="utf-8"
content-length: 1593
date: Mon, 20 Oct 2025 17:44:48 GMT

431
media_
View File

@@ -1,431 +0,0 @@
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoserver/src/config_ext.rs:35:5
|
35 | async fn init_config_api(&mut self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
35 - async fn init_config_api(&mut self) -> Result<()>;
35 + fn init_config_api(&mut self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: `pmoserver` (lib) generated 1 warning
warning: variable does not need to be mutable
--> pmocache/src/cache.rs:604:9
|
604 | mut reader: R,
| ----^^^^^^
| |
| help: remove this `mut`
|
= note: `#[warn(unused_mut)]` (part of `#[warn(unused)]`) on by default
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:97:5
|
97 | async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
97 - async fn add_from_url(&self, url: &str, collection: Option<&str>) -> Result<String>;
97 + fn add_from_url(&self, url: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:111:5
|
111 | async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
111 - async fn add_from_file(&self, path: &str, collection: Option<&str>) -> Result<String>;
111 + fn add_from_file(&self, path: &str, collection: Option<&str>) -> impl std::future::Future<Output = Result<String>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:118:5
|
118 | async fn get(&self, pk: &str) -> Result<PathBuf>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
118 - async fn get(&self, pk: &str) -> Result<PathBuf>;
118 + fn get(&self, pk: &str) -> impl std::future::Future<Output = Result<PathBuf>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:125:5
|
125 | async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
125 - async fn get_collection(&self, collection: &str) -> Result<Vec<PathBuf>>;
125 + fn get_collection(&self, collection: &str) -> impl std::future::Future<Output = Result<Vec<PathBuf>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:128:5
|
128 | async fn purge(&self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
128 - async fn purge(&self) -> Result<()>;
128 + fn purge(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:131:5
|
131 | async fn consolidate(&self) -> Result<()>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
131 - async fn consolidate(&self) -> Result<()>;
131 + fn consolidate(&self) -> impl std::future::Future<Output = Result<()>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/cache_trait.rs:147:5
|
147 | async fn is_valid_pk(&self, pk: &str) -> bool {
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
147 ~ fn is_valid_pk(&self, pk: &str) -> impl std::future::Future<Output = bool> + Send {async {
148 | if self.get_database().get(pk, false).is_err() {
...
218 | false
219 ~ } }
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmocache/src/pmoserver_ext.rs:391:5
|
391 | async fn init_generic_cache<C: CacheConfig + 'static>(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
391 ~ fn init_generic_cache<C: CacheConfig + 'static>(
392 | &mut self,
...
395 | content_type: &'static str,
396 ~ ) -> impl std::future::Future<Output = anyhow::Result<Arc<Cache<C>>>> + Send;
|
warning: `pmocache` (lib) generated 9 warnings (run `cargo fix --lib -p pmocache` to apply 1 suggestion)
warning: unused import: `serde_json::Value`
--> pmoaudiocache/src/cache.rs:11:5
|
11 | use serde_json::Value;
| ^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: unused import: `pmometadata::TrackMetadata`
--> pmoaudiocache/src/api.rs:11:5
|
11 | use pmometadata::TrackMetadata;
| ^^^^^^^^^^^^^^^^^^^^^^^^^^
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/lib.rs:188:5
|
188 | async fn init_audio_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
188 ~ fn init_audio_cache(
189 | &mut self,
190 | cache_dir: &str,
191 | limit: usize,
192 ~ ) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/lib.rs:197:5
|
197 | async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
197 - async fn init_audio_cache_configured(&mut self) -> anyhow::Result<std::sync::Arc<Cache>>;
197 + fn init_audio_cache_configured(&mut self) -> impl std::future::Future<Output = anyhow::Result<std::sync::Arc<Cache>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:36:5
|
36 | async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
36 - async fn get_title(&self, pk: &str) -> anyhow::Result<Option<String>>;
36 + fn get_title(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:37:5
|
37 | async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
37 - async fn get_artist(&self, pk: &str) -> anyhow::Result<Option<String>>;
37 + fn get_artist(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:38:5
|
38 | async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
38 - async fn get_album(&self, pk: &str) -> anyhow::Result<Option<String>>;
38 + fn get_album(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<String>>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoaudiocache/src/metadata_ext.rs:39:5
|
39 | async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
39 - async fn get_duration_secs(&self, pk: &str) -> anyhow::Result<Option<i64>>;
39 + fn get_duration_secs(&self, pk: &str) -> impl std::future::Future<Output = anyhow::Result<Option<i64>>> + Send;
|
warning: `pmoaudiocache` (lib) generated 8 warnings (run `cargo fix --lib -p pmoaudiocache` to apply 1 suggestion)
warning: unused import: `tokio_stream::StreamExt`
--> pmoplaylist/src/sse.rs:16:5
|
16 | use tokio_stream::StreamExt;
| ^^^^^^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default
warning: methods `list_playlist_ids` and `remove_by_cache_pk` are never used
--> pmoplaylist/src/persistence/mod.rs:219:18
|
17 | impl PersistenceManager {
| ----------------------- methods in this implementation
...
219 | pub async fn list_playlist_ids(&self) -> Result<Vec<String>> {
| ^^^^^^^^^^^^^^^^^
...
240 | pub async fn remove_by_cache_pk(&self, cache_pk: &str) -> Result<()> {
| ^^^^^^^^^^^^^^^^^^
|
= note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default
warning: function `PlaylistManager` should have a snake case name
--> pmoplaylist/src/manager.rs:620:8
|
620 | pub fn PlaylistManager() -> &'static PlaylistManager {
| ^^^^^^^^^^^^^^^ help: convert the identifier to snake case: `playlist_manager`
|
= note: `#[warn(non_snake_case)]` (part of `#[warn(nonstandard_style)]`) on by default
warning: `pmoplaylist` (lib) generated 3 warnings
warning: unused variable: `base_url`
--> pmoupnp/src/upnp_server.rs:306:13
|
306 | let base_url = self.info().base_url.clone();
| ^^^^^^^^ help: if this is intentional, prefix it with an underscore: `_base_url`
|
= note: `#[warn(unused_variables)]` (part of `#[warn(unused)]`) on by default
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_api.rs:229:5
|
229 | async fn register_upnp_api(&mut self);
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
= note: `#[warn(async_fn_in_trait)]` on by default
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
229 - async fn register_upnp_api(&mut self);
229 + fn register_upnp_api(&mut self) -> impl std::future::Future<Output = ()> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:96:5
|
96 | async fn register_device(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
96 ~ fn register_device(
97 | &mut self,
98 | device: Arc<Device>,
99 ~ ) -> impl std::future::Future<Output = Result<Arc<DeviceInstance>, DeviceError>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:125:5
|
125 | async fn init_cover_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
125 ~ fn init_cover_cache(
126 | &mut self,
127 | cache_dir: &str,
128 | limit: usize,
129 ~ ) -> impl std::future::Future<Output = Result<Arc<CoverCache>, anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:144:5
|
144 | async fn init_audio_cache(
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
144 ~ fn init_audio_cache(
145 | &mut self,
146 | cache_dir: &str,
147 | limit: usize,
148 ~ ) -> impl std::future::Future<Output = Result<Arc<AudioCache>, anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:158:5
|
158 | async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
158 - async fn init_caches(&mut self) -> Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>;
158 + fn init_caches(&mut self) -> impl std::future::Future<Output = Result<(Arc<CoverCache>, Arc<AudioCache>), anyhow::Error>> + Send;
|
warning: use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified
--> pmoupnp/src/upnp_server.rs:225:5
|
225 | async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
| ^^^^^
|
= note: you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future`
help: you can alternatively desugar to a normal `fn` that returns `impl Future` and add any desired bounds such as `Send`, but these cannot be relaxed without a breaking API change
|
225 - async fn create_upnp_server() -> Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>;
225 + fn create_upnp_server() -> impl std::future::Future<Output = Result<Arc<tokio::sync::RwLock<Server>>, anyhow::Error>> + Send;
|
warning: `pmoupnp` (lib) generated 7 warnings
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.16s
Running `target/debug/examples/media_server_events_demo`
ControlPoint started; waiting 5s for discovery...
Discovered media servers:
- BubbleUPnP Media Server (SM-A536B) | model=BubbleUPnP Media Server | udn=uuid:d38a2dc7-13c1-4a39-ab36-513490cf6766 | location=http://192.168.0.98:58645/dev/d38a2dc7-13c1-4a39-ab36-513490cf6766/desc.xml
- fenice | model=Jellyfin Server | udn=uuid:526dedec-fde2-4224-bac6-06f7b11711cf | location=http://192.168.0.34:8096/dlna/526dedec-fde2-4224-bac6-06f7b11711cf/description.xml
- PMOMusic Media Server | model=PMOMusic Media Server | udn=uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4 | location=http://192.168.0.138:8080/device/17fe2ea6-8908-4e30-bc52-b28ea4cab3e4/desc.xml
- Freebox Server | model=Freebox Media Server | udn=uuid:e929a46e-d218-377d-2dde-32bd8080dfbf | location=http://192.168.0.254:52424/device.xml
Listening for ContentDirectory events for 90 seconds...
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=27)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:history
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=28)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=30)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=31)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=32)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=33)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=37)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=40)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=41)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=42)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=44)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=45)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=46)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:main:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=49)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=50)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=51)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=52)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=53)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=54)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=55)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=56)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=57)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=59)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=63)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=64)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=65)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=66)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=67)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=68)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Global content update (SystemUpdateID=69)
[PMOMusic Media Server (uuid:17fe2ea6-8908-4e30-bc52-b28ea4cab3e4)] Containers updated: radio-paradise:channel:rock:liveplaylist
Monitoring finished.

View File

@@ -1,80 +0,0 @@
//! Exemple de configuration multiroom avec BufferNode
//!
//! Démontre l'utilisation du buffer circulaire pour synchroniser
//! plusieurs sorties avec des délais différents
use pmoaudio::{BufferNode, SinkNode, SourceNode};
#[tokio::main]
async fn main() {
println!("=== Multiroom Demo ===\n");
// Buffer avec capacité pour gérer les délais
let (buffer, buffer_tx) = BufferNode::new(50, 10);
// Créer 3 sorties avec délais différents
let (sink1, sink1_tx) = SinkNode::new("Room 1 (no delay)".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Room 2 (5 chunks delay)".to_string(), 10);
let (sink3, sink3_tx) = SinkNode::new("Room 3 (10 chunks delay)".to_string(), 10);
buffer.add_subscriber_with_offset(sink1_tx, 0).await;
buffer.add_subscriber_with_offset(sink2_tx, 5).await;
buffer.add_subscriber_with_offset(sink3_tx, 10).await;
// Spawn buffer et sinks
tokio::spawn(async move {
buffer.run().await.unwrap();
});
let sink1_handle = tokio::spawn(async move {
let stats = sink1.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink2_handle = tokio::spawn(async move {
let stats = sink2.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink3_handle = tokio::spawn(async move {
let stats = sink3.run_with_stats().await.unwrap();
stats.display();
stats
});
// Générer de l'audio dans une tâche séparée
println!("Generating audio for multiroom playback...\n");
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(buffer_tx);
source
.generate_chunks(30, 4800, 48000, 440.0)
.await
.unwrap();
});
println!("Waiting for all rooms to finish...\n");
// Attendre toutes les sorties
let stats1 = sink1_handle.await.unwrap();
let stats2 = sink2_handle.await.unwrap();
let stats3 = sink3_handle.await.unwrap();
println!("\n=== Multiroom Summary ===");
println!(
"{}: {} chunks received",
stats1.name, stats1.chunks_received
);
println!(
"{}: {} chunks received",
stats2.name, stats2.chunks_received
);
println!(
"{}: {} chunks received",
stats3.name, stats3.chunks_received
);
println!("\nNote: Delayed rooms receive fewer chunks due to the offset");
}

View File

@@ -1,168 +0,0 @@
//! Exemple complet de pipeline multiroom avec contrôle de volume
//!
//! Ce programme démontre :
//! - Une source audio unique
//! - Deux branches de sortie : Chromecast et DiskSink
//! - Un volume master avec deux VolumeNodes secondaires synchronisés
//! - Système d'événements pour la communication entre nodes
use pmoaudio::{
ChromecastConfig, ChromecastSink, DiskSink, DiskSinkConfig, SourceNode, VolumeNode,
};
use tokio::sync::mpsc;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== PMOAudio Multiroom Volume Demo ===\n");
// Configuration
let sample_rate = 48000u32;
let chunk_size = 4800usize; // 100ms à 48kHz
let num_chunks = 50; // 5 secondes de lecture
let frequency = 440.0; // La 440 Hz
// ===== 1. Créer la source audio =====
println!("1. Creating audio source...");
let mut source = SourceNode::new();
// ===== 2. Créer le volume master =====
println!("2. Creating master volume node...");
let (mut master_volume, master_tx) = VolumeNode::new("master".to_string(), 1.0, 50);
let master_handle = master_volume.get_handle();
// Channel pour les événements du volume master
let (master_event_tx, master_event_rx_chromecast) = mpsc::channel(10);
let (_, master_event_rx_disk) = mpsc::channel(10);
master_volume.subscribe_volume_events(master_event_tx);
source.add_subscriber(master_tx);
// ===== 3. Créer les branches de sortie =====
// Branche 1: Chromecast avec volume secondaire
println!("3a. Creating Chromecast output branch...");
let (mut chromecast_volume, chromecast_volume_tx) =
VolumeNode::new("chromecast_volume".to_string(), 0.8, 50);
chromecast_volume.set_master_volume_source(master_event_rx_chromecast);
let chromecast_config = ChromecastConfig {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
..Default::default()
};
let (chromecast_sink, chromecast_sink_tx) =
ChromecastSink::new("chromecast1".to_string(), chromecast_config, 50);
chromecast_volume.add_subscriber(chromecast_sink_tx);
master_volume.add_subscriber(chromecast_volume_tx);
// Branche 2: DiskSink avec volume secondaire
println!("3b. Creating DiskSink output branch...");
let (mut disk_volume, disk_volume_tx) = VolumeNode::new("disk_volume".to_string(), 0.9, 50);
disk_volume.set_master_volume_source(master_event_rx_disk);
let disk_config = DiskSinkConfig {
output_dir: std::env::temp_dir().join("pmoaudio_demo"),
filename: Some("multiroom_output.wav".to_string()),
..Default::default()
};
let (disk_sink, disk_sink_tx) = DiskSink::new("disk1".to_string(), disk_config, 50);
disk_volume.add_subscriber(disk_sink_tx);
master_volume.add_subscriber(disk_volume_tx);
// ===== 4. Lancer tous les nodes =====
println!("4. Starting pipeline nodes...\n");
// Spawn master volume
let master_volume_handle = tokio::spawn(async move {
master_volume.run().await.unwrap();
});
// Spawn chromecast branch
let chromecast_volume_handle = tokio::spawn(async move {
chromecast_volume.run().await.unwrap();
});
let chromecast_sink_handle = tokio::spawn(async move {
let stats = chromecast_sink.run().await.unwrap();
stats.display();
});
// Spawn disk branch
let disk_volume_handle = tokio::spawn(async move {
disk_volume.run().await.unwrap();
});
let disk_sink_handle = tokio::spawn(async move {
let stats = disk_sink.run().await.unwrap();
stats.display();
});
// ===== 5. Contrôler le volume pendant la lecture =====
let master_handle_clone = master_handle.clone();
tokio::spawn(async move {
// Attendre un peu, puis diminuer le volume
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!("\n>>> Decreasing master volume to 0.7");
master_handle_clone.set_volume(0.7).await;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!(">>> Decreasing master volume to 0.4");
master_handle_clone.set_volume(0.4).await;
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
println!(">>> Increasing master volume back to 1.0");
master_handle_clone.set_volume(1.0).await;
});
// ===== 6. Générer et envoyer les chunks audio =====
println!("5. Generating and streaming audio...");
tokio::spawn(async move {
source
.generate_chunks(num_chunks, chunk_size, sample_rate, frequency)
.await
.unwrap();
println!("\n>>> Audio generation complete!");
});
// ===== 7. Attendre la fin de tous les nodes =====
println!("6. Waiting for all nodes to complete...\n");
// Attendre que les sinks terminent
chromecast_sink_handle.await?;
disk_sink_handle.await?;
// Nettoyer
master_volume_handle.abort();
chromecast_volume_handle.abort();
disk_volume_handle.abort();
println!("\n=== Demo completed successfully! ===");
println!("\nSummary:");
println!(
"- Generated {} chunks of {} samples each",
num_chunks, chunk_size
);
println!(
"- Total duration: {:.2} seconds",
(num_chunks as usize * chunk_size) as f32 / sample_rate as f32
);
println!("- Output to Chromecast: Living Room (192.168.1.100)");
println!(
"- Output to file: {}",
std::env::temp_dir()
.join("pmoaudio_demo")
.join("multiroom_output.wav")
.display()
);
println!("- Master volume control demonstrated with live changes");
println!("\nAll streams received synchronized volume updates!");
Ok(())
}

View File

@@ -1,126 +0,0 @@
//! Exemple de pipeline audio stéréo complet avec tous les nodes
//!
//! Pipeline: SourceNode → DecoderNode → DspNode → BufferNode → TimerNode → SinkNode(s)
use pmoaudio::{BufferNode, DecoderNode, DspNode, SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== PMOAudio Pipeline Demo ===\n");
// Créer le pipeline de nodes
// 2. DecoderNode - passthrough dans cet exemple
let (mut decoder, decoder_tx) = DecoderNode::new(10);
// 3. DspNode - applique un gain de 0.5
let (mut dsp, dsp_tx) = DspNode::new(10, 0.5);
// 4. BufferNode - buffer circulaire pour multiroom
let (mut buffer, buffer_tx) = BufferNode::new(100, 10);
// 5. TimerNode - calcule la position temporelle
let (mut timer, timer_tx) = TimerNode::new(10);
// 6. SinkNodes - deux destinations finales
let (sink1, sink1_tx) = SinkNode::new("Main Output".to_string(), 10);
let (sink2, sink2_tx) = SinkNode::new("Secondary Output".to_string(), 10);
// Ajouter un abonné au BufferNode avec offset (multiroom simulation)
let (sink3, sink3_tx) = SinkNode::new("Delayed Output".to_string(), 10);
buffer.add_subscriber_with_offset(sink3_tx, 5).await; // 5 chunks de retard
// Connecter le pipeline
decoder.add_subscriber(dsp_tx);
dsp.add_subscriber(buffer_tx);
buffer.add_next_subscriber(timer_tx); // BufferNode -> TimerNode
timer.add_subscriber(sink1_tx);
timer.add_subscriber(sink2_tx);
// Obtenir un handle pour lire la position du TimerNode
let timer_handle = timer.get_position_handle();
// Spawn tous les nodes
let decoder_handle = tokio::spawn(async move {
decoder.run_passthrough().await.unwrap();
});
let dsp_handle = tokio::spawn(async move {
dsp.run().await.unwrap();
});
let buffer_handle = tokio::spawn(async move {
buffer.run().await.unwrap();
});
let timer_handle_task = tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink1_handle = tokio::spawn(async move {
let stats = sink1.run_with_stats().await.unwrap();
stats.display();
stats
});
let sink2_handle = tokio::spawn(async move {
sink2.run_silent().await.unwrap();
});
let sink3_handle = tokio::spawn(async move {
let stats = sink3.run_with_stats().await.unwrap();
stats.display();
stats
});
// Spawn une tâche pour afficher la position périodiquement
let position_monitor = tokio::spawn(async move {
for _ in 0..10 {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let position = timer_handle.position_sec().await;
let samples = timer_handle.elapsed_samples().await;
println!("Position: {:.3} sec ({} samples)", position, samples);
}
});
// Générer des chunks audio
println!("Generating audio chunks...\n");
let chunk_size = 4800; // 100ms à 48kHz
let sample_rate = 48000;
let frequency = 440.0; // La 440Hz
// Source node dans une tâche séparée
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(decoder_tx);
// Générer 50 chunks (environ 5 secondes)
source
.generate_chunks(50, chunk_size, sample_rate, frequency)
.await
.unwrap();
println!("\nChunks sent. Processing...\n");
});
// Attendre que tous les nodes terminent
decoder_handle.await.unwrap();
dsp_handle.await.unwrap();
buffer_handle.await.unwrap();
timer_handle_task.await.unwrap();
let stats1 = sink1_handle.await.unwrap();
sink2_handle.await.unwrap();
let stats3 = sink3_handle.await.unwrap();
position_monitor.await.unwrap();
println!("\n=== Pipeline Demo Complete ===");
println!(
"Main output processed: {} chunks, {:.3} sec",
stats1.chunks_received, stats1.total_duration_sec
);
println!(
"Delayed output processed: {} chunks, {:.3} sec",
stats3.chunks_received, stats3.total_duration_sec
);
}

View File

@@ -1,96 +0,0 @@
//! Quick Start - Démonstration rapide des nouvelles fonctionnalités
//!
//! Cet exemple montre l'utilisation des principales nouvelles fonctionnalités :
//! - VolumeNode avec contrôle dynamique
//! - DiskSink pour écriture sur disque
//! - Pipeline simple et efficace
use pmoaudio::{AudioFileFormat, DiskSink, DiskSinkConfig, SourceNode, VolumeNode};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== PMOAudio Quick Start ===\n");
// 1. Créer la source audio (génère un signal de test)
let mut source = SourceNode::new();
// 2. Créer un VolumeNode pour contrôler le volume
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 0.8, 10);
let volume_handle = volume.get_handle();
// 3. Créer un DiskSink pour écrire sur disque
let output_dir = std::env::temp_dir().join("pmoaudio_quickstart");
let config = DiskSinkConfig {
output_dir: output_dir.clone(),
filename: Some("quickstart_output.wav".to_string()),
format: AudioFileFormat::Wav,
buffer_size: 50,
};
let (disk_sink, disk_tx) = DiskSink::new("disk".to_string(), config, 10);
// 4. Connecter le pipeline : Source → Volume → DiskSink
source.add_subscriber(volume_tx);
volume.add_subscriber(disk_tx);
println!("Pipeline configured:");
println!(" SourceNode → VolumeNode (vol=0.8) → DiskSink");
println!(" Output: {}/quickstart_output.wav\n", output_dir.display());
// 5. Lancer les nodes
let volume_handle_clone = volume_handle.clone();
tokio::spawn(async move {
volume.run().await.unwrap();
});
let disk_handle = tokio::spawn(async move {
let stats = disk_sink.run().await.unwrap();
println!("\nDiskSink Statistics:");
stats.display();
stats
});
// 6. Démonstration du contrôle de volume pendant la lecture
tokio::spawn(async move {
println!("Generating audio with volume changes...");
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
println!(" → Volume: 0.8 (initial)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(0.5).await;
println!(" → Volume: 0.5 (decreased)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(1.0).await;
println!(" → Volume: 1.0 (maximum)");
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
volume_handle_clone.set_volume(0.3).await;
println!(" → Volume: 0.3 (low)");
});
// 7. Générer l'audio (10 chunks de 4800 samples à 48kHz = ~1 seconde)
source
.generate_chunks(
10, // nombre de chunks
4800, // samples par chunk (100ms @ 48kHz)
48000, // sample rate
440.0, // fréquence (La 440 Hz)
)
.await?;
// 8. Attendre la fin du traitement
let stats = disk_handle.await?;
// 9. Résumé
println!("\n=== Summary ===");
println!("✓ Audio file generated successfully");
println!("{} chunks written", stats.chunks_written);
println!("✓ Duration: {:.2} seconds", stats.total_duration_sec);
println!("✓ Volume was dynamically adjusted during playback");
println!("\nYou can play the file with:");
println!(" ffplay {}/quickstart_output.wav", output_dir.display());
Ok(())
}

View File

@@ -1,53 +0,0 @@
//! Exemple simple de pipeline audio : Source → Timer → Sink
//!
//! Démontre l'utilisation basique du pipeline avec calcul de position
use pmoaudio::{SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== Simple Pipeline Example ===\n");
// Créer les nodes
let (mut timer, timer_tx) = TimerNode::new(10);
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
// Connecter
timer.add_subscriber(sink_tx);
// Handle pour monitorer la position
let timer_handle = timer.get_position_handle();
// Spawn timer et sink
tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink_handle = tokio::spawn(async move {
let stats = sink.run_with_stats().await.unwrap();
stats.display();
stats
});
// Générer quelques secondes d'audio dans une tâche séparée
println!("Generating 440Hz sine wave...\n");
tokio::spawn(async move {
let mut source = SourceNode::new();
source.add_subscriber(timer_tx);
source
.generate_chunks(30, 4800, 48000, 440.0) // ~3 secondes
.await
.unwrap();
// La source est drop ici, fermant le channel
});
// Attendre la fin
let stats = sink_handle.await.unwrap();
let final_position = timer_handle.position_sec().await;
println!("\nFinal position: {:.3} seconds", final_position);
println!("Total duration: {:.3} seconds", stats.total_duration_sec);
}

View File

@@ -1,52 +0,0 @@
//! Exemple de streaming audio en temps réel
//!
//! Démontre l'utilisation du pipeline avec génération de chunks
//! en temps réel avec timing approprié
use pmoaudio::{SinkNode, SourceNode, TimerNode};
#[tokio::main]
async fn main() {
println!("=== Streaming Demo ===\n");
println!("Streaming audio in real-time for 3 seconds...\n");
let mut source = SourceNode::new();
let (mut timer, timer_tx) = TimerNode::new(20);
let (sink, sink_tx) = SinkNode::new("Streaming Output".to_string(), 20);
source.add_subscriber(timer_tx);
timer.add_subscriber(sink_tx);
let timer_handle = timer.get_position_handle();
// Spawn le pipeline
tokio::spawn(async move {
timer.run().await.unwrap();
});
let sink_handle = tokio::spawn(async move {
sink.run_with_logging().await.unwrap();
});
// Monitor la position
let monitor_handle = tokio::spawn(async move {
for _ in 0..15 {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
let position = timer_handle.position_sec().await;
println!("Playback position: {:.3} sec", position);
}
});
// Stream des chunks avec timing réel
// 100ms par chunk à 48kHz = 4800 samples
source
.stream_chunks(4800, 48000, 440.0, 3000) // 3 secondes
.await
.unwrap();
println!("\nStreaming complete.");
// Attendre la fin
sink_handle.await.unwrap();
monitor_handle.await.unwrap();
}

View File

@@ -1,58 +0,0 @@
//! Exemple simple de contrôle de volume
//!
//! Démontre l'utilisation du VolumeNode avec changements dynamiques
use pmoaudio::{SinkNode, SourceNode, VolumeNode};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("=== Volume Control Demo ===\n");
// Créer la source
let mut source = SourceNode::new();
// Créer le volume node
let (mut volume, volume_tx) = VolumeNode::new("main".to_string(), 1.0, 10);
let volume_handle = volume.get_handle();
// Créer le sink
let (sink, sink_tx) = SinkNode::new("Output".to_string(), 10);
// Connecter le pipeline
source.add_subscriber(volume_tx);
volume.add_subscriber(sink_tx);
// Lancer les nodes
tokio::spawn(async move { volume.run().await.unwrap() });
let sink_handle = tokio::spawn(async move { sink.run_with_stats().await.unwrap() });
// Contrôler le volume pendant la lecture
let volume_control = tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 0.5");
volume_handle.set_volume(0.5).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 0.2");
volume_handle.set_volume(0.2).await;
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
println!("Setting volume to 1.0");
volume_handle.set_volume(1.0).await;
});
// Générer l'audio
source
.generate_chunks(20, 4800, 48000, 440.0)
.await
.unwrap();
volume_control.await?;
let stats = sink_handle.await?;
println!("\nFinal statistics:");
stats.display();
Ok(())
}

View File

@@ -1,245 +0,0 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioSegment,
};
use std::collections::VecDeque;
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// Subscriber avec son propre offset dans le buffer
struct BufferSubscriber {
tx: mpsc::Sender<Arc<AudioSegment>>,
offset: usize, // Position dans le buffer circulaire
}
/// BufferNode avec buffer circulaire pour support multiroom
///
/// Ce node maintient un buffer circulaire de chunks et permet à plusieurs
/// abonnés de lire avec des offsets différents, ce qui est idéal pour des
/// configurations multiroom où différentes pièces peuvent avoir un léger
/// délai de synchronisation.
///
/// # Fonctionnement
///
/// - Le buffer est implémenté avec un `VecDeque` de taille fixe
/// - Chaque abonné peut avoir un offset indépendant (en nombre de chunks)
/// - Utilise `try_send` pour éviter de bloquer si un abonné est saturé
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{BufferNode, SinkNode};
///
/// #[tokio::main]
/// async fn main() {
/// let (buffer, buffer_tx) = BufferNode::new(50, 10);
///
/// let (sink1, sink1_tx) = SinkNode::new("Room 1".to_string(), 10);
/// let (sink2, sink2_tx) = SinkNode::new("Room 2".to_string(), 10);
///
/// // Room 1 sans délai
/// buffer.add_subscriber_with_offset(sink1_tx, 0).await;
///
/// // Room 2 avec 5 chunks de retard
/// buffer.add_subscriber_with_offset(sink2_tx, 5).await;
///
/// tokio::spawn(async move { buffer.run().await.unwrap() });
/// // ... spawn sinks et source
/// }
/// ```
pub struct BufferNode {
buffer: Arc<RwLock<VecDeque<Arc<AudioSegment>>>>,
subscribers: Arc<RwLock<Vec<BufferSubscriber>>>,
buffer_size: usize,
rx: mpsc::Receiver<Arc<AudioSegment>>,
next_subscribers: MultiSubscriberNode, // Pour passer au node suivant
}
impl BufferNode {
/// Crée un nouveau BufferNode
///
/// # Arguments
/// * `buffer_size` - Taille maximale du buffer circulaire
/// * `channel_size` - Taille du channel bounded pour backpressure
pub fn new(buffer_size: usize, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioSegment>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
buffer: Arc::new(RwLock::new(VecDeque::with_capacity(buffer_size))),
subscribers: Arc::new(RwLock::new(Vec::new())),
buffer_size,
rx,
next_subscribers: MultiSubscriberNode::new(),
};
(node, tx)
}
/// Ajoute un abonné avec un offset spécifique (pour multiroom)
pub async fn add_subscriber_with_offset(
&self,
tx: mpsc::Sender<Arc<AudioSegment>>,
offset: usize,
) {
let mut subs = self.subscribers.write().await;
subs.push(BufferSubscriber { tx, offset });
}
/// Ajoute un abonné sans offset (commence au chunk courant)
pub async fn add_subscriber(&self, tx: mpsc::Sender<Arc<AudioSegment>>) {
self.add_subscriber_with_offset(tx, 0).await;
}
/// Ajoute un abonné pour le node suivant (sans buffer)
pub fn add_next_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioSegment>>) {
self.next_subscribers.add_subscriber(tx);
}
/// Démarre la boucle de traitement du BufferNode
pub async fn run(mut self) -> Result<(), AudioError> {
let mut chunk_index = 0usize;
while let Some(chunk) = self.rx.recv().await {
// Ajouter au buffer circulaire
{
let mut buffer = self.buffer.write().await;
if buffer.len() >= self.buffer_size {
buffer.pop_front();
}
buffer.push_back(chunk.clone());
}
// Envoyer aux abonnés avec offset
{
let buffer = self.buffer.read().await;
let mut subs = self.subscribers.write().await;
for sub in subs.iter_mut() {
// Calculer l'index dans le buffer en fonction de l'offset
let target_index = if chunk_index >= sub.offset {
chunk_index - sub.offset
} else {
continue; // Pas encore assez de données
};
// Vérifier si le chunk est disponible dans le buffer
let buffer_age = chunk_index - target_index;
if buffer_age < buffer.len() {
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
// try_send non-bloquant pour éviter de bloquer la source
let _ = sub.tx.try_send(chunk_to_send.clone());
}
}
}
// Push vers les nodes suivants sans buffer
self.next_subscribers.try_push(chunk).await?;
chunk_index += 1;
}
Ok(())
}
/// Version avec push synchrone au lieu de try_push
pub async fn run_blocking(mut self) -> Result<(), AudioError> {
let mut chunk_index = 0usize;
while let Some(chunk) = self.rx.recv().await {
// Ajouter au buffer circulaire
{
let mut buffer = self.buffer.write().await;
if buffer.len() >= self.buffer_size {
buffer.pop_front();
}
buffer.push_back(chunk.clone());
}
// Envoyer aux abonnés avec offset
{
let buffer = self.buffer.read().await;
let subs = self.subscribers.read().await;
for sub in subs.iter() {
let target_index = if chunk_index >= sub.offset {
chunk_index - sub.offset
} else {
continue;
};
let buffer_age = chunk_index - target_index;
if buffer_age < buffer.len() {
let chunk_to_send = &buffer[buffer.len() - buffer_age - 1];
let _ = sub.tx.send(chunk_to_send.clone()).await;
}
}
}
// Push vers les nodes suivants
for _ in 0..self.next_subscribers.subscribers.len() {
self.next_subscribers.push(chunk.clone()).await?;
}
chunk_index += 1;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_buffer_node_basic() {
let (mut node, tx) = BufferNode::new(10, 5);
let (out_tx, mut out_rx) = mpsc::channel(5);
node.add_next_subscriber(out_tx);
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer des chunks
for i in 0..3 {
let chunk = AudioSegment::AudioChunk(AudioChunk::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24));
tx.send(chunk).await.unwrap();
}
// Recevoir les chunks
for i in 0..3 {
let chunk = out_rx.recv().await.unwrap();
assert_eq!(chunk.order(), i);
}
}
#[tokio::test]
async fn test_buffer_node_with_offset() {
let (node, tx) = BufferNode::new(10, 10);
let (out_tx, mut out_rx) = mpsc::channel(10);
// Ajouter un abonné avec offset de 2 chunks
node.add_subscriber_with_offset(out_tx, 2).await;
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer 5 chunks
for i in 0..5 {
let chunk = AudioSegment::new(i, vec![[0i32; 2]; 100], 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
}
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// L'abonné devrait recevoir les chunks 0, 1, 2 (avec 2 chunks de retard)
let chunk = out_rx.try_recv().unwrap();
assert_eq!(chunk.order(), 0);
}
}

View File

@@ -1,290 +0,0 @@
//! ChromecastSink - Diffuse le flux audio vers un périphérique Chromecast
//!
//! Ce module fournit un sink qui envoie le flux audio à un Chromecast.
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
//! nécessiterait une bibliothèque comme `rust-cast` ou similaire.
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Configuration pour le ChromecastSink
#[derive(Debug, Clone)]
pub struct ChromecastConfig {
/// Nom ou adresse IP du Chromecast
pub device_address: String,
/// Nom amical du device
pub device_name: String,
/// Port de communication (défaut: 8009)
pub port: u16,
/// Taille du buffer de streaming
pub buffer_size: usize,
/// Format d'encodage pour le streaming
pub encoding: StreamEncoding,
}
impl Default for ChromecastConfig {
fn default() -> Self {
Self {
device_address: "192.168.1.100".to_string(),
device_name: "Living Room".to_string(),
port: 8009,
buffer_size: 50,
encoding: StreamEncoding::Mp3,
}
}
}
/// Formats d'encodage supportés pour le streaming
#[derive(Debug, Clone, Copy)]
pub enum StreamEncoding {
/// MP3 (compatible avec la plupart des Chromecasts)
Mp3,
/// AAC
Aac,
/// Opus
Opus,
/// PCM non compressé (haute qualité, bande passante élevée)
Pcm,
}
/// ChromecastSink - Diffuse vers un périphérique Chromecast
///
/// Ce sink encode le flux audio et le streame vers un Chromecast.
/// La connexion est établie lors de l'initialisation et maintenue pendant toute la durée.
///
/// # Implémentation actuelle
///
/// Cette version est un mock qui simule l'envoi au Chromecast.
/// Pour une vraie implémentation, il faudrait:
/// - Utiliser une bibliothèque comme `rust-cast`
/// - Établir une connexion TLS avec le device
/// - Lancer une application de récepteur sur le Chromecast
/// - Encoder l'audio dans le format approprié
/// - Streamer via HTTP ou WebSocket
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{ChromecastSink, ChromecastConfig};
///
/// #[tokio::main]
/// async fn main() {
/// let config = ChromecastConfig {
/// device_address: "192.168.1.100".to_string(),
/// device_name: "Living Room".to_string(),
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = ChromecastSink::new("chromecast1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct ChromecastSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: ChromecastConfig,
/// État de la connexion (mock)
connected: bool,
}
impl ChromecastSink {
/// Crée un nouveau ChromecastSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration du Chromecast
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: ChromecastConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
connected: false,
};
(sink, tx)
}
/// Établit la connexion avec le Chromecast (mock)
async fn connect(&mut self) -> Result<(), AudioError> {
println!(
"[{}] Connecting to Chromecast '{}' at {}:{}...",
self.node_id, self.config.device_name, self.config.device_address, self.config.port
);
// Simuler une connexion
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
self.connected = true;
println!(
"[{}] Connected to Chromecast '{}' successfully",
self.node_id, self.config.device_name
);
Ok(())
}
/// Envoie un chunk au Chromecast (mock)
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
if !self.connected {
return Err(AudioError::ProcessingError(
"Not connected to Chromecast".to_string(),
));
}
// Dans une vraie implémentation:
// 1. Appliquer le gain
// 2. Encoder dans le format approprié (MP3, AAC, etc.)
// 3. Envoyer via le protocole Chromecast
// Pour l'instant, simplement simuler un délai d'envoi
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
Ok(())
}
/// Déconnecte proprement du Chromecast (mock)
async fn disconnect(&mut self) -> Result<(), AudioError> {
if self.connected {
println!(
"[{}] Disconnecting from Chromecast '{}'...",
self.node_id, self.config.device_name
);
// Simuler la déconnexion
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
self.connected = false;
println!("[{}] Disconnected successfully", self.node_id);
}
Ok(())
}
/// Démarre la boucle de traitement du ChromecastSink
pub async fn run(mut self) -> Result<ChromecastStats, AudioError> {
// Établir la connexion
self.connect().await?;
let mut stats = ChromecastStats::new(self.node_id.clone(), self.config.device_name.clone());
// Boucle principale
while let Some(chunk) = self.rx.recv().await {
// Appliquer le gain si nécessaire
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
Arc::clone(&chunk).apply_gain()
} else {
Arc::clone(&chunk)
};
// Envoyer au Chromecast
self.send_chunk(&chunk_to_send).await?;
stats.record_chunk(&chunk_to_send);
}
// Déconnexion propre
self.disconnect().await?;
stats.finalize();
Ok(stats)
}
}
/// Statistiques du ChromecastSink
#[derive(Debug, Clone)]
pub struct ChromecastStats {
pub node_id: String,
pub device_name: String,
pub chunks_sent: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl ChromecastStats {
pub fn new(node_id: String, device_name: String) -> Self {
Self {
node_id,
device_name,
chunks_sent: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_sent += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
}
pub fn finalize(&mut self) {
// Calculs finaux si nécessaire
}
pub fn display(&self) {
println!("\n=== Chromecast Statistics: {} ===", self.node_id);
println!("Device: {}", self.device_name);
println!("Chunks sent: {}", self.chunks_sent);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("==================================\n");
}
}
#[cfg(test)]
mod tests {
use std::i32;
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_chromecast_sink_basic() {
let config = ChromecastConfig {
device_address: "127.0.0.1".to_string(),
device_name: "Test Device".to_string(),
..Default::default()
};
let (sink, tx) = ChromecastSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let stereo = vec![[i32::MAX / 2; 2]; 1000];
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_sent, 5);
assert_eq!(stats.device_name, "Test Device");
}
}

View File

@@ -1,162 +0,0 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// DecoderNode - Décode des chunks audio
///
/// Version mock qui passe simplement les chunks (ou simule un décodage simple)
pub struct DecoderNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
}
impl DecoderNode {
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
};
(node, tx)
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Mode passthrough - passe les chunks sans modification
pub async fn run_passthrough(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Mode mock décodage - simule un changement de sample rate
pub async fn run_with_resampling(mut self, target_sample_rate: u32) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
if chunk.sample_rate() == target_sample_rate {
// Pas besoin de resampling
self.subscribers.push(chunk).await?;
} else {
// Simuler un resampling (mock simple)
let ratio = target_sample_rate as f64 / chunk.sample_rate() as f64;
let new_len = (chunk.len() as f64 * ratio) as usize;
let pairs = chunk.to_pairs_f32();
let mut resampled = Vec::with_capacity(new_len);
// Resampling linéaire simple (mock)
for i in 0..new_len {
let src_pos = i as f64 / ratio;
let src_idx = src_pos as usize;
if src_idx + 1 < pairs.len() {
let frac = src_pos - src_idx as f64;
let alpha = (1.0 - frac) as f32;
let beta = frac as f32;
let left_sample = pairs[src_idx][0] * alpha + pairs[src_idx + 1][0] * beta;
let right_sample = pairs[src_idx][1] * alpha + pairs[src_idx + 1][1] * beta;
resampled.push([left_sample, right_sample]);
} else if src_idx < pairs.len() {
resampled.push(pairs[src_idx]);
}
}
let mut new_chunk = AudioChunk::from_pairs_f32(
chunk.order(),
resampled,
target_sample_rate,
chunk.bit_depth(),
);
if chunk.gain_db().abs() > f64::EPSILON {
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
}
self.subscribers.push(new_chunk).await?;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_decoder_passthrough() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_passthrough().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::from_channels_f32(
0,
vec![1.0, 2.0, 3.0],
vec![4.0, 5.0, 6.0],
48000,
BitDepth::B24,
);
tx.send(chunk.clone()).await.unwrap();
// Recevoir le chunk
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk, &received));
}
#[tokio::test]
async fn test_decoder_resampling() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_with_resampling(96000).await.unwrap();
});
// Envoyer un chunk à 48000 Hz
let chunk =
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
// Recevoir le chunk resampleé
let received = out_rx.recv().await.unwrap();
assert_eq!(received.sample_rate(), 96000);
// Le chunk devrait être environ 2x plus grand
assert!(received.len() > 150 && received.len() < 250);
}
#[tokio::test]
async fn test_decoder_no_resampling_needed() {
let (mut node, tx) = DecoderNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run_with_resampling(48000).await.unwrap();
});
// Envoyer un chunk déjà au bon sample rate
let chunk =
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
tx.send(chunk.clone()).await.unwrap();
// Le chunk devrait être passé sans modification
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk, &received));
}
}

View File

@@ -1,500 +0,0 @@
//! DiskSink - Écrit le flux audio dans un fichier
//!
//! Ce module fournit un sink qui écrit les chunks audio sur disque,
//! avec support de la dérivation automatique du nom de fichier depuis la source.
use crate::{events::SourceNameUpdateEvent, nodes::AudioError, AudioChunk};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::fs::File;
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, RwLock};
/// Configuration pour le DiskSink
#[derive(Debug, Clone)]
pub struct DiskSinkConfig {
/// Chemin racine où écrire les fichiers
pub output_dir: PathBuf,
/// Nom de fichier explicite (optionnel)
/// Si None, sera dérivé du nom de la source
pub filename: Option<String>,
/// Format d'écriture
pub format: AudioFileFormat,
/// Taille du buffer d'écriture (en chunks)
pub buffer_size: usize,
}
impl Default for DiskSinkConfig {
fn default() -> Self {
Self {
output_dir: PathBuf::from("."),
filename: None,
format: AudioFileFormat::Wav,
buffer_size: 100,
}
}
}
/// Formats de fichiers audio supportés
#[derive(Debug, Clone, Copy)]
pub enum AudioFileFormat {
/// Format WAV (non compressé)
Wav,
/// Format FLAC (compressé sans perte)
Flac,
/// Format brut PCM
Raw,
}
impl AudioFileFormat {
/// Retourne l'extension de fichier appropriée
pub fn extension(&self) -> &str {
match self {
AudioFileFormat::Wav => "wav",
AudioFileFormat::Flac => "flac",
AudioFileFormat::Raw => "pcm",
}
}
}
/// DiskSink - Écrit le flux audio dans un fichier sur disque
///
/// Ce sink consomme les chunks audio et les écrit dans un fichier.
/// Le nom du fichier peut être dérivé automatiquement du nom de la source
/// via les événements `SourceNameUpdateEvent`.
///
/// # Caractéristiques
///
/// - Écriture asynchrone avec buffer
/// - Dérivation automatique du nom de fichier depuis la source
/// - Support de plusieurs formats (WAV, FLAC, PCM brut)
/// - Gestion du gain : applique le gain avant l'écriture
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{DiskSink, DiskSinkConfig};
/// use std::path::PathBuf;
///
/// #[tokio::main]
/// async fn main() {
/// let config = DiskSinkConfig {
/// output_dir: PathBuf::from("/tmp/audio"),
/// filename: Some("output.wav".to_string()),
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = DiskSink::new("disk1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct DiskSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: DiskSinkConfig,
/// Nom de fichier résolu (partagé)
resolved_filename: Arc<RwLock<Option<PathBuf>>>,
/// Receiver pour les événements de nom de source (optionnel)
source_name_rx: Option<mpsc::Receiver<SourceNameUpdateEvent>>,
/// Writer pour le fichier
writer: Option<AudioFileWriter>,
}
impl DiskSink {
/// Crée un nouveau DiskSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration du sink
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: DiskSinkConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
resolved_filename: Arc::new(RwLock::new(None)),
source_name_rx: None,
writer: None,
};
(sink, tx)
}
/// Configure la source des événements de nom de source
pub fn set_source_name_source(&mut self, rx: mpsc::Receiver<SourceNameUpdateEvent>) {
self.source_name_rx = Some(rx);
}
/// Résout le nom du fichier de sortie
///
/// Si un filename explicite est fourni dans la config, l'utilise.
/// Sinon, utilise le source_name avec l'extension appropriée.
fn resolve_filename(&self, source_name: Option<&str>) -> PathBuf {
let filename = if let Some(ref explicit_name) = self.config.filename {
explicit_name.clone()
} else if let Some(name) = source_name {
// Nettoyer le nom de la source pour en faire un nom de fichier valide
let clean_name = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect::<String>();
format!("{}.{}", clean_name, self.config.format.extension())
} else {
// Fallback sur un nom par défaut
format!("{}.{}", self.node_id, self.config.format.extension())
};
self.config.output_dir.join(filename)
}
/// Initialise le writer pour le fichier de sortie
async fn initialize_writer(&mut self, source_name: Option<&str>) -> Result<(), AudioError> {
let path = self.resolve_filename(source_name);
*self.resolved_filename.write().await = Some(path.clone());
// Créer le répertoire parent si nécessaire
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to create directory: {}", e))
})?;
}
// Créer le writer approprié selon le format
let writer = match self.config.format {
AudioFileFormat::Wav => AudioFileWriter::new_wav(path).await?,
AudioFileFormat::Flac => {
// FLAC nécessiterait une bibliothèque externe, pour l'instant utiliser WAV
AudioFileWriter::new_wav(path).await?
}
AudioFileFormat::Raw => AudioFileWriter::new_raw(path).await?,
};
self.writer = Some(writer);
Ok(())
}
/// Démarre la boucle de traitement du DiskSink
pub async fn run(mut self) -> Result<DiskSinkStats, AudioError> {
let mut stats = DiskSinkStats::new(self.node_id.clone());
let mut source_name: Option<String> = None;
let mut initialized = false;
loop {
tokio::select! {
// Recevoir les chunks audio
chunk_opt = self.rx.recv() => {
match chunk_opt {
Some(chunk) => {
// Initialiser le writer à la réception du premier chunk
if !initialized {
self.initialize_writer(source_name.as_deref()).await?;
initialized = true;
}
// Appliquer le gain avant l'écriture
let chunk_with_gain = if chunk.gain_db().abs() > f64::EPSILON {
Arc::clone(&chunk).apply_gain()
} else {
Arc::clone(&chunk)
};
// Écrire le chunk
if let Some(ref mut writer) = self.writer {
writer.write_chunk(&chunk_with_gain).await?;
stats.record_chunk(&chunk_with_gain);
}
}
None => {
// Channel fermé, terminer
break;
}
}
}
// Recevoir les mises à jour du nom de source
source_event_opt = async {
if let Some(ref mut rx) = self.source_name_rx {
rx.recv().await
} else {
std::future::pending().await
}
} => {
if let Some(event) = source_event_opt {
source_name = Some(event.source_name.clone());
// Si on n'a pas encore initialisé, le nom sera utilisé plus tard
// Sinon, on pourrait décider de fermer le fichier actuel et d'en créer un nouveau
}
}
}
}
// Fermer le fichier proprement
if let Some(writer) = self.writer {
writer.close().await?;
}
stats.finalize();
Ok(stats)
}
}
/// Writer pour fichiers audio
struct AudioFileWriter {
file: File,
format: AudioFileFormat,
sample_rate: Option<u32>,
total_samples: usize,
}
impl AudioFileWriter {
/// Crée un writer WAV
async fn new_wav(path: PathBuf) -> Result<Self, AudioError> {
let file = File::create(path)
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
Ok(Self {
file,
format: AudioFileFormat::Wav,
sample_rate: None,
total_samples: 0,
})
}
/// Crée un writer pour PCM brut
async fn new_raw(path: PathBuf) -> Result<Self, AudioError> {
let file = File::create(path)
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to create file: {}", e)))?;
Ok(Self {
file,
format: AudioFileFormat::Raw,
sample_rate: None,
total_samples: 0,
})
}
/// Écrit un chunk audio
async fn write_chunk(&mut self, chunk: &AudioChunk) -> Result<(), AudioError> {
// Enregistrer le sample rate du premier chunk
if self.sample_rate.is_none() {
let sr = chunk.sample_rate();
self.sample_rate = Some(sr);
// Pour WAV, écrire l'en-tête (simplifié)
if matches!(self.format, AudioFileFormat::Wav) {
self.write_wav_header(sr).await?;
}
}
// Convertir en bytes (little-endian 16-bit PCM)
let mut bytes = Vec::with_capacity(chunk.len() * 4);
let max_val = chunk.bit_depth().max_value();
for frame in chunk.frames() {
let left = (frame[0] as f32 / max_val).clamp(-1.0, 1.0);
let right = (frame[1] as f32 / max_val).clamp(-1.0, 1.0);
let sample_i16 = (left * 32767.0) as i16;
bytes.extend_from_slice(&sample_i16.to_le_bytes());
let sample_r16 = (right * 32767.0) as i16;
bytes.extend_from_slice(&sample_r16.to_le_bytes());
}
self.file.write_all(&bytes).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to write audio data: {}", e))
})?;
self.total_samples += chunk.len();
Ok(())
}
/// Écrit un en-tête WAV simplifié
async fn write_wav_header(&mut self, sample_rate: u32) -> Result<(), AudioError> {
// En-tête WAV basique (sera mis à jour à la fermeture)
let mut header = Vec::new();
// RIFF chunk
header.extend_from_slice(b"RIFF");
header.extend_from_slice(&0u32.to_le_bytes()); // Taille (à mettre à jour)
header.extend_from_slice(b"WAVE");
// fmt chunk
header.extend_from_slice(b"fmt ");
header.extend_from_slice(&16u32.to_le_bytes()); // Taille du fmt chunk
header.extend_from_slice(&1u16.to_le_bytes()); // Format PCM
header.extend_from_slice(&2u16.to_le_bytes()); // 2 canaux (stéréo)
header.extend_from_slice(&sample_rate.to_le_bytes());
header.extend_from_slice(&(sample_rate * 4).to_le_bytes()); // Byte rate
header.extend_from_slice(&4u16.to_le_bytes()); // Block align
header.extend_from_slice(&16u16.to_le_bytes()); // Bits per sample
// data chunk header
header.extend_from_slice(b"data");
header.extend_from_slice(&0u32.to_le_bytes()); // Taille des données (à mettre à jour)
self.file.write_all(&header).await.map_err(|e| {
AudioError::ProcessingError(format!("Failed to write WAV header: {}", e))
})?;
Ok(())
}
/// Ferme le fichier et met à jour l'en-tête si nécessaire
async fn close(mut self) -> Result<(), AudioError> {
if matches!(self.format, AudioFileFormat::Wav) {
// Mettre à jour les tailles dans l'en-tête WAV
let data_size = (self.total_samples * 4) as u32; // 2 bytes per sample * 2 channels
let file_size = data_size + 36;
// Positionner au début et réécrire les tailles
use tokio::io::AsyncSeekExt;
self.file
.seek(std::io::SeekFrom::Start(4))
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
})?;
self.file
.write_all(&file_size.to_le_bytes())
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to update file size: {}", e))
})?;
self.file
.seek(std::io::SeekFrom::Start(40))
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to seek in file: {}", e))
})?;
self.file
.write_all(&data_size.to_le_bytes())
.await
.map_err(|e| {
AudioError::ProcessingError(format!("Failed to update data size: {}", e))
})?;
}
self.file
.flush()
.await
.map_err(|e| AudioError::ProcessingError(format!("Failed to flush file: {}", e)))?;
Ok(())
}
}
/// Statistiques du DiskSink
#[derive(Debug, Clone)]
pub struct DiskSinkStats {
pub node_id: String,
pub chunks_written: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl DiskSinkStats {
pub fn new(node_id: String) -> Self {
Self {
node_id,
chunks_written: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_written += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
}
pub fn finalize(&mut self) {
// Pourrait effectuer des calculs finaux ici
}
pub fn display(&self) {
println!("\n=== DiskSink Statistics: {} ===", self.node_id);
println!("Chunks written: {}", self.chunks_written);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("============================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_disk_sink_basic() {
let temp_dir = std::env::temp_dir().join("pmoaudio_test");
tokio::fs::create_dir_all(&temp_dir).await.unwrap();
let config = DiskSinkConfig {
output_dir: temp_dir.clone(),
filename: Some("test_output.wav".to_string()),
format: AudioFileFormat::Wav,
buffer_size: 10,
};
let (sink, tx) = DiskSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let chunk = AudioChunk::from_channels_f32(
i,
vec![0.5; 1000],
vec![0.5; 1000],
48000,
BitDepth::B24,
);
tx.send(chunk).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_written, 5);
// Vérifier que le fichier existe
let output_path = temp_dir.join("test_output.wav");
assert!(output_path.exists());
// Nettoyage
tokio::fs::remove_file(output_path).await.ok();
tokio::fs::remove_dir(temp_dir).await.ok();
}
}

View File

@@ -1,254 +0,0 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// DspNode - Applique des transformations DSP aux chunks audio
///
/// Clone les données uniquement si elles doivent être modifiées
pub struct DspNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
gain_db: f32,
}
impl DspNode {
pub fn new(channel_size: usize, gain_db: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
gain_db,
};
(node, tx)
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Applique le gain aux chunks
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
if self.gain_db.abs() < f32::EPSILON {
// Gain = 0 dB, pas de transformation nécessaire
self.subscribers.push(chunk).await?;
continue;
}
let gain_linear = AudioChunk::gain_linear_from_db(self.gain_db as f64) as f32;
let mut pairs = chunk.to_pairs_f32();
for frame in &mut pairs {
frame[0] *= gain_linear;
frame[1] *= gain_linear;
}
let mut new_chunk = AudioChunk::from_pairs_f32(
chunk.order(),
pairs,
chunk.sample_rate(),
chunk.bit_depth(),
);
if chunk.gain_db().abs() > f64::EPSILON {
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
}
self.subscribers.push(new_chunk).await?;
}
Ok(())
}
/// Met à jour le gain dynamiquement (nécessite un `Arc<RwLock<f32>>` dans une version réelle)
pub fn set_gain_db(&mut self, gain_db: f32) {
self.gain_db = gain_db;
}
}
/// DspNode avec filtre passe-bas simple (mock)
#[allow(dead_code)]
pub struct LowPassDspNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
alpha: f32, // Coefficient du filtre
prev_left: f32,
prev_right: f32,
}
impl LowPassDspNode {
#[allow(dead_code)]
pub fn new(channel_size: usize, cutoff_ratio: f32) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
// Filtre RC simple: alpha = dt / (RC + dt)
// cutoff_ratio entre 0 (tout couper) et 1 (tout passer)
let alpha = cutoff_ratio.clamp(0.0, 1.0);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
alpha,
prev_left: 0.0,
prev_right: 0.0,
};
(node, tx)
}
#[allow(dead_code)]
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
#[allow(dead_code)]
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
let pairs = chunk.to_pairs_f32();
let mut filtered = Vec::with_capacity(pairs.len());
for sample in pairs.iter() {
self.prev_left = self.prev_left + self.alpha * (sample[0] - self.prev_left);
self.prev_right = self.prev_right + self.alpha * (sample[1] - self.prev_right);
filtered.push([self.prev_left, self.prev_right]);
}
let mut new_chunk = AudioChunk::from_pairs_f32(
chunk.order(),
filtered,
chunk.sample_rate(),
chunk.bit_depth(),
);
if chunk.gain_db().abs() > f64::EPSILON {
new_chunk = new_chunk.set_gain_db(chunk.gain_db());
}
self.subscribers.push(new_chunk).await?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_dsp_node_unity_gain() {
let (mut node, tx) = DspNode::new(10, 0.0);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::from_channels_f32(
0,
vec![0.25, 0.5, 0.75],
vec![0.1, 0.2, 0.3],
48000,
BitDepth::B24,
);
tx.send(chunk.clone()).await.unwrap();
// Avec gain = 1.0, le chunk ne devrait pas être cloné
let received = out_rx.recv().await.unwrap();
assert!(Arc::ptr_eq(&chunk, &received));
}
#[tokio::test]
async fn test_dsp_node_gain() {
let gain_db = AudioChunk::gain_db_from_linear(2.0) as f32;
let (mut node, tx) = DspNode::new(10, gain_db);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::from_channels_f32(
0,
vec![0.25, 0.5, 0.75],
vec![0.1, 0.2, 0.3],
48000,
BitDepth::B24,
);
tx.send(chunk).await.unwrap();
// Vérifier que le gain a été appliqué
let received = out_rx.recv().await.unwrap();
let frames = received.to_pairs_f32();
const EPS: f32 = 1e-3;
assert!((frames[0][0] - 0.5).abs() < EPS);
assert!((frames[1][0] - 1.0).abs() < EPS);
assert!((frames[2][0] - 1.0).abs() < EPS); // Clamp at full scale
assert!((frames[0][1] - 0.2).abs() < EPS);
assert!((frames[1][1] - 0.4).abs() < EPS);
assert!((frames[2][1] - 0.6).abs() < EPS);
}
#[tokio::test]
async fn test_lowpass_dsp_node() {
let (mut node, tx) = LowPassDspNode::new(10, 0.5);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk avec un signal carré
let chunk = AudioChunk::from_channels_f32(
0,
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
vec![1.0, 1.0, 1.0, -1.0, -1.0, -1.0],
48000,
BitDepth::B24,
);
tx.send(chunk).await.unwrap();
// Le filtre devrait lisser le signal
let received = out_rx.recv().await.unwrap();
let frames = received.to_pairs_f32();
assert!(frames[0][0].abs() < 1.0); // Premier échantillon lissé
assert!(frames[2][0].abs() < 1.0); // Signal ne devrait pas atteindre 1.0 immédiatement
}
#[tokio::test]
async fn test_dsp_node_multiple_subscribers() {
let gain_db = AudioChunk::gain_db_from_linear(0.5) as f32;
let (mut node, tx) = DspNode::new(10, gain_db);
let (out_tx1, mut out_rx1) = mpsc::channel(10);
let (out_tx2, mut out_rx2) = mpsc::channel(10);
node.add_subscriber(out_tx1);
node.add_subscriber(out_tx2);
tokio::spawn(async move {
node.run().await.unwrap();
});
let chunk =
AudioChunk::from_channels_f32(0, vec![0.8, 0.4], vec![0.8, 0.4], 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
// Les deux abonnés devraient recevoir le même Arc
let received1 = out_rx1.recv().await.unwrap();
let received2 = out_rx2.recv().await.unwrap();
assert!(Arc::ptr_eq(&received1, &received2));
let frames = received1.to_pairs_f32();
const EPS: f32 = 1e-3;
assert!((frames[0][0] - 0.4).abs() < EPS); // 0.8 * 0.5
assert!((frames[1][0] - 0.2).abs() < EPS); // 0.4 * 0.5
}
}

View File

@@ -1,398 +0,0 @@
//! MpdSink - Envoie le flux audio à un démon MPD (Music Player Daemon)
//!
//! Ce module fournit un sink qui streame l'audio vers un démon MPD distant ou local.
//! Note: Cette implémentation est une version mock/skeleton. Une vraie implémentation
//! nécessiterait le protocole MPD complet et l'utilisation de bibliothèques comme `mpd`.
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// Configuration pour le MpdSink
#[derive(Debug, Clone)]
pub struct MpdConfig {
/// Adresse du serveur MPD
pub host: String,
/// Port du serveur MPD (défaut: 6600)
pub port: u16,
/// Mot de passe optionnel
pub password: Option<String>,
/// Nom de l'output MPD à utiliser (optionnel)
pub output_name: Option<String>,
/// Taille du buffer
pub buffer_size: usize,
/// Format d'envoi
pub format: MpdAudioFormat,
}
impl Default for MpdConfig {
fn default() -> Self {
Self {
host: "localhost".to_string(),
port: 6600,
password: None,
output_name: None,
buffer_size: 50,
format: MpdAudioFormat::S16Le,
}
}
}
/// Formats audio supportés par MPD
#[derive(Debug, Clone, Copy)]
pub enum MpdAudioFormat {
/// Signed 16-bit Little Endian
S16Le,
/// Signed 24-bit Little Endian
S24Le,
/// Signed 32-bit Little Endian
S32Le,
/// Float 32-bit
F32,
}
impl MpdAudioFormat {
/// Retourne le nom du format pour le protocole MPD
pub fn as_mpd_string(&self) -> &str {
match self {
MpdAudioFormat::S16Le => "16:16:2",
MpdAudioFormat::S24Le => "24:24:2",
MpdAudioFormat::S32Le => "32:32:2",
MpdAudioFormat::F32 => "f:32:2",
}
}
}
/// MpdSink - Streame vers un démon MPD
///
/// Ce sink se connecte à un serveur MPD et lui envoie le flux audio.
/// MPD peut ensuite router l'audio vers différents outputs (ALSA, PulseAudio, HTTP, etc.).
///
/// # Implémentation actuelle
///
/// Cette version est un mock qui simule la communication avec MPD.
/// Pour une vraie implémentation, il faudrait:
/// - Implémenter le protocole MPD (commandes textuelles sur TCP)
/// - S'authentifier si nécessaire
/// - Configurer le format audio
/// - Envoyer les données PCM via le protocole approprié
/// - Gérer les commandes de contrôle (play, pause, stop)
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::{MpdSink, MpdConfig};
///
/// #[tokio::main]
/// async fn main() {
/// let config = MpdConfig {
/// host: "localhost".to_string(),
/// port: 6600,
/// password: None,
/// ..Default::default()
/// };
///
/// let (sink, sink_tx) = MpdSink::new("mpd1".to_string(), config, 10);
///
/// tokio::spawn(async move {
/// sink.run().await.unwrap()
/// });
/// }
/// ```
pub struct MpdSink {
/// Identifiant du sink
node_id: String,
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Configuration
config: MpdConfig,
/// État de la connexion (mock)
connected: bool,
/// Version du serveur MPD (mock)
mpd_version: Option<String>,
}
impl MpdSink {
/// Crée un nouveau MpdSink
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du sink
/// * `config` - Configuration MPD
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
config: MpdConfig,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let sink = Self {
node_id,
rx,
config,
connected: false,
mpd_version: None,
};
(sink, tx)
}
/// Établit la connexion avec le serveur MPD (mock)
async fn connect(&mut self) -> Result<(), AudioError> {
println!(
"[{}] Connecting to MPD at {}:{}...",
self.node_id, self.config.host, self.config.port
);
// Simuler une connexion TCP
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
// Dans une vraie implémentation:
// 1. Établir connexion TCP
// 2. Lire la bannière de version
// 3. S'authentifier si password fourni
// 4. Configurer le format audio
self.mpd_version = Some("0.23.0".to_string());
self.connected = true;
println!(
"[{}] Connected to MPD v{} successfully",
self.node_id,
self.mpd_version.as_ref().unwrap()
);
// Configurer le format audio
self.configure_audio_format().await?;
Ok(())
}
/// Configure le format audio sur MPD (mock)
async fn configure_audio_format(&self) -> Result<(), AudioError> {
println!(
"[{}] Configuring audio format: {}",
self.node_id,
self.config.format.as_mpd_string()
);
// Dans une vraie implémentation:
// Envoyer une commande MPD pour configurer le format
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
Ok(())
}
/// Envoie un chunk au serveur MPD (mock)
async fn send_chunk(&self, _chunk: &AudioChunk) -> Result<(), AudioError> {
if !self.connected {
return Err(AudioError::ProcessingError(
"Not connected to MPD".to_string(),
));
}
// Dans une vraie implémentation:
// 1. Appliquer le gain
// 2. Convertir dans le format approprié (S16LE, etc.)
// 3. Envoyer via le protocole MPD (probablement via une commande `sendmessage` ou pipe)
// Simuler un délai d'envoi
tokio::time::sleep(tokio::time::Duration::from_micros(50)).await;
Ok(())
}
/// Déconnecte proprement du serveur MPD (mock)
async fn disconnect(&mut self) -> Result<(), AudioError> {
if self.connected {
println!("[{}] Disconnecting from MPD...", self.node_id);
// Dans une vraie implémentation:
// Envoyer la commande "close"
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
self.connected = false;
println!("[{}] Disconnected successfully", self.node_id);
}
Ok(())
}
/// Démarre la boucle de traitement du MpdSink
pub async fn run(mut self) -> Result<MpdStats, AudioError> {
// Établir la connexion
self.connect().await?;
let mut stats = MpdStats::new(
self.node_id.clone(),
format!("{}:{}", self.config.host, self.config.port),
);
// Boucle principale
while let Some(chunk) = self.rx.recv().await {
// Appliquer le gain si nécessaire
let chunk_to_send = if chunk.gain_db().abs() > f64::EPSILON {
Arc::clone(&chunk).apply_gain()
} else {
Arc::clone(&chunk)
};
// Envoyer au serveur MPD
self.send_chunk(&chunk_to_send).await?;
stats.record_chunk(&chunk_to_send);
}
// Déconnexion propre
self.disconnect().await?;
stats.finalize();
Ok(stats)
}
/// Retourne un handle pour contrôler le sink (mock)
pub fn get_handle(&self) -> MpdHandle {
MpdHandle {
node_id: self.node_id.clone(),
}
}
}
/// Handle pour contrôler le MpdSink
///
/// Permet d'envoyer des commandes de contrôle au serveur MPD
#[derive(Clone)]
pub struct MpdHandle {
node_id: String,
}
impl MpdHandle {
/// Commande play (mock)
pub async fn play(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: play", self.node_id);
Ok(())
}
/// Commande pause (mock)
pub async fn pause(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: pause", self.node_id);
Ok(())
}
/// Commande stop (mock)
pub async fn stop(&self) -> Result<(), AudioError> {
println!("[{}] MPD command: stop", self.node_id);
Ok(())
}
/// Change le volume MPD (0-100) (mock)
pub async fn set_volume(&self, volume: u8) -> Result<(), AudioError> {
let clamped = volume.min(100);
println!("[{}] MPD command: setvol {}", self.node_id, clamped);
Ok(())
}
}
/// Statistiques du MpdSink
#[derive(Debug, Clone)]
pub struct MpdStats {
pub node_id: String,
pub server_address: String,
pub chunks_sent: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
}
impl MpdStats {
pub fn new(node_id: String, server_address: String) -> Self {
Self {
node_id,
server_address,
chunks_sent: 0,
total_samples: 0,
total_duration_sec: 0.0,
}
}
pub fn record_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_sent += 1;
self.total_samples += chunk.len() as u64;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
}
pub fn finalize(&mut self) {
// Calculs finaux si nécessaire
}
pub fn display(&self) {
println!("\n=== MPD Sink Statistics: {} ===", self.node_id);
println!("Server: {}", self.server_address);
println!("Chunks sent: {}", self.chunks_sent);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("===============================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_mpd_sink_basic() {
let config = MpdConfig {
host: "localhost".to_string(),
port: 6600,
..Default::default()
};
let (sink, tx) = MpdSink::new("test".to_string(), config, 10);
let handle = tokio::spawn(async move { sink.run().await });
// Envoyer quelques chunks
for i in 0..5 {
let chunk = AudioChunk::from_channels_f32(
i,
vec![0.5; 1000],
vec![0.5; 1000],
48000,
BitDepth::B24,
);
tx.send(chunk).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_sent, 5);
assert_eq!(stats.server_address, "localhost:6600");
}
#[tokio::test]
async fn test_mpd_handle() {
let config = MpdConfig::default();
let (sink, _tx) = MpdSink::new("test".to_string(), config, 10);
let handle = sink.get_handle();
// Tester les commandes (mock)
handle.play().await.unwrap();
handle.pause().await.unwrap();
handle.set_volume(75).await.unwrap();
handle.stop().await.unwrap();
}
}

View File

@@ -1,212 +0,0 @@
use crate::{nodes::AudioError, AudioChunk};
use std::sync::Arc;
use tokio::sync::mpsc;
/// SinkNode - Node terminal qui consomme les chunks audio
///
/// Version mock pour tests et logging
pub struct SinkNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
name: String,
}
impl SinkNode {
pub fn new(name: String, channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self { rx, name };
(node, tx)
}
/// Version silencieuse - consomme les chunks sans action
pub async fn run_silent(mut self) -> Result<(), AudioError> {
while let Some(_chunk) = self.rx.recv().await {
// Ne rien faire, juste consommer
}
Ok(())
}
/// Version avec logging
pub async fn run_with_logging(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
println!(
"[{}] Received chunk #{} - {} samples @ {} Hz",
self.name,
chunk.order(),
chunk.len(),
chunk.sample_rate()
);
}
Ok(())
}
/// Version avec statistiques
pub async fn run_with_stats(mut self) -> Result<SinkStats, AudioError> {
let mut stats = SinkStats::new(self.name.clone());
while let Some(chunk) = self.rx.recv().await {
stats.process_chunk(&chunk);
}
Ok(stats)
}
/// Version mock pour écriture dans un fichier (simule l'écriture)
pub async fn run_mock_file_writer(mut self) -> Result<usize, AudioError> {
let mut total_samples = 0;
while let Some(chunk) = self.rx.recv().await {
total_samples += chunk.len();
// Simuler l'écriture avec un petit délai
tokio::time::sleep(tokio::time::Duration::from_micros(10)).await;
}
Ok(total_samples)
}
}
/// Statistiques collectées par un SinkNode
#[derive(Debug, Clone)]
pub struct SinkStats {
pub name: String,
pub chunks_received: u64,
pub total_samples: u64,
pub total_duration_sec: f64,
pub peak_left: f32,
pub peak_right: f32,
pub rms_left: f64,
pub rms_right: f64,
}
impl SinkStats {
pub fn new(name: String) -> Self {
Self {
name,
chunks_received: 0,
total_samples: 0,
total_duration_sec: 0.0,
peak_left: 0.0,
peak_right: 0.0,
rms_left: 0.0,
rms_right: 0.0,
}
}
pub fn process_chunk(&mut self, chunk: &AudioChunk) {
self.chunks_received += 1;
let len = chunk.len() as u64;
self.total_samples += len;
self.total_duration_sec += chunk.len() as f64 / chunk.sample_rate() as f64;
let inv_max = 1.0f32 / chunk.bit_depth().max_value();
let mut peak_left = self.peak_left;
let mut peak_right = self.peak_right;
let mut sum_squares_left = 0.0f64;
let mut sum_squares_right = 0.0f64;
for frame in chunk.frames() {
let left = frame[0] as f32 * inv_max;
let right = frame[1] as f32 * inv_max;
let left_abs = left.abs();
let right_abs = right.abs();
if left_abs > peak_left {
peak_left = left_abs;
}
if right_abs > peak_right {
peak_right = right_abs;
}
let l64 = left as f64;
let r64 = right as f64;
sum_squares_left += l64 * l64;
sum_squares_right += r64 * r64;
}
self.peak_left = peak_left;
self.peak_right = peak_right;
let prev_samples = self.total_samples - len;
self.rms_left = ((self.rms_left.powi(2) * prev_samples as f64 + sum_squares_left)
/ self.total_samples as f64)
.sqrt();
self.rms_right = ((self.rms_right.powi(2) * prev_samples as f64 + sum_squares_right)
/ self.total_samples as f64)
.sqrt();
}
pub fn display(&self) {
println!("\n=== Sink Statistics: {} ===", self.name);
println!("Chunks received: {}", self.chunks_received);
println!("Total samples: {}", self.total_samples);
println!("Total duration: {:.3} sec", self.total_duration_sec);
println!("Peak L/R: {:.3} / {:.3}", self.peak_left, self.peak_right);
println!("RMS L/R: {:.3} / {:.3}", self.rms_left, self.rms_right);
println!("========================\n");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
const BD: BitDepth = BitDepth::B24;
#[tokio::test]
async fn test_sink_node_silent() {
let (node, tx) = SinkNode::new("test".to_string(), 10);
let handle = tokio::spawn(async move { node.run_silent().await });
// Envoyer quelques chunks
for i in 0..3 {
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
tx.send(chunk).await.unwrap();
}
drop(tx);
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_sink_node_stats() {
let (node, tx) = SinkNode::new("test".to_string(), 10);
let handle = tokio::spawn(async move { node.run_with_stats().await });
// Envoyer des chunks avec signal connu
for i in 0..3 {
let chunk =
AudioChunk::from_channels_f32(i, vec![1.0; 1000], vec![0.5; 1000], 48000, BD);
tx.send(chunk).await.unwrap();
}
drop(tx);
let stats = handle.await.unwrap().unwrap();
assert_eq!(stats.chunks_received, 3);
assert_eq!(stats.total_samples, 3000);
assert!((stats.peak_left - 1.0).abs() < 1e-6);
assert!((stats.peak_right - 0.5).abs() < 1e-6);
assert!((stats.rms_left - 1.0).abs() < 0.001);
assert!((stats.rms_right - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn test_sink_node_file_writer() {
let (node, tx) = SinkNode::new("writer".to_string(), 10);
let handle = tokio::spawn(async move { node.run_mock_file_writer().await });
// Envoyer des chunks
for i in 0..5 {
let chunk = AudioChunk::from_channels_f32(i, vec![0.0; 100], vec![0.0; 100], 48000, BD);
tx.send(chunk).await.unwrap();
}
drop(tx);
let total_samples = handle.await.unwrap().unwrap();
assert_eq!(total_samples, 500);
}
}

View File

@@ -1,171 +0,0 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk, BitDepth,
};
use std::sync::Arc;
use tokio::sync::mpsc;
/// SourceNode - Génère ou lit des chunks audio depuis une source
///
/// Ce node est la source du pipeline. Version mock pour tests.
pub struct SourceNode {
subscribers: MultiSubscriberNode,
}
const DEFAULT_BIT_DEPTH: BitDepth = BitDepth::B24;
impl SourceNode {
pub fn new() -> Self {
Self {
subscribers: MultiSubscriberNode::new(),
}
}
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Génère un chunk de test avec une forme d'onde sinusoïdale
pub fn generate_test_chunk(
order: u64,
size: usize,
sample_rate: u32,
frequency: f32,
) -> Arc<AudioChunk> {
let mut left = Vec::with_capacity(size);
let mut right = Vec::with_capacity(size);
for i in 0..size {
let t = (order * size as u64 + i as u64) as f32 / sample_rate as f32;
let sample = (2.0 * std::f32::consts::PI * frequency * t).sin();
left.push(sample);
right.push(sample * 0.8); // Légèrement différent pour la stéréo
}
AudioChunk::from_channels_f32(order, left, right, sample_rate, DEFAULT_BIT_DEPTH)
}
/// Génère et envoie des chunks de test
pub async fn generate_chunks(
&self,
count: u64,
chunk_size: usize,
sample_rate: u32,
frequency: f32,
) -> Result<(), AudioError> {
for i in 0..count {
let chunk = Self::generate_test_chunk(i, chunk_size, sample_rate, frequency);
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Génère des chunks silencieux
pub async fn generate_silence(
&self,
count: u64,
chunk_size: usize,
sample_rate: u32,
) -> Result<(), AudioError> {
for i in 0..count {
let stereo = vec![[0i32; 2]; chunk_size];
let chunk = AudioChunk::new(i, stereo, sample_rate, DEFAULT_BIT_DEPTH);
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Version streaming : génère des chunks continuellement avec délai
pub async fn stream_chunks(
&self,
chunk_size: usize,
sample_rate: u32,
frequency: f32,
duration_ms: u64,
) -> Result<(), AudioError> {
let chunk_duration_ms = (chunk_size as f64 / sample_rate as f64 * 1000.0) as u64;
let mut order = 0u64;
let start = tokio::time::Instant::now();
let duration = tokio::time::Duration::from_millis(duration_ms);
while start.elapsed() < duration {
let chunk = Self::generate_test_chunk(order, chunk_size, sample_rate, frequency);
self.subscribers.push(chunk).await?;
order += 1;
// Attendre pour simuler le timing réel
tokio::time::sleep(tokio::time::Duration::from_millis(chunk_duration_ms)).await;
}
Ok(())
}
}
impl Default for SourceNode {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_source_node_generation() {
let mut source = SourceNode::new();
let (tx, mut rx) = mpsc::channel(10);
source.add_subscriber(tx);
// Générer 3 chunks
source.generate_chunks(3, 100, 48000, 440.0).await.unwrap();
// Vérifier la réception
for i in 0..3 {
let chunk = rx.recv().await.unwrap();
assert_eq!(chunk.order(), i);
assert_eq!(chunk.len(), 100);
assert_eq!(chunk.sample_rate(), 48000);
}
}
#[test]
fn test_sine_wave_generation() {
let chunk = SourceNode::generate_test_chunk(0, 48000, 48000, 440.0);
// Vérifier qu'on a bien une sinusoïde
// À 440 Hz avec 48000 samples/s, on devrait avoir 440 cycles
let pairs = chunk.to_pairs_f32();
let left: Vec<f32> = pairs.iter().map(|frame| frame[0]).collect();
// Trouver les passages par zéro
let mut zero_crossings = 0;
for i in 1..left.len() {
if (left[i - 1] < 0.0 && left[i] >= 0.0) || (left[i - 1] >= 0.0 && left[i] < 0.0) {
zero_crossings += 1;
}
}
// 440 cycles = 880 passages par zéro (approximativement)
assert!(zero_crossings > 850 && zero_crossings < 910);
}
#[tokio::test]
async fn test_source_node_silence() {
let mut source = SourceNode::new();
let (tx, mut rx) = mpsc::channel(10);
source.add_subscriber(tx);
source.generate_silence(2, 100, 48000).await.unwrap();
for _ in 0..2 {
let chunk = rx.recv().await.unwrap();
assert!(chunk.frames().iter().all(|frame| frame[0] == 0));
assert!(chunk.frames().iter().all(|frame| frame[1] == 0));
}
}
}

View File

@@ -1,291 +0,0 @@
use crate::{
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// TimerNode - Node passthrough qui calcule la position temporelle
///
/// Ce node ne modifie pas les données audio, il les passe directement
/// aux abonnés tout en maintenant un compteur de samples pour calculer
/// la position en secondes.
///
/// # Fonctionnement
///
/// Pour chaque chunk reçu:
/// 1. Incrémente `elapsed_samples += chunk.len()`
/// 2. Calcule `position_sec = elapsed_samples / sample_rate`
/// 3. Push le chunk (sans modification) vers les abonnés
///
/// # Utilisation
///
/// Le TimerNode fournit un [`TimerHandle`] qui permet de lire la position
/// depuis d'autres threads/tasks sans bloquer le pipeline.
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (mut timer, timer_tx) = TimerNode::new(10);
/// let handle = timer.get_position_handle();
///
/// tokio::spawn(async move {
/// timer.run().await.unwrap();
/// });
///
/// // Lire la position depuis un autre thread
/// let position = handle.position_sec().await;
/// println!("Position: {:.2} sec", position);
/// }
/// ```
pub struct TimerNode {
rx: mpsc::Receiver<Arc<AudioChunk>>,
subscribers: MultiSubscriberNode,
elapsed_samples: Arc<RwLock<u64>>,
current_sample_rate: Arc<RwLock<u32>>,
}
impl TimerNode {
/// Crée un nouveau TimerNode
pub fn new(channel_size: usize) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
elapsed_samples: Arc::new(RwLock::new(0)),
current_sample_rate: Arc::new(RwLock::new(48000)), // Default
};
(node, tx)
}
/// Ajoute un abonné
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Retourne la position actuelle en secondes
pub async fn position_sec(&self) -> f64 {
let elapsed = *self.elapsed_samples.read().await;
let sample_rate = *self.current_sample_rate.read().await;
elapsed as f64 / sample_rate as f64
}
/// Retourne le nombre total d'échantillons écoulés
pub async fn elapsed_samples(&self) -> u64 {
*self.elapsed_samples.read().await
}
/// Reset le compteur
pub async fn reset(&self) {
let mut elapsed = self.elapsed_samples.write().await;
*elapsed = 0;
}
/// Démarre la boucle de traitement du TimerNode
pub async fn run(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
// Mettre à jour le sample rate si nécessaire
{
let mut sr = self.current_sample_rate.write().await;
if *sr != chunk.sample_rate() {
*sr = chunk.sample_rate();
}
}
// Incrémenter le compteur d'échantillons
{
let mut elapsed = self.elapsed_samples.write().await;
*elapsed += chunk.len() as u64;
}
// Push immédiatement le même chunk vers les abonnés (passthrough)
self.subscribers.push(chunk).await?;
}
Ok(())
}
/// Version non-bloquante avec try_push
pub async fn run_nonblocking(mut self) -> Result<(), AudioError> {
while let Some(chunk) = self.rx.recv().await {
{
let mut sr = self.current_sample_rate.write().await;
if *sr != chunk.sample_rate() {
*sr = chunk.sample_rate();
}
}
{
let mut elapsed = self.elapsed_samples.write().await;
*elapsed += chunk.len() as u64;
}
self.subscribers.try_push(chunk).await?;
}
Ok(())
}
/// Retourne un handle pour lire la position depuis d'autres threads
pub fn get_position_handle(&self) -> TimerHandle {
TimerHandle {
elapsed_samples: self.elapsed_samples.clone(),
current_sample_rate: self.current_sample_rate.clone(),
}
}
}
/// Handle pour lire la position du TimerNode depuis d'autres threads
///
/// Ce handle peut être cloné et utilisé depuis plusieurs threads/tasks
/// pour monitorer la position de lecture sans bloquer le pipeline.
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::TimerNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (mut timer, _tx) = TimerNode::new(10);
/// let handle = timer.get_position_handle();
/// let handle_clone = handle.clone();
///
/// // Utiliser depuis plusieurs tasks
/// tokio::spawn(async move {
/// loop {
/// let pos = handle_clone.position_sec().await;
/// println!("Position: {:.2}s", pos);
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
/// }
/// });
/// }
/// ```
#[derive(Clone)]
pub struct TimerHandle {
elapsed_samples: Arc<RwLock<u64>>,
current_sample_rate: Arc<RwLock<u32>>,
}
impl TimerHandle {
/// Retourne la position actuelle en secondes
pub async fn position_sec(&self) -> f64 {
let elapsed = *self.elapsed_samples.read().await;
let sample_rate = *self.current_sample_rate.read().await;
elapsed as f64 / sample_rate as f64
}
/// Retourne le nombre total d'échantillons écoulés
pub async fn elapsed_samples(&self) -> u64 {
*self.elapsed_samples.read().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_timer_node_position_calculation() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = node.get_position_handle();
// Spawn le node
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer 3 chunks de 1000 samples à 48000 Hz
for i in 0..3 {
let stereo = vec![[0i32; 2]; 1000];
let chunk = AudioChunk::new(i, stereo, 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
}
// Attendre que les chunks soient traités
for _ in 0..3 {
out_rx.recv().await.unwrap();
}
// Vérifier la position
let position = handle.position_sec().await;
let expected = 3000.0 / 48000.0; // 3 chunks * 1000 samples / 48000 Hz
assert!((position - expected).abs() < 0.0001);
let elapsed = handle.elapsed_samples().await;
assert_eq!(elapsed, 3000);
}
#[tokio::test]
async fn test_timer_node_passthrough() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
tokio::spawn(async move {
node.run().await.unwrap();
});
// Envoyer un chunk
let chunk = AudioChunk::from_channels_i32(
42,
vec![100, 200, 300],
vec![400, 500, 600],
48000,
BitDepth::B24,
);
tx.send(chunk.clone()).await.unwrap();
// Recevoir le chunk
let received = out_rx.recv().await.unwrap();
// Vérifier que c'est le même Arc (pas de clone des données)
assert!(Arc::ptr_eq(&chunk, &received));
assert_eq!(received.order(), 42);
}
#[tokio::test]
async fn test_timer_node_sample_rate_change() {
let (mut node, tx) = TimerNode::new(10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = node.get_position_handle();
tokio::spawn(async move {
node.run().await.unwrap();
});
// Chunk à 48000 Hz
let chunk1 = AudioChunk::new(0, vec![[0i32; 2]; 48000], 48000, BitDepth::B24);
tx.send(chunk1).await.unwrap();
out_rx.recv().await.unwrap();
// Après 48000 samples à 48000 Hz = 1 seconde
let pos1 = handle.position_sec().await;
assert!((pos1 - 1.0).abs() < 0.0001);
// Chunk à 96000 Hz
let chunk2 = AudioChunk::new(1, vec![[0i32; 2]; 96000], 96000, BitDepth::B24);
tx.send(chunk2).await.unwrap();
out_rx.recv().await.unwrap();
// Position calculée avec le nouveau sample rate
let pos2 = handle.position_sec().await;
let expected = (48000.0 + 96000.0) / 96000.0;
assert!((pos2 - expected).abs() < 0.0001);
}
}

View File

@@ -1,363 +0,0 @@
//! Volume nodes - Contrôle du volume audio
//!
//! Ce module fournit des nodes pour ajuster le volume du flux audio,
//! avec support du volume master/secondaire et notification des changements.
use crate::{
events::{EventPublisher, VolumeChangeEvent},
nodes::{AudioError, MultiSubscriberNode},
AudioChunk,
};
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
/// VolumeNode - Applique un gain au flux audio (contrôle software)
///
/// Ce node modifie le champ `gain` de chaque `AudioChunk` qui le traverse.
/// Le gain est multiplié avec le gain existant du chunk, permettant ainsi
/// une chaîne de contrôles de volume.
///
/// # Caractéristiques
///
/// - Thread-safe : le volume peut être modifié pendant l'exécution via `set_volume`
/// - Notification : émet des événements `VolumeChangeEvent` lors des changements
/// - Master/Slave : peut s'abonner à un volume master pour synchronisation
///
/// # Exemples
///
/// ```no_run
/// use pmoaudio::VolumeNode;
///
/// #[tokio::main]
/// async fn main() {
/// let (volume_node, volume_tx) = VolumeNode::new("Room 1".to_string(), 0.8, 10);
///
/// // Modifier le volume pendant l'exécution
/// let handle = volume_node.get_handle();
/// tokio::spawn(async move {
/// tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
/// handle.set_volume(0.5).await;
/// });
///
/// tokio::spawn(async move { volume_node.run().await.unwrap() });
/// }
/// ```
pub struct VolumeNode {
/// Channel pour recevoir les chunks audio
rx: mpsc::Receiver<Arc<AudioChunk>>,
/// Subscribers pour les chunks modifiés
subscribers: MultiSubscriberNode,
/// Volume courant (partagé via RwLock pour lecture/écriture thread-safe)
volume: Arc<RwLock<f32>>,
/// Publisher pour les événements de changement de volume
volume_publisher: EventPublisher<VolumeChangeEvent>,
/// Identifiant unique du node (pour traçabilité)
node_id: String,
/// Receiver pour les événements de volume master (optionnel)
master_volume_rx: Option<mpsc::Receiver<VolumeChangeEvent>>,
}
impl VolumeNode {
/// Crée un nouveau VolumeNode
///
/// # Arguments
///
/// * `node_id` - Identifiant unique du node
/// * `initial_volume` - Volume initial (0.0 à 1.0)
/// * `channel_size` - Taille du buffer du channel
pub fn new(
node_id: String,
initial_volume: f32,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (tx, rx) = mpsc::channel(channel_size);
let node = Self {
rx,
subscribers: MultiSubscriberNode::new(),
volume: Arc::new(RwLock::new(initial_volume)),
volume_publisher: EventPublisher::new(),
node_id,
master_volume_rx: None,
};
(node, tx)
}
/// Ajoute un subscriber pour recevoir les chunks audio modifiés
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.subscribers.add_subscriber(tx);
}
/// Ajoute un subscriber pour les événements de changement de volume
pub fn subscribe_volume_events(&mut self, tx: mpsc::Sender<VolumeChangeEvent>) {
self.volume_publisher.subscribe(tx);
}
/// Configure ce node pour écouter un volume master
///
/// Le node appliquera à la fois son volume local ET le volume master reçu.
pub fn set_master_volume_source(&mut self, rx: mpsc::Receiver<VolumeChangeEvent>) {
self.master_volume_rx = Some(rx);
}
/// Retourne un handle pour contrôler le volume depuis un autre contexte
pub fn get_handle(&self) -> VolumeHandle {
VolumeHandle {
volume: self.volume.clone(),
node_id: self.node_id.clone(),
publisher: Arc::new(RwLock::new(self.volume_publisher.clone())),
}
}
/// Démarre la boucle de traitement du VolumeNode
pub async fn run(mut self) -> Result<(), AudioError> {
let mut master_volume = 1.0f32;
loop {
tokio::select! {
// Recevoir les chunks audio
chunk_opt = self.rx.recv() => {
match chunk_opt {
Some(chunk) => {
let local_volume = *self.volume.read().await;
let total_volume = (local_volume * master_volume).max(0.0);
// Créer un nouveau chunk avec le gain modifié (conversion vers dB)
let modified_chunk =
chunk.with_modified_gain_linear(total_volume as f64);
// Envoyer aux subscribers
self.subscribers.push(modified_chunk).await?;
}
None => {
// Channel fermé, terminer
break;
}
}
}
// Recevoir les mises à jour du volume master (si configuré)
master_event_opt = async {
if let Some(ref mut rx) = self.master_volume_rx {
rx.recv().await
} else {
// Bloquer indéfiniment si pas de master
std::future::pending().await
}
} => {
if let Some(event) = master_event_opt {
master_volume = event.volume;
// Optionnel : re-publier l'événement combiné
let local_volume = *self.volume.read().await;
let combined_event = VolumeChangeEvent {
volume: local_volume * master_volume,
source_node_id: self.node_id.clone(),
};
self.volume_publisher.publish(combined_event).await;
}
}
}
}
Ok(())
}
}
/// Handle pour contrôler un VolumeNode depuis un autre contexte
///
/// Ce handle permet de modifier le volume et de notifier les subscribers
/// sans avoir accès direct au node.
#[derive(Clone)]
pub struct VolumeHandle {
volume: Arc<RwLock<f32>>,
node_id: String,
publisher: Arc<RwLock<EventPublisher<VolumeChangeEvent>>>,
}
impl VolumeHandle {
/// Modifie le volume
///
/// # Arguments
///
/// * `new_volume` - Nouveau volume (0.0 à 1.0)
pub async fn set_volume(&self, new_volume: f32) {
let clamped = new_volume.clamp(0.0, 1.0);
*self.volume.write().await = clamped;
// Publier l'événement de changement
let event = VolumeChangeEvent {
volume: clamped,
source_node_id: self.node_id.clone(),
};
self.publisher.read().await.publish(event).await;
}
/// Obtient le volume courant
pub async fn get_volume(&self) -> f32 {
*self.volume.read().await
}
/// Augmente le volume de manière relative
pub async fn adjust_volume(&self, delta: f32) {
let current = *self.volume.read().await;
self.set_volume(current + delta).await;
}
}
/// HardwareVolumeNode - Contrôle matériel du volume
///
/// Ce node simule un contrôle hardware du volume. Dans une implémentation réelle,
/// il communiquerait avec le driver audio pour ajuster le volume matériel.
///
/// Pour cette version, il agit de manière similaire à `VolumeNode` mais pourrait
/// être étendu pour utiliser des APIs système spécifiques.
pub struct HardwareVolumeNode {
inner: VolumeNode,
}
impl HardwareVolumeNode {
/// Crée un nouveau HardwareVolumeNode
pub fn new(
node_id: String,
initial_volume: f32,
channel_size: usize,
) -> (Self, mpsc::Sender<Arc<AudioChunk>>) {
let (inner, tx) = VolumeNode::new(node_id, initial_volume, channel_size);
(Self { inner }, tx)
}
/// Ajoute un subscriber
pub fn add_subscriber(&mut self, tx: mpsc::Sender<Arc<AudioChunk>>) {
self.inner.add_subscriber(tx);
}
/// Obtient un handle pour contrôler le volume
pub fn get_handle(&self) -> VolumeHandle {
self.inner.get_handle()
}
/// Démarre la boucle de traitement
pub async fn run(self) -> Result<(), AudioError> {
// Dans une vraie implémentation, on communiquerait avec le hardware ici
// Pour l'instant, délègue au VolumeNode standard
self.inner.run().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::BitDepth;
#[tokio::test]
async fn test_volume_node_basic() {
let (mut node, tx) = VolumeNode::new("test".to_string(), 0.5, 10);
let (out_tx, mut out_rx) = mpsc::channel(10);
node.add_subscriber(out_tx);
let handle = tokio::spawn(async move { node.run().await });
// Envoyer un chunk avec gain 1.0
let chunk =
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
tx.send(chunk).await.unwrap();
// Recevoir le chunk modifié
let modified = out_rx.recv().await.unwrap();
assert!((modified.gain_linear() - 0.5).abs() < 1e-6);
drop(tx);
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn test_volume_handle() {
let (node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
let handle = node.get_handle();
tokio::spawn(async move { node.run().await });
// Modifier le volume via le handle
handle.set_volume(0.3).await;
let volume = handle.get_volume().await;
assert!((volume - 0.3).abs() < f32::EPSILON);
drop(tx);
}
#[tokio::test]
async fn test_volume_events() {
let (mut node, tx) = VolumeNode::new("test".to_string(), 1.0, 10);
let (event_tx, mut event_rx) = mpsc::channel(10);
node.subscribe_volume_events(event_tx);
let handle = node.get_handle();
tokio::spawn(async move { node.run().await });
// Changer le volume
handle.set_volume(0.7).await;
// Vérifier l'événement
let event = event_rx.recv().await.unwrap();
assert!((event.volume - 0.7).abs() < f32::EPSILON);
assert_eq!(event.source_node_id, "test");
drop(tx);
}
#[tokio::test]
async fn test_master_slave_volume() {
// Créer le master
let (mut master, master_tx) = VolumeNode::new("master".to_string(), 1.0, 10);
let (master_event_tx, master_event_rx) = mpsc::channel(10);
master.subscribe_volume_events(master_event_tx);
let master_handle = master.get_handle();
// Créer le slave
let (mut slave, slave_tx) = VolumeNode::new("slave".to_string(), 0.8, 10);
slave.set_master_volume_source(master_event_rx);
let (out_tx, mut out_rx) = mpsc::channel(10);
slave.add_subscriber(out_tx);
tokio::spawn(async move { master.run().await });
tokio::spawn(async move { slave.run().await });
// Envoyer un chunk au slave
let chunk =
AudioChunk::from_channels_f32(0, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
slave_tx.send(chunk).await.unwrap();
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Modifier le volume master
master_handle.set_volume(0.5).await;
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
// Envoyer un autre chunk
let chunk2 =
AudioChunk::from_channels_f32(1, vec![1.0; 100], vec![1.0; 100], 48000, BitDepth::B24);
slave_tx.send(chunk2).await.unwrap();
// Le deuxième chunk devrait avoir un gain de 0.8 * 0.5 = 0.4 (≈ -7.96 dB)
let _first = out_rx.recv().await.unwrap(); // gain ≈ 0.8
let second = out_rx.recv().await.unwrap(); // gain ≈ 0.4
assert!((second.gain_linear() - 0.4).abs() < 0.01);
drop(master_tx);
drop(slave_tx);
}
}

View File

@@ -1,170 +0,0 @@
//! Exemple d'utilisation basique de pmoplaylist
//!
//! Pour exécuter cet exemple :
//! ```bash
//! cargo run -p pmoplaylist --example basic_usage
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
#[tokio::main]
async fn main() {
println!("=== Exemple pmoplaylist ===\n");
// 1. Créer une playlist FIFO
println!("1. Création d'une playlist avec capacité de 5 tracks...");
let playlist = FifoPlaylist::new(
"my-radio".to_string(),
"Ma Radio Préférée".to_string(),
5,
DEFAULT_IMAGE,
);
println!(" ✓ Playlist créée: {}", playlist.title().await);
println!(" ✓ ID: {}", playlist.id().await);
println!(" ✓ Capacité: 5 tracks");
println!(" ✓ Update ID initial: {}\n", playlist.update_id().await);
// 2. Ajouter des tracks
println!("2. Ajout de 3 tracks...");
let tracks = vec![
Track::new(
"track-1",
"Bohemian Rhapsody",
"http://example.com/queen/bohemian.flac",
)
.with_artist("Queen")
.with_album("A Night at the Opera")
.with_duration(354)
.with_image("http://example.com/covers/queen-anato.jpg"),
Track::new(
"track-2",
"Stairway to Heaven",
"http://example.com/zeppelin/stairway.mp3",
)
.with_artist("Led Zeppelin")
.with_album("Led Zeppelin IV")
.with_duration(482),
Track::new(
"track-3",
"Hotel California",
"http://example.com/eagles/hotel.flac",
)
.with_artist("Eagles")
.with_album("Hotel California")
.with_duration(391),
];
for track in tracks {
playlist.append_track(track.clone()).await;
println!(
" ✓ Ajouté: {} - {}",
track.title,
track.artist.unwrap_or_default()
);
}
println!("\n Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 3. Tester le comportement FIFO
println!("3. Test du comportement FIFO (capacité = 5)...");
println!(" Ajout de 4 tracks supplémentaires...");
for i in 4..=7 {
let track = Track::new(
format!("track-{}", i),
format!("Song Number {}", i),
format!("http://example.com/songs/{}.mp3", i),
);
playlist.append_track(track).await;
}
println!(
" ✓ Total tracks (limité par capacité): {}",
playlist.len().await
);
// Afficher les tracks actuels
let items = playlist.get_items(0, 10).await;
println!("\n Tracks actuels dans la FIFO:");
for (idx, track) in items.iter().enumerate() {
println!(" {}. {} ({})", idx + 1, track.title, track.id);
}
println!(" (Les tracks 1 et 2 ont été supprimés automatiquement)\n");
// 4. Supprimer le plus ancien
println!("4. Suppression du track le plus ancien...");
if let Some(removed) = playlist.remove_oldest().await {
println!(" ✓ Supprimé: {} ({})", removed.title, removed.id);
}
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 5. Supprimer par ID
println!("5. Suppression d'un track par ID (track-5)...");
if playlist.remove_by_id("track-5").await {
println!(" ✓ Track supprimé");
}
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}\n", playlist.update_id().await);
// 6. Générer un Container DIDL-Lite
println!("6. Génération du Container DIDL-Lite...");
let container = playlist.as_container().await;
println!(" Container:");
println!(" - ID: {}", container.id);
println!(" - Parent ID: {}", container.parent_id);
println!(" - Title: {}", container.title);
println!(" - Class: {}", container.class);
println!(
" - Child Count: {}\n",
container.child_count.unwrap_or_default()
);
// 7. Générer des Items DIDL-Lite
println!("7. Génération des Items DIDL-Lite...");
let didl_items = playlist
.as_objects(0, 10, Some("http://myserver/api/default-image"))
.await;
println!(" Items DIDL-Lite:");
for (idx, item) in didl_items.iter().enumerate() {
println!("\n Item {}:", idx + 1);
println!(" - ID: {}", item.id);
println!(" - Title: {}", item.title);
println!(" - Artist: {}", item.artist.as_deref().unwrap_or("N/A"));
println!(" - Album: {}", item.album.as_deref().unwrap_or("N/A"));
println!(" - Class: {}", item.class);
println!(" - Parent ID: {}", item.parent_id);
if !item.resources.is_empty() {
println!(" - Resource URI: {}", item.resources[0].url);
if let Some(ref duration) = item.resources[0].duration {
println!(" - Duration: {}", duration);
}
}
if let Some(ref art) = item.album_art {
println!(" - Album Art: {}", art);
}
}
// 8. Image par défaut
println!("\n8. Image par défaut...");
let default_image = playlist.default_image().await;
println!(
" ✓ Taille de l'image par défaut: {} bytes",
default_image.len()
);
println!(" (Cette image peut être servie via un endpoint HTTP)\n");
// 9. Vider la playlist
println!("9. Vidage de la playlist...");
playlist.clear().await;
println!(" ✓ Playlist vidée");
println!(" Total tracks: {}", playlist.len().await);
println!(" Is empty: {}", playlist.is_empty().await);
println!(" Update ID final: {}\n", playlist.update_id().await);
println!("=== Exemple terminé ===");
}

View File

@@ -1,217 +0,0 @@
//! Exemple d'intégration avec un serveur HTTP
//!
//! Cet exemple montre comment exposer une playlist FIFO via des endpoints HTTP simples.
//! Dans un vrai MediaServer UPnP, ces endpoints seraient appelés par le protocole ContentDirectory.
//!
//! Pour exécuter :
//! ```bash
//! cargo run -p pmoplaylist --example http_server_integration
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
use std::sync::Arc;
#[tokio::main]
async fn main() {
println!("=== Intégration HTTP Server ===\n");
// Créer une playlist partagée
let playlist = Arc::new(FifoPlaylist::new(
"my-radio".to_string(),
"My Internet Radio".to_string(),
20,
DEFAULT_IMAGE,
));
println!("📻 Playlist créée: {}", playlist.title().await);
println!("🆔 ID: {}\n", playlist.id().await);
// Ajouter quelques tracks initiaux
println!("📝 Ajout de tracks initiaux...");
let initial_tracks = vec![
("The Beatles", "Come Together", "Abbey Road", 259),
("Nirvana", "Smells Like Teen Spirit", "Nevermind", 301),
("Queen", "Bohemian Rhapsody", "A Night at the Opera", 354),
];
for (idx, (artist, title, album, duration)) in initial_tracks.iter().enumerate() {
playlist
.append_track(
Track::new(
format!("track-{}", idx),
*title,
format!("http://media.server/music/{}.flac", idx),
)
.with_artist(*artist)
.with_album(*album)
.with_duration(*duration)
.with_image(format!("http://media.server/covers/{}.jpg", idx)),
)
.await;
println!("{} - {}", artist, title);
}
println!();
// Simuler différents endpoints HTTP
// 1. GET /playlist/container - Retourne le container DIDL-Lite
println!("🌐 Endpoint: GET /playlist/container");
simulate_get_container(playlist.clone()).await;
println!();
// 2. GET /playlist/items?offset=0&count=10 - Retourne les items
println!("🌐 Endpoint: GET /playlist/items?offset=0&count=10");
simulate_get_items(playlist.clone(), 0, 10).await;
println!();
// 3. GET /playlist/metadata - Retourne les métadonnées
println!("🌐 Endpoint: GET /playlist/metadata");
simulate_get_metadata(playlist.clone()).await;
println!();
// 4. POST /playlist/track - Ajoute un nouveau track
println!("🌐 Endpoint: POST /playlist/track");
let new_track = Track::new(
"track-new-1",
"Stairway to Heaven",
"http://media.server/music/stairway.flac",
)
.with_artist("Led Zeppelin")
.with_album("Led Zeppelin IV")
.with_duration(482);
simulate_add_track(playlist.clone(), new_track).await;
println!();
// 5. DELETE /playlist/oldest - Supprime le plus ancien
println!("🌐 Endpoint: DELETE /playlist/oldest");
simulate_delete_oldest(playlist.clone()).await;
println!();
// 6. GET /playlist/default-image - Retourne l'image par défaut
println!("🌐 Endpoint: GET /playlist/default-image");
simulate_get_default_image(playlist.clone()).await;
println!();
// 7. Vérifier l'état final
println!("📊 État final:");
let final_items = playlist.get_items(0, 10).await;
println!(" Total tracks: {}", playlist.len().await);
println!(" Update ID: {}", playlist.update_id().await);
println!("\n Tracks actuels:");
for (idx, track) in final_items.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
println!(" {}. {} - {}", idx + 1, artist, track.title);
}
println!("\n=== Exemple terminé ===");
}
/// Simule GET /playlist/container
async fn simulate_get_container(playlist: Arc<FifoPlaylist>) {
let container = playlist.as_container().await;
println!(" Response (JSON representation):");
println!(" {{");
println!(" \"id\": \"{}\",", container.id);
println!(" \"parentId\": \"{}\",", container.parent_id);
println!(" \"title\": \"{}\",", container.title);
println!(" \"class\": \"{}\",", container.class);
println!(
" \"childCount\": {}",
container.child_count.unwrap_or_default()
);
println!(" }}");
}
/// Simule GET /playlist/items?offset=X&count=Y
async fn simulate_get_items(playlist: Arc<FifoPlaylist>, offset: usize, count: usize) {
let items = playlist
.as_objects(offset, count, Some("http://media.server/api/default-image"))
.await;
println!(" Response: {} items", items.len());
println!(" [");
for (idx, item) in items.iter().enumerate() {
println!(" {{");
println!(" \"id\": \"{}\",", item.id);
println!(" \"title\": \"{}\",", item.title);
println!(
" \"artist\": \"{}\",",
item.artist.as_deref().unwrap_or("")
);
println!(
" \"album\": \"{}\",",
item.album.as_deref().unwrap_or("")
);
println!(" \"class\": \"{}\",", item.class);
if !item.resources.is_empty() {
println!(" \"uri\": \"{}\",", item.resources[0].url);
}
print!(" }}");
if idx < items.len() - 1 {
println!(",");
} else {
println!();
}
}
println!(" ]");
}
/// Simule GET /playlist/metadata
async fn simulate_get_metadata(playlist: Arc<FifoPlaylist>) {
let update_id = playlist.update_id().await;
let last_change = playlist.last_change().await;
let count = playlist.len().await;
let id = playlist.id().await;
let title = playlist.title().await;
println!(" Response:");
println!(" {{");
println!(" \"id\": \"{}\",", id);
println!(" \"title\": \"{}\",", title);
println!(" \"trackCount\": {},", count);
println!(" \"updateId\": {},", update_id);
println!(" \"lastChange\": \"{:?}\"", last_change);
println!(" }}");
}
/// Simule POST /playlist/track
async fn simulate_add_track(playlist: Arc<FifoPlaylist>, track: Track) {
let old_update_id = playlist.update_id().await;
playlist.append_track(track.clone()).await;
let new_update_id = playlist.update_id().await;
println!(
" Track added: {} - {}",
track.artist.as_deref().unwrap_or("Unknown"),
track.title
);
println!(" Update ID: {}{}", old_update_id, new_update_id);
println!(" Response: 201 Created");
}
/// Simule DELETE /playlist/oldest
async fn simulate_delete_oldest(playlist: Arc<FifoPlaylist>) {
if let Some(removed) = playlist.remove_oldest().await {
println!(" Track removed: {} ({})", removed.title, removed.id);
println!(" New update ID: {}", playlist.update_id().await);
println!(" Response: 200 OK");
} else {
println!(" No tracks to remove");
println!(" Response: 404 Not Found");
}
}
/// Simule GET /playlist/default-image
async fn simulate_get_default_image(playlist: Arc<FifoPlaylist>) {
let image_bytes = playlist.default_image().await;
println!(" Response:");
println!(" Content-Type: image/webp");
println!(" Content-Length: {} bytes", image_bytes.len());
println!(" Status: 200 OK");
println!(" (Image WebP {} bytes ready to serve)", image_bytes.len());
}

View File

@@ -1,198 +0,0 @@
//! Exemple simulant une radio en streaming
//!
//! Cet exemple démontre :
//! - L'utilisation de FifoPlaylist dans un contexte multi-thread
//! - La simulation d'un flux radio continu
//! - La surveillance des changements via update_id
//!
//! Pour exécuter :
//! ```bash
//! cargo run -p pmoplaylist --example radio_streaming
//! ```
use pmoplaylist::{FifoPlaylist, Track, DEFAULT_IMAGE};
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main]
async fn main() {
println!("=== Simulation Radio en Streaming ===\n");
// Créer une radio avec historique limité à 10 tracks
let radio = FifoPlaylist::new(
"radio-paradise".to_string(),
"Radio Paradise - Main Mix".to_string(),
10,
DEFAULT_IMAGE,
);
println!("📻 Radio créée: {}", radio.title().await);
println!("📊 Capacité: 10 tracks (historique limité)");
println!("🆔 ID: {}\n", radio.id().await);
// Cloner pour les différentes tâches
let radio_streamer = radio.clone();
let radio_monitor = radio.clone();
let radio_client = radio.clone();
// Tâche 1: Simuler le streaming (ajoute des tracks régulièrement)
let streamer = tokio::spawn(async move {
println!("🎵 [STREAMER] Démarrage du flux radio...\n");
let tracks_data = vec![
("Radiohead", "Paranoid Android", "OK Computer", 383),
("Massive Attack", "Teardrop", "Mezzanine", 329),
(
"Pink Floyd",
"Shine On You Crazy Diamond",
"Wish You Were Here",
810,
),
("Portishead", "Glory Box", "Dummy", 305),
("Dire Straits", "Sultans of Swing", "Dire Straits", 349),
("The Cure", "Pictures of You", "Disintegration", 428),
("David Bowie", "Heroes", "Heroes", 371),
(
"Talking Heads",
"Once in a Lifetime",
"Remain in Light",
259,
),
("Fleetwood Mac", "Dreams", "Rumours", 257),
(
"The Smiths",
"There Is a Light That Never Goes Out",
"The Queen Is Dead",
244,
),
("Joy Division", "Love Will Tear Us Apart", "Closer", 206),
("New Order", "Blue Monday", "Power, Corruption & Lies", 448),
("Depeche Mode", "Enjoy the Silence", "Violator", 376),
("R.E.M.", "Losing My Religion", "Out of Time", 269),
(
"U2",
"Where the Streets Have No Name",
"The Joshua Tree",
337,
),
];
for (idx, (artist, title, album, duration)) in tracks_data.iter().enumerate() {
let track = Track::new(
format!("radio-track-{}", idx),
*title,
format!("http://stream.radioparadise.com/track/{}", idx),
)
.with_artist(*artist)
.with_album(*album)
.with_duration(*duration);
radio_streamer.append_track(track).await;
println!("🎵 [STREAMER] Now Playing: {} - {}", artist, title);
// Simuler l'attente entre les tracks
sleep(Duration::from_millis(500)).await;
}
println!("\n🎵 [STREAMER] Fin du streaming");
});
// Tâche 2: Monitorer les changements (update_id)
let monitor = tokio::spawn(async move {
sleep(Duration::from_millis(100)).await;
println!("👁️ [MONITOR] Surveillance des changements...\n");
let mut last_update_id = 0;
let mut iterations = 0;
loop {
let current_update_id = radio_monitor.update_id().await;
let count = radio_monitor.len().await;
if current_update_id != last_update_id {
println!(
"👁️ [MONITOR] Changement détecté! Update ID: {}{} | Tracks: {}",
last_update_id, current_update_id, count
);
last_update_id = current_update_id;
}
iterations += 1;
if iterations >= 50 {
break;
}
sleep(Duration::from_millis(200)).await;
}
println!("\n👁️ [MONITOR] Fin de la surveillance");
});
// Tâche 3: Client consultant l'historique
let client = tokio::spawn(async move {
sleep(Duration::from_millis(2000)).await;
println!("\n📱 [CLIENT] Consultation de l'historique de la radio...\n");
// Consulter plusieurs fois pendant le streaming
for i in 0..3 {
sleep(Duration::from_millis(2000)).await;
let history = radio_client.get_items(0, 10).await;
let update_id = radio_client.update_id().await;
println!(
"📱 [CLIENT] Consultation #{} (Update ID: {})",
i + 1,
update_id
);
println!(" Historique actuel ({} tracks):", history.len());
for (idx, track) in history.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
println!(" {}. {} - {}", idx + 1, artist, track.title);
}
println!();
}
// Générer le container DIDL-Lite à la fin
println!("📱 [CLIENT] Génération du Container DIDL-Lite...");
let container = radio_client.as_container().await;
println!(" Container ID: {}", container.id);
println!(" Title: {}", container.title);
println!(
" Child Count: {}",
container.child_count.unwrap_or_default()
);
println!("\n📱 [CLIENT] Fin de la consultation");
});
// Attendre que toutes les tâches se terminent
let _ = tokio::join!(streamer, monitor, client);
// Afficher l'état final
println!("\n=== État Final ===");
println!("📊 Total tracks dans la radio: {}", radio.len().await);
println!("🆔 Update ID final: {}", radio.update_id().await);
let final_history = radio.get_items(0, 10).await;
println!("\n🎵 Historique final (10 derniers tracks):");
for (idx, track) in final_history.iter().enumerate() {
let artist = track.artist.as_deref().unwrap_or("Unknown");
let duration_min = track.duration.map(|d| d / 60).unwrap_or(0);
let duration_sec = track.duration.map(|d| d % 60).unwrap_or(0);
println!(
" {}. {} - {} ({}:{:02})",
idx + 1,
artist,
track.title,
duration_min,
duration_sec
);
}
println!("\n=== Simulation terminée ===");
}

6
package-lock.json generated
View File

@@ -1,6 +0,0 @@
{
"name": "pmomusic",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

Some files were not shown because too many files have changed in this diff Show More