Compare commits
54 Commits
version-0.
...
version-0.
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd0e79be8 | |||
| 02cff8e913 | |||
| 7b5eb5a4da | |||
| c524ba57e5 | |||
| 8e9dd7601f | |||
| 50bc0570ba | |||
| f3274fcc10 | |||
| 2cdc7109d1 | |||
| c250801a9f | |||
| 2ef4ebf020 | |||
| 82bfab233f | |||
| 93f5900456 | |||
| af003292bc | |||
| c1bf488ab2 | |||
| 6f25b8ef53 | |||
| 01abf5d8bc | |||
| 258d97bd1f | |||
| 646633af6b | |||
| 7d2cb75e58 | |||
| da12cc82b3 | |||
| 7ce91bc079 | |||
| f074f4d657 | |||
| 8fb135541d | |||
| 5a30731854 | |||
| 36a05a2575 | |||
| 293daf5b3b | |||
| 700c8f2fc9 | |||
| c3a9e3ea7b | |||
| 7f10f55086 | |||
| 220eeb1244 | |||
| c9f15289d0 | |||
| c745179200 | |||
| 56e1f4c0fb | |||
| 4324e1499a | |||
| bd8f87fbe4 | |||
| 58ac28489c | |||
| 256495b483 | |||
| 2ebbed2ffc | |||
| 38f49d4439 | |||
| d708bb6035 | |||
| 3d533e1989 | |||
| 60785745aa | |||
| 9ff85bbd10 | |||
| 7546072d2b | |||
| c53ccf2f93 | |||
| 048718c895 | |||
| 7e14d36230 | |||
| a143e39422 | |||
| f5d055be6f | |||
| e42a7b1ef2 | |||
| 5320545d56 | |||
| 9c90e706f1 | |||
| a4301140d8 | |||
| 64450185c5 |
@@ -5,7 +5,6 @@ on:
|
||||
branches:
|
||||
- main # Changez cela si votre branche principale a un autre nom
|
||||
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -24,6 +23,3 @@ jobs:
|
||||
no_cache: true
|
||||
version_file: version.txt
|
||||
check_uuid: 82a30d23-b3bd-4199-9237-776965831d20
|
||||
|
||||
|
||||
|
||||
32
.vscode/settings.json
vendored
32
.vscode/settings.json
vendored
@@ -1,15 +1,37 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false,
|
||||
"git.enabled": false,
|
||||
"claude-code.environmentVariables": [
|
||||
"claude-code.environmentVariables": [],
|
||||
|
||||
],
|
||||
// Exclusions via VS Code
|
||||
// Exclusions de fichiers/dossiers inutiles pour Rust Analyzer
|
||||
"files.exclude": {
|
||||
"target": true,
|
||||
"**/target": true,
|
||||
"node_modules": 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.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,
|
||||
}
|
||||
921
Blackboard/Architecture/music_source.md
Normal file
921
Blackboard/Architecture/music_source.md
Normal file
@@ -0,0 +1,921 @@
|
||||
# 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.
|
||||
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
1074
Blackboard/Architecture/pmoconfig_ext.md
Normal file
File diff suppressed because it is too large
Load Diff
870
Blackboard/Architecture/pmoserver_ext.md
Normal file
870
Blackboard/Architecture/pmoserver_ext.md
Normal file
@@ -0,0 +1,870 @@
|
||||
# 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(¶ms).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
|
||||
905
Blackboard/Done/Pinnable_cache_item.md
Normal file
905
Blackboard/Done/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,905 @@
|
||||
# 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.
|
||||
152
Blackboard/Done/WeabApp_debouncingSSE.md
Normal file
152
Blackboard/Done/WeabApp_debouncingSSE.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
138
Blackboard/Done/shuffle_play.md
Normal file
138
Blackboard/Done/shuffle_play.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# 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.
|
||||
114
Blackboard/Done/stateful_music_renderer.md
Normal file
114
Blackboard/Done/stateful_music_renderer.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# 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)
|
||||
390
Blackboard/Report/Pinnable_cache_item.md
Normal file
390
Blackboard/Report/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# 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
|
||||
152
Blackboard/Report/WeabApp_debouncingSSE.md
Normal file
152
Blackboard/Report/WeabApp_debouncingSSE.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# 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
|
||||
96
Blackboard/Report/config_ext.md
Normal file
96
Blackboard/Report/config_ext.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 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
|
||||
227
Blackboard/Report/music_source.md
Normal file
227
Blackboard/Report/music_source.md
Normal file
@@ -0,0 +1,227 @@
|
||||
# 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.
|
||||
77
Blackboard/Report/pmoserver_ext.md
Normal file
77
Blackboard/Report/pmoserver_ext.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 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)
|
||||
229
Blackboard/Report/stateful_music_renderer.md
Normal file
229
Blackboard/Report/stateful_music_renderer.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# 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.
|
||||
177
Blackboard/Rules.md
Normal file
177
Blackboard/Rules.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# 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)
|
||||
8
Blackboard/ToDiscuss/Pinnable_cache_item.md
Normal file
8
Blackboard/ToDiscuss/Pinnable_cache_item.md
Normal file
@@ -0,0 +1,8 @@
|
||||
**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.
|
||||
21
Blackboard/ToDiscuss/pmoserver_ext.md
Normal file
21
Blackboard/ToDiscuss/pmoserver_ext.md
Normal file
@@ -0,0 +1,21 @@
|
||||
**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.
|
||||
515
Blackboard/ToThinkAbout/MusicBoxSource.md
Normal file
515
Blackboard/ToThinkAbout/MusicBoxSource.md
Normal file
@@ -0,0 +1,515 @@
|
||||
**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(¶dise, "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
|
||||
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
570
Blackboard/ToThinkAbout/PlayListSource.md
Normal file
@@ -0,0 +1,570 @@
|
||||
**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)
|
||||
17
Blackboard/Todo/config_ext.md
Normal file
17
Blackboard/Todo/config_ext.md
Normal file
@@ -0,0 +1,17 @@
|
||||
**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.
|
||||
14
Blackboard/Todo/music_source.md
Normal file
14
Blackboard/Todo/music_source.md
Normal file
@@ -0,0 +1,14 @@
|
||||
**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.
|
||||
934
Blackboard_HTML/Architecture_music_source.html
Normal file
934
Blackboard_HTML/Architecture_music_source.html
Normal file
@@ -0,0 +1,934 @@
|
||||
<!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
|
||||
d’implémentation d’une 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 d’ensemble</a></li>
|
||||
<li><a href="#structure-dune-musicsource">Structure d’une
|
||||
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
|
||||
d’implé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 d’ensemble</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 d’URI
|
||||
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 d’une MusicSource</h2>
|
||||
<h3 id="organisation-du-code">Organisation du code</h3>
|
||||
<pre><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</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">"../pmosource"</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">"../pmodidl"</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">"../pmoplaylist"</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">"../pmoaudiocache"</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">"../pmocovers"</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">"0.1"</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">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"sync"</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">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"derive"</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">"cache"</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">"pmoaudiocache"</span><span class="op">,</span> <span class="st">"pmocovers"</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">"pmoplaylist"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</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">"Ma Source Musicale"</span> <span class="co">// Nom affiché dans l'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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</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">"my-music-source"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</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">"../assets/default.webp"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="op">&</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">"image/webp"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">></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">// "my-music-source"</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">"0"</span><span class="op">.</span>to_string()<span class="op">,</span> <span class="co">// Toujours "0" 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">"1"</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">"1"</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">"object.container"</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">&</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>BrowseResult<span class="op">></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">=></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">=></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">&</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">&</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">=></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'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">&</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">=></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">"Unknown object: {}"</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 d’Object ID recommandé :</strong></p>
|
||||
<pre><code><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</pre>
|
||||
<p><strong>Types de BrowseResult :</strong> -
|
||||
<code>Containers(Vec<Container>)</code> : Liste de containers
|
||||
(navigation) - <code>Items(Vec<Item>)</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 d’URI</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">&</span><span class="kw">self</span><span class="op">,</span> object_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">></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">"{}/audio/flac/{}"</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'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">=></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">&</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">=></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">"Cannot resolve URI for: {}"</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"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</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"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</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">&</span><span class="kw">self</span>) <span class="op">-></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">&</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> Item) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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'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">&</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Option</span><span class="op"><</span>Item<span class="op">>></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">&</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'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">&</span><span class="kw">self</span>) <span class="op">-></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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>SystemTime<span class="op">></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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></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">&</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">&</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">&</span><span class="kw">self</span>) <span class="op">-></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">&</span><span class="kw">self</span><span class="op">,</span> _track<span class="op">:</span> Item) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">"This source is read-only"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Option</span><span class="op"><</span>Item<span class="op">>></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">&</span><span class="kw">self</span>) <span class="op">-></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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span>SystemTime<span class="op">></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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></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 d’implé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"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</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"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</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"><</span><span class="pp">std::sync::</span>Mutex<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u64</span><span class="op">>>>,</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"><</span>Arc<span class="op"><</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&</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">>>,</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">&</span>Arc<span class="op"><</span><span class="dt">Self</span><span class="op">></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">&</span>[<span class="pp">format!</span>(<span class="st">"radio-paradise:history"</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<RwLock<u32>></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"><</span>QobuzSourceInner<span class="op">>,</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"><</span>QobuzClient<span class="op">>,</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"><</span><span class="dt">u32</span><span class="op">>,</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"><</span>SystemTime<span class="op">>,</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">&</span><span class="kw">self</span><span class="op">,</span> track<span class="op">:</span> <span class="op">&</span>Track) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>(<span class="dt">String</span><span class="op">,</span> <span class="dt">String</span>)<span class="op">></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">"qobuz://track/{}"</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">"QOBUZ:{}"</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">&</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'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">&</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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></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">"qobuz-album-{}"</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">&</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">&</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">&</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">&</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 l’audio (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 l’audio 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>Lorsqu’une 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">&</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"><</span>Item<span class="op">>,</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">&</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">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Item<span class="op">>></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'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">"/audio/flac/"</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">&</span>pk<span class="op">,</span> <span class="st">"source_track_id"</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">"my-source:track:{}"</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">'/'</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">"{}{}"</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">'/'</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">"{}{}"</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">"Music"</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 l’ID 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">"my-source-album-123"</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">"Album Title"</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">"Artist Name"</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">"cover-pk"</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">"pk1"</span><span class="op">,</span> <span class="st">"pk2"</span><span class="op">,</span> <span class="st">"pk3"</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">"my-source-album-123"</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">"my-source"</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">"https://example.com/cover.jpg"</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">"MY-SOURCE:123"</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">&</span>audio_pk<span class="op">,</span> <span class="st">"key"</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"><</span>MyClient<span class="op">>,</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">&</span><span class="kw">self</span><span class="op">,</span> lazy_pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u8</span><span class="op">>,</span> LazyProviderError<span class="op">></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'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">"MY-SOURCE:"</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'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'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">&</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">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">>;</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">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Container<span class="op">></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">"my-source:album:{}"</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">"1"</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">"1"</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">"object.container.album.musicAlbum"</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">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Item<span class="op">>;</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">&</span><span class="kw">self</span><span class="op">,</span> parent_id<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Item<span class="op">></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">"my-source:track:{}"</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">"1"</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">"object.item.audioItem.musicTrack"</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">"Music"</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">"http-get:*:audio/flac:*"</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">"2"</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">"/audio/flac/{}"</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<votre-source></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 d’Object
|
||||
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 d’URI</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 d’erreurs
|
||||
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
|
||||
d’inté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 d’intérêt :</strong> - Structure avec
|
||||
<code>Arc<RwLock<>></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 d’Object 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 d’intérêt :</strong> - <code>SourceCacheManager</code>
|
||||
centralisé - Cache lazy pour audio, eager pour covers -
|
||||
<code>LazyProvider</code> personnalisé - Playlists d’albums avec TTL (7
|
||||
jours) - Adaptation IDs playlist → Qobuz - Navigation hiérarchique
|
||||
complexe (Discover, Genres, Favorites)</p>
|
||||
<p><strong>Schema d’Object 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 d’implé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 d’items 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<></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><upnp:genre></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
|
||||
l’UI</li>
|
||||
</ol>
|
||||
<h3 id="gestion-derreurs">Gestion d’erreurs</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
|
||||
l’URI</li>
|
||||
<li><strong>PlaylistError</strong> : Erreur d’interaction 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<RwLock<>></strong> : Pour l’état mutable
|
||||
partagé</li>
|
||||
<li><strong>tokio::sync::RwLock</strong> : Pour l’async</li>
|
||||
<li><strong>Éviter Rc<></strong> : Pas thread-safe</li>
|
||||
<li><strong>Clone</strong> : Implémentez <code>Clone</code> pour
|
||||
<code>Arc<></code></li>
|
||||
</ol>
|
||||
<h2 id="conclusion">Conclusion</h2>
|
||||
<p>L’implémentation d’une nouvelle <code>MusicSource</code> suit ces
|
||||
étapes :</p>
|
||||
<ol type="1">
|
||||
<li><strong>Définir le schéma d’Object 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 d’abord, 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>
|
||||
1023
Blackboard_HTML/Architecture_pmoconfig_ext.html
Normal file
1023
Blackboard_HTML/Architecture_pmoconfig_ext.html
Normal file
File diff suppressed because it is too large
Load Diff
929
Blackboard_HTML/Architecture_pmoserver_ext.html
Normal file
929
Blackboard_HTML/Architecture_pmoserver_ext.html
Normal file
@@ -0,0 +1,929 @@
|
||||
<!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 d’extension
|
||||
PMOServer (<code>pmoserver_ext</code>)</h1>
|
||||
<h2 id="vue-densemble">Vue d’ensemble</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 d’extension que
|
||||
<code>pmoserver::Server</code> implémente via une feature Cargo.</p>
|
||||
<h2 id="anatomie-dune-extension">Anatomie d’une 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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"dep:axum"</span><span class="op">,</span> <span class="st">"dep:async-trait"</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">"0.8"</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">"0.1"</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">"../pmoserver"</span><span class="op"> }</span></span></pre></div>
|
||||
<h3 id="définir-le-trait-dextension">2. Définir le trait
|
||||
d’extension</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">"pmoserver"</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'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">&</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">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>>;</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">"pmoserver"</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">&</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">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>></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'é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">"/api/xxx"</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"><</span>Resource<span class="op">>,</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"><</span>Resource<span class="op">></span>) <span class="op">-></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'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">-></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">"/items"</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">"/items/{id}"</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"><</span>XxxState<span class="op">></span></span>
|
||||
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>ItemSummary<span class="op">>></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"><</span>XxxState<span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||
<span id="cb7-20"><a href="#cb7-20" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>Item<span class="op">>,</span> StatusCode<span class="op">></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">&</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::<W>(path)</code></td>
|
||||
<td>Sert une Single Page Application (RustEmbed)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>base_url()</code></td>
|
||||
<td>Récupère l’URL 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 l’API.</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">"5"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"axum_extras"</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">"1"</span><span class="op">, </span><span class="dt">features</span><span class="op"> =</span> <span class="op">[</span><span class="st">"derive"</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'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">"item-123"</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'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">"Mon Item"</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">"Une description détaillée"</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"><</span><span class="dt">String</span><span class="op">>,</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'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'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"><</span>ItemInfo<span class="op">>,</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'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'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">"Nouvel Item"</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"><</span><span class="dt">String</span><span class="op">>,</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'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'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">"Item not found"</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 l’API - Utiliser <code>Option<T></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">"/items"</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">"limit"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Nombre max d'items à retourner"</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">"offset"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u32</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Offset pour la pagination"</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">"Liste des items"</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">"Erreur serveur"</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">"items"</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"><</span>XxxState<span class="op">>,</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"><</span>ListParams<span class="op">>,</span></span>
|
||||
<span id="cb10-18"><a href="#cb10-18" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>ItemList<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></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">"/items/{id}"</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">"id"</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">"ID unique de l'item"</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">"Item trouvé"</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">"Item non trouvé"</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">"Erreur serveur"</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">"items"</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"><</span>XxxState<span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||
<span id="cb10-48"><a href="#cb10-48" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>ItemInfo<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></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">&</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">"Item {} not found"</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">"/items"</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">"Item créé"</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">"Requête invalide"</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">"Erreur serveur"</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">"items"</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"><</span>XxxState<span class="op">>,</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"><</span>CreateItemRequest<span class="op">>,</span></span>
|
||||
<span id="cb10-74"><a href="#cb10-74" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>(StatusCode<span class="op">,</span> Json<span class="op"><</span>ItemInfo<span class="op">></span>)<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></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">"/items/{id}"</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">"id"</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">"ID unique de l'item"</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">"Item supprimé"</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">"Item non trouvé"</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">"Erreur serveur"</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">"items"</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"><</span>XxxState<span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||
<span id="cb10-101"><a href="#cb10-101" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>StatusCode<span class="op">,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span>ErrorResponse<span class="op">></span>)<span class="op">></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">&</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 l’endpoint (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 d’endpoints
|
||||
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'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">"XXX API"</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">"1.0.0"</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#"</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'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'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&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> "#
|
||||
),
|
||||
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;</pre>
|
||||
<p><strong>Sections importantes</strong> : -
|
||||
<strong><code>info</code></strong> : Titre, version et description
|
||||
Markdown de l’API - <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 l’API avec
|
||||
OpenAPI</h3>
|
||||
<p>Dans l’implémentation du trait d’extension :</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">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Resource<span class="op">>></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">"/items"</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">"/items/{id}"</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">"xxx"</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"><</span><span class="dt">u32</span><span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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'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">"https://apps.radioparadise.com/blocks/chan/0/4/1234567-1234580.flac"</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'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">"/stream-url/{event_id}"</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">"event_id"</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">"Event ID du block"</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">"channel"</span> <span class="op">=</span> <span class="dt">Option</span><span class="op"><</span><span class="dt">u8</span><span class="op">>,</span> Query<span class="op">,</span> description <span class="op">=</span> <span class="st">"Channel ID (0-3)"</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">"URL de streaming"</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">"Erreur serveur"</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">"Radio Paradise"</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"><</span>RadioParadiseState<span class="op">>,</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"><</span><span class="dt">u64</span><span class="op">>,</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"><</span>ParadiseQuery<span class="op">>,</span></span>
|
||||
<span id="cb17-54"><a href="#cb17-54" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>StreamUrlResponse<span class="op">>,</span> StatusCode<span class="op">></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">&</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">"Failed to fetch block {}: {}"</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">"Radio Paradise API"</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">"1.0.0"</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">"API REST pour accéder aux métadonnées Radio Paradise"</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">"Radio Paradise"</span><span class="op">,</span> description <span class="op">=</span> <span class="st">"Endpoints Radio Paradise"</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>, l’API 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>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</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">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>State<span class="op">></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">"radioparadise"</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">&</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">&</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">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Cache<span class="op">>></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">"audio/flac"</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">"/"</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">"/"</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">"/{pk}"</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">"audio"</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">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>Manager<span class="op">>></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'é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">"/stream/{}/flac"</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">&</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"><</span>Arc<span class="op"><</span>StreamingState<span class="op">>>|</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">"/stream/{}/ogg"</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">&</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"><</span>Arc<span class="op"><</span>StreamingState<span class="op">>>|</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"><</span>ControlPointState<span class="op">></span></span>
|
||||
<span id="cb21-3"><a href="#cb21-3" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span>Summary<span class="op">>></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">&</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"><</span>ControlPointState<span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</span></span>
|
||||
<span id="cb22-6"><a href="#cb22-6" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Json<span class="op"><</span>Response<span class="op">>,</span> (StatusCode<span class="op">,</span> Json<span class="op"><</span><span class="bu">Error</span><span class="op">></span>)<span class="op">></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">&</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 d’attendre 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"><</span>XxxState<span class="op">>,</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"><</span>Request<span class="op">>,</span></span>
|
||||
<span id="cb23-4"><a href="#cb23-4" aria-hidden="true" tabindex="-1"></a>) <span class="op">-></span> Json<span class="op"><</span>Response<span class="op">></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">&</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'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">=></span> <span class="pp">debug!</span>(<span class="st">"Action completed"</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">=></span> <span class="pp">warn!</span>(<span class="st">"Action failed: {}"</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 d’implé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 l’enregistrer
|
||||
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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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">"pmoserver"</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"><</span>ExampleResource<span class="op">>,</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">"pmoserver"</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">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>ExampleResource<span class="op">>>;</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">"pmoserver"</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">&</span><span class="kw">mut</span> <span class="kw">self</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>Arc<span class="op"><</span>ExampleResource<span class="op">>></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">"/items"</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">"/api/example"</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">"pmoserver"</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"><</span>ExampleState<span class="op">></span>) <span class="op">-></span> Json<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>></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 d’erreurs 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>
|
||||
1034
Blackboard_HTML/Done_Pinnable_cache_item.html
Normal file
1034
Blackboard_HTML/Done_Pinnable_cache_item.html
Normal file
File diff suppressed because it is too large
Load Diff
209
Blackboard_HTML/Done_WeabApp_debouncingSSE.html
Normal file
209
Blackboard_HTML/Done_WeabApp_debouncingSSE.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!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
|
||||
l’application 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>J’ai identifié trois endroits avec des mécanismes de temporisation
|
||||
dans l’application 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 n’est PAS du débouncing, c’est
|
||||
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
|
||||
l’utilisateur fait glisser le curseur</li>
|
||||
<li><strong>Justification</strong>: Débouncing légitime pour l’interface
|
||||
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"><</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">></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"><</span><span class="dt">number</span><span class="op">></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'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 "Lire maintenant" 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">=></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">=></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">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</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"><</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">=></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 "Lire maintenant" 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">=></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">=></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'on n'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">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&&</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 l’invalidation du
|
||||
cache</li>
|
||||
<li><strong>Impact</strong>: Réactivité améliorée de l’interface, 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 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.</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>
|
||||
518
Blackboard_HTML/Report_Pinnable_cache_item.html
Normal file
518
Blackboard_HTML/Report_Pinnable_cache_item.html
Normal file
@@ -0,0 +1,518 @@
|
||||
<!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é 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.</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
|
||||
l’item est épinglé</li>
|
||||
<li><strong><code>ttl_expires_at</code></strong> : Date/heure
|
||||
d’expiration 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"><</span><span class="dt">String</span><span class="op">>,</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: &str)</code></strong> : Épingle un
|
||||
item</p>
|
||||
<ul>
|
||||
<li>Vérifie que l’item n’a 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: &str)</code></strong> : Désépingle un
|
||||
item</p></li>
|
||||
<li><p><strong><code>is_pinned(pk: &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: &str, expires_at: &str)</code></strong>
|
||||
: Définit le TTL d’un item</p>
|
||||
<ul>
|
||||
<li>Vérifie que l’item n’est pas épinglé (règle métier)</li>
|
||||
<li>Retourne une erreur si l’item est épinglé</li>
|
||||
</ul></li>
|
||||
<li><p><strong><code>clear_ttl(pk: &str)</code></strong> : Supprime
|
||||
le TTL d’un 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">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">bool</span><span class="op">></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">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span><span class="op">,</span> expires_at<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">&</span><span class="kw">self</span><span class="op">,</span> pk<span class="op">:</span> <span class="op">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">></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 d’une 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 d’utilisation</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"><</span>MyConfig<span class="op">></span><span class="pp">::</span>new(<span class="st">"./cache"</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">"https://example.com/file.dat"</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'é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">&</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">&</span>pk2<span class="op">,</span> <span class="op">&</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">&</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">"Fichier protégé"</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 l’API 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">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||
<span id="cb6-6"><a href="#cb6-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"pinned"</span><span class="ex">:</span> false,</span>
|
||||
<span id="cb6-7"><a href="#cb6-7" aria-hidden="true" tabindex="-1"></a> <span class="st">"ttl_expires_at"</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">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||
<span id="cb7-6"><a href="#cb7-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Item '1a2b3c4d5e6f7a8b' pinned successfully"</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">"error"</span><span class="ex">:</span> <span class="st">"CONFLICT"</span>,</span>
|
||||
<span id="cb7-12"><a href="#cb7-12" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Cannot pin an item with TTL set. Clear TTL first."</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">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||
<span id="cb8-6"><a href="#cb8-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Item '1a2b3c4d5e6f7a8b' unpinned successfully"</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">"expires_at"</span><span class="ex">:</span> <span class="st">"2025-01-20T10:30:00Z"</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">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||
<span id="cb9-11"><a href="#cb9-11" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"TTL set successfully for item '1a2b3c4d5e6f7a8b'"</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">"error"</span><span class="ex">:</span> <span class="st">"CONFLICT"</span>,</span>
|
||||
<span id="cb9-17"><a href="#cb9-17" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Cannot set TTL on a pinned item. Unpin first."</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">"error"</span><span class="ex">:</span> <span class="st">"INVALID_DATE"</span>,</span>
|
||||
<span id="cb9-23"><a href="#cb9-23" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"Invalid RFC3339 date format"</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">"pk"</span><span class="ex">:</span> <span class="st">"1a2b3c4d5e6f7a8b"</span>,</span>
|
||||
<span id="cb10-6"><a href="#cb10-6" aria-hidden="true" tabindex="-1"></a> <span class="st">"message"</span><span class="ex">:</span> <span class="st">"TTL cleared successfully for item '1a2b3c4d5e6f7a8b'"</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
|
||||
l’API 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 d’API :
|
||||
<ul>
|
||||
<li><code>get_pin_status()</code> : Récupération du statut de
|
||||
pinning</li>
|
||||
<li><code>pin_item()</code> : Épinglage d’un item</li>
|
||||
<li><code>unpin_item()</code> : Désépinglage d’un 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 l’API</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 d’un 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 d’un item (expiration automatique)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>DELETE</code></td>
|
||||
<td><code>/{pk}/ttl</code></td>
|
||||
<td>Supprime le TTL d’un 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 d’usage</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 d’opé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 d’erreur possibles</li>
|
||||
</ul></li>
|
||||
<li><strong>Interface Swagger UI</strong> :
|
||||
<ul>
|
||||
<li>Accessible à <code>/swagger-ui/{cache_name}</code></li>
|
||||
<li>Permet de tester l’API directement depuis le navigateur</li>
|
||||
</ul></li>
|
||||
</ul>
|
||||
<h3 id="gestion-des-erreurs">Gestion des erreurs</h3>
|
||||
<p>L’API suit une structure d’erreur 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">"error"</span><span class="fu">:</span> <span class="st">"CODE_ERREUR"</span><span class="fu">,</span></span>
|
||||
<span id="cb11-3"><a href="#cb11-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"message"</span><span class="fu">:</span> <span class="st">"Description lisible de l'erreur"</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
|
||||
d’erreur explicites guidant l’utilisateur</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 d’usage<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 d’erreurs cohérente</strong> avec codes HTTP
|
||||
appropriés</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
209
Blackboard_HTML/Report_WeabApp_debouncingSSE.html
Normal file
209
Blackboard_HTML/Report_WeabApp_debouncingSSE.html
Normal file
@@ -0,0 +1,209 @@
|
||||
<!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
|
||||
l’application 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>J’ai identifié trois endroits avec des mécanismes de temporisation
|
||||
dans l’application 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 n’est PAS du débouncing, c’est
|
||||
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
|
||||
l’utilisateur fait glisser le curseur</li>
|
||||
<li><strong>Justification</strong>: Débouncing légitime pour l’interface
|
||||
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"><</span><span class="dt">number</span> <span class="op">|</span> <span class="dt">null</span><span class="op">></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"><</span><span class="dt">number</span><span class="op">></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'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 "Lire maintenant" 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">=></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">=></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">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</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"><</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">=></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 "Lire maintenant" 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">=></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">=></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'on n'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">&&</span> props<span class="op">.</span><span class="at">containerId</span> <span class="op">&&</span> <span class="op">!</span>loading<span class="op">.</span><span class="at">value</span> <span class="op">&&</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 l’invalidation du
|
||||
cache</li>
|
||||
<li><strong>Impact</strong>: Réactivité améliorée de l’interface, 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 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.</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>
|
||||
152
Blackboard_HTML/Report_config_ext.html
Normal file
152
Blackboard_HTML/Report_config_ext.html
Normal file
@@ -0,0 +1,152 @@
|
||||
<!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 d’extension pmoconfig</h1>
|
||||
<h2 id="objectif-de-la-tâche">Objectif de la tâche</h2>
|
||||
<p>Créer une fiche descriptive documentant le pattern d’implémentation
|
||||
des traits d’extension 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 d’extension 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 d’ensemble</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
|
||||
d’usage</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
|
||||
d’implémentation complète - Patterns pour tous les cas d’usage
|
||||
identifiés - Bonnes pratiques et anti-patterns - Checklist
|
||||
d’implé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 l’objectif principal</li>
|
||||
<li>Documentation prête pour classification (Done/ToDiscuss) par
|
||||
l’humain</li>
|
||||
</ul>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
238
Blackboard_HTML/Report_music_source.html
Normal file
238
Blackboard_HTML/Report_music_source.html
Normal file
@@ -0,0 +1,238 @@
|
||||
<!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 d’implémentation d’une nouvelle MusicSource</h1>
|
||||
<h2 id="objectif">Objectif</h2>
|
||||
<p>Créer une documentation complète et pratique pour guider
|
||||
l’implémentation d’une 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>J’ai 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 d’ensemble 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"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>></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"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>></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"><</span>Mutex<span class="op"><</span><span class="dt">Vec</span><span class="op"><</span><span class="dt">u64</span><span class="op">>>></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"><</span>Arc<span class="op"><</span><span class="kw">dyn</span> <span class="bu">Fn</span>(<span class="op">&</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">>></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 l’audio à
|
||||
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 d’ensemble</li>
|
||||
<li>Structure d’une MusicSource</li>
|
||||
<li>Implémentation du trait MusicSource</li>
|
||||
<li>Patterns d’implé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 d’ensemble</strong> - Définition d’une
|
||||
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 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</p>
|
||||
<p><strong>Section 7 : Exemples de référence</strong> - Radio Paradise
|
||||
(source dynamique FIFO) - Qobuz (source catalogue lazy) - Schemas
|
||||
d’Object 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 d’Object ID</h4>
|
||||
<p>Format recommandé hiérarchique :</p>
|
||||
<pre><code><source-id>
|
||||
<source-id>:albums
|
||||
<source-id>:album:<album_id>
|
||||
<source-id>:track:<track_id>
|
||||
<source-id>:playlist:<playlist_id></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 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)</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<RwLock<>></code> pour
|
||||
état mutable partagé - <code>tokio::sync::RwLock</code> pour async -
|
||||
Éviter <code>Rc<></code>, <code>RefCell</code> (non thread-safe) -
|
||||
Implémenter <code>Clone</code> via <code>Arc<></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 d’exemple 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
|
||||
d’adaptation</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 à d’autres sources</li>
|
||||
</ul>
|
||||
<h3 id="cas-dusage-couverts">Cas d’usage 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 d’exemple détaillé de search
|
||||
(optionnel dans le trait)</li>
|
||||
<li><strong>Authentification</strong> : Mentionné mais pas d’exemple
|
||||
complet</li>
|
||||
<li><strong>Multi-format</strong> : Pas d’exemple 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 l’intégration avec un système d’auth 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 d’Object 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
|
||||
s’inspirant des patterns éprouvés de Radio Paradise et Qobuz.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
120
Blackboard_HTML/Report_pmoserver_ext.html
Normal file
120
Blackboard_HTML/Report_pmoserver_ext.html
Normal file
@@ -0,0 +1,120 @@
|
||||
<!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 d’extension 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 l’anatomie du pattern en 5 étapes - Ajout d’une checklist et
|
||||
d’un exemple minimal</p>
|
||||
<p><strong>Résultat</strong> : Document focalisé sur l’implé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
|
||||
d’important. Remets le.”</p>
|
||||
<p><strong>Actions</strong> : - 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 <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 l’implémentation.</p>
|
||||
<h2 id="structure-finale-du-document">Structure finale du document</h2>
|
||||
<ol type="1">
|
||||
<li><strong>Vue d’ensemble</strong> : Principe du pattern</li>
|
||||
<li><strong>Anatomie d’une 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 d’implé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
|
||||
à l’implé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>
|
||||
54
Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html
Normal file
54
Blackboard_HTML/ToDiscuss_Pinnable_cache_item.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!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. 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.</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
|
||||
qu’on 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 n’y a pas à prévoir de phase de transition. Nous sommes en
|
||||
période de développement.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
59
Blackboard_HTML/ToDiscuss_pmoserver_ext.html
Normal file
59
Blackboard_HTML/ToDiscuss_pmoserver_ext.html
Normal file
@@ -0,0 +1,59 @@
|
||||
<!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 d’extension du PMO serveur.</p>
|
||||
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||
dans le fichier:
|
||||
<code>Blackboard/Architecture/pmoserver_ext.md</code></p>
|
||||
<h2 id="round-2">Round 2</h2>
|
||||
<p>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.</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 d’important. Remets le.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
563
Blackboard_HTML/ToThinkAbout_MusicBoxSource.html
Normal file
563
Blackboard_HTML/ToThinkAbout_MusicBoxSource.html
Normal file
@@ -0,0 +1,563 @@
|
||||
<!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 n’importe 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 n’importe 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 --> 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</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 "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
|
||||
}</pre>
|
||||
<h3 id="tables-dassociation">Tables d’association</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 & 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 à l’initialisation :</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 d’esprit, é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> : L’utilisateur 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 d’import</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->>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)</pre>
|
||||
<h3 id="import-url-directe">Import URL directe</h3>
|
||||
<pre class="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</pre>
|
||||
<h3 id="import-playlist-jspfm3u8">Import playlist JSPF/M3U8</h3>
|
||||
<pre class="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]</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">"include_all_tags"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"mood:energetic"</span><span class="ot">,</span> <span class="st">"genre:rock"</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">"exclude_tags"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"mood:melancholic"</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">"year_min"</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">"year_max"</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">"min_rating"</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">"lossless_only"</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">"order_by"</span><span class="fu">:</span> <span class="st">"play_count"</span><span class="fu">,</span></span>
|
||||
<span id="cb9-9"><a href="#cb9-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">"order"</span><span class="fu">:</span> <span class="st">"desc"</span><span class="fu">,</span></span>
|
||||
<span id="cb9-10"><a href="#cb9-10" aria-hidden="true" tabindex="-1"></a> <span class="dt">"limit"</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">'mood:energetic'</span>, <span class="st">'genre:rock'</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">'mood:melancholic'</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">>=</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">'flac'</span>, <span class="st">'alac'</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/] --> 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</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 d’implé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 d’usage</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(&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>
|
||||
539
Blackboard_HTML/ToThinkAbout_PlayListSource.html
Normal file
539
Blackboard_HTML/ToThinkAbout_PlayListSource.html
Normal file
@@ -0,0 +1,539 @@
|
||||
<!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] --> 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]</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">"container"</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">"title"</span><span class="fu">:</span> <span class="st">"Collection Jazz"</span><span class="fu">,</span></span>
|
||||
<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"description"</span><span class="fu">:</span> <span class="st">"Mes playlists jazz favorites"</span><span class="fu">,</span></span>
|
||||
<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">"creator"</span><span class="fu">:</span> <span class="st">"John Doe"</span><span class="fu">,</span></span>
|
||||
<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"image"</span><span class="fu">:</span> <span class="st">"covers/jazz-collection.webp"</span><span class="fu">,</span></span>
|
||||
<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">"date"</span><span class="fu">:</span> <span class="st">"2026-01-15"</span><span class="fu">,</span></span>
|
||||
<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> <span class="dt">"meta"</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">"rel"</span><span class="fu">:</span> <span class="st">"genre"</span><span class="fu">,</span> <span class="dt">"content"</span><span class="fu">:</span> <span class="st">"Jazz"</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">"rel"</span><span class="fu">:</span> <span class="st">"mood"</span><span class="fu">,</span> <span class="dt">"content"</span><span class="fu">:</span> <span class="st">"Relaxing"</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">"camelCase"</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Vec::is_empty"</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"><</span>JspfAttribution<span class="op">>,</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">"Vec::is_empty"</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"><</span>JspfMeta<span class="op">>,</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"><</span>JspfTrack<span class="op">>,</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">"camelCase"</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">"Vec::is_empty"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Vec::is_empty"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">String</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">u32</span><span class="op">>,</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">"Option::is_none"</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"><</span><span class="dt">u64</span><span class="op">>,</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">"Vec::is_empty"</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"><</span>JspfMeta<span class="op">>,</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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</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">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</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"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(path<span class="op">:</span> P) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></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">"JSON: {}"</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><playlist></code>, <code><track></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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></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: <title> → playlist.title, <track> → 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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">></span> <span class="op">{</span></span>
|
||||
<span id="cb12-5"><a href="#cb12-5" aria-hidden="true" tabindex="-1"></a> <span class="co">// HashMap<index, (file, title, duration)></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"><</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">></span>(jspf<span class="op">:</span> <span class="op">&</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</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"><</span>W<span class="op">:</span> <span class="bu">Write</span><span class="op">></span>(jspf<span class="op">:</span> <span class="op">&</span>Jspf<span class="op">,</span> writer<span class="op">:</span> W) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</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">&</span>Jspf) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</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">&</span>Jspf) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</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">&</span><span class="dt">str</span>) <span class="op">-></span> <span class="dt">Option</span><span class="op"><</span><span class="dt">Self</span><span class="op">>;</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"><</span>R<span class="op">:</span> <span class="bu">Read</span><span class="op">></span>(reader<span class="op">:</span> R<span class="op">,</span> format<span class="op">:</span> PlaylistFormat) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>Jspf<span class="op">>;</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"><</span>RwLock<span class="op"><</span>HashMap<span class="op"><</span><span class="dt">String</span><span class="op">,</span> ParsedPlaylist<span class="op">>>>,</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"><</span>RwLock<span class="op"><</span>HashMap<span class="op"><</span><span class="dt">PathBuf</span><span class="op">,</span> ContainerMetadata<span class="op">>>>,</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"><</span><span class="pp">notify::</span>RecommendedWatcher<span class="op">>,</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"><</span>RwLock<span class="op"><</span><span class="dt">u32</span><span class="op">>>,</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"><</span>RwLock<span class="op"><</span>SystemTime<span class="op">>>,</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"><</span>PlaylistTrack<span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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"><</span><span class="dt">String</span><span class="op">>,</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"><</span>MetaEntry<span class="op">>,</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">&</span><span class="kw">self</span><span class="op">,</span> dir_path<span class="op">:</span> <span class="op">&</span><span class="dt">Path</span>) <span class="op">-></span> <span class="dt">Result</span><span class="op"><</span>ContainerMetadata<span class="op">></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">"metadata.json"</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">&</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">&</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">&</span><span class="dt">str</span> <span class="op">=</span> <span class="st">"playlists"</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">&</span><span class="kw">self</span>) <span class="op">-></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"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</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">&</span><span class="kw">self</span>) <span class="op">-></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">&</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">>;</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">&</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">>;</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">&</span><span class="kw">self</span>) <span class="op">-></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">"sources.playlists.directory"</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">"Failed to get playlists directory"</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"><</span>P<span class="op">:</span> <span class="bu">AsRef</span><span class="op"><</span><span class="dt">Path</span><span class="op">>></span>(<span class="op">&</span><span class="kw">self</span><span class="op">,</span> path<span class="op">:</span> P) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">"sources.playlists.directory"</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">&</span><span class="kw">self</span>) <span class="op">-></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">"sources.playlists.enabled"</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">"sources.playlists.enabled"</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">&</span><span class="kw">self</span><span class="op">,</span> enabled<span class="op">:</span> <span class="dt">bool</span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">"sources.playlists.enabled"</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">&</span><span class="kw">self</span>) <span class="op">-></span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></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">"sources.playlists.formats"</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">"jspf"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"xspf"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"m3u8"</span><span class="op">.</span>into()<span class="op">,</span> <span class="st">"pls"</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">"sources.playlists.formats"</span><span class="op">,</span> <span class="op">&</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">&</span><span class="kw">self</span><span class="op">,</span> formats<span class="op">:</span> <span class="dt">Vec</span><span class="op"><</span><span class="dt">String</span><span class="op">></span>) <span class="op">-></span> <span class="pp">anyhow::</span><span class="dt">Result</span><span class="op"><</span>()<span class="op">></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">"sources.playlists.formats"</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">"playlists"</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 d’usage</h3>
|
||||
<ol type="1">
|
||||
<li><p><strong>Résolution d’identifiants</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">"identifier"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"musicbrainz://recording/abc123"</span><span class="ot">]</span><span class="fu">,</span> <span class="dt">"title"</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>
|
||||
55
Blackboard_HTML/Todo_config_ext.html
Normal file
55
Blackboard_HTML/Todo_config_ext.html
Normal file
@@ -0,0 +1,55 @@
|
||||
<!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 d’extension de PMOConfig (pmoconfig::Config).</p>
|
||||
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||
dans le fichier:
|
||||
<code>Blackboard/Architecture/pmoconfig_ext.md</code></p>
|
||||
<p>Reste bien focalisé sur l’objectif principal.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
52
Blackboard_HTML/Todo_music_source.html
Normal file
52
Blackboard_HTML/Todo_music_source.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!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 d’architecture L’implémentation d’une
|
||||
nouvelle MusicSource.</p>
|
||||
<p>Le résultat sera une documentation d’implémentation qui sera placé
|
||||
dans le fichier:
|
||||
<code>Blackboard/Architecture/music_source.md</code></p>
|
||||
<p>Reste bien focalisé sur l’objectif principal.</p>
|
||||
</article>
|
||||
</body>
|
||||
</html>
|
||||
40
Blackboard_HTML/index.html
Normal file
40
Blackboard_HTML/index.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!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>
|
||||
70
Cargo.lock
generated
70
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.1.0"
|
||||
version = "0.3.5"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
@@ -306,6 +306,17 @@ dependencies = [
|
||||
"rustix 1.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.110",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
@@ -2073,6 +2084,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -3631,7 +3648,7 @@ dependencies = [
|
||||
"pmoflac",
|
||||
"pmometadata",
|
||||
"pmoserver",
|
||||
"quick-xml 0.37.5",
|
||||
"quick-xml",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3666,6 +3683,8 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-http",
|
||||
"tracing",
|
||||
"utoipa",
|
||||
]
|
||||
@@ -3694,7 +3713,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-std",
|
||||
@@ -3710,7 +3729,8 @@ dependencies = [
|
||||
"pmodidl",
|
||||
"pmoserver",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"rand 0.9.2",
|
||||
"ratatui",
|
||||
"rust_cast",
|
||||
"rustls",
|
||||
@@ -3758,7 +3778,7 @@ dependencies = [
|
||||
"bevy_reflect",
|
||||
"bevy_reflect_derive",
|
||||
"pmoutils",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"utoipa",
|
||||
"utoipa-swagger-ui",
|
||||
@@ -3779,7 +3799,7 @@ dependencies = [
|
||||
"opus",
|
||||
"pmometadata",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -3793,7 +3813,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"pmodidl",
|
||||
"pmoupnp",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3816,10 +3836,10 @@ dependencies = [
|
||||
"pmosource",
|
||||
"pmoupnp",
|
||||
"pmoutils",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
@@ -3898,7 +3918,7 @@ dependencies = [
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3935,7 +3955,7 @@ dependencies = [
|
||||
"serde_yaml",
|
||||
"sha1",
|
||||
"tempfile",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
@@ -3987,7 +4007,7 @@ dependencies = [
|
||||
"pmoupnp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3999,6 +4019,8 @@ name = "pmoupnp"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"axum 0.8.7",
|
||||
"base64 0.22.1",
|
||||
"bevy_reflect",
|
||||
@@ -4017,7 +4039,7 @@ dependencies = [
|
||||
"pmoplaylist",
|
||||
"pmoserver",
|
||||
"pmoutils",
|
||||
"quick-xml 0.37.5",
|
||||
"quick-xml",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4040,7 +4062,7 @@ dependencies = [
|
||||
"get_if_addrs",
|
||||
"netstat2",
|
||||
"os_info",
|
||||
"quick-xml 0.38.4",
|
||||
"quick-xml",
|
||||
"sysinfo",
|
||||
"users",
|
||||
"xmltree 0.10.3",
|
||||
@@ -4324,16 +4346,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -5841,14 +5853,24 @@ checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower 0.5.2",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
33
Cargo.toml
33
Cargo.toml
@@ -21,3 +21,36 @@ members = [
|
||||
"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"
|
||||
|
||||
89
Makefile
89
Makefile
@@ -13,6 +13,7 @@ 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
|
||||
|
||||
@@ -193,6 +194,28 @@ 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)"
|
||||
@@ -204,3 +227,69 @@ coverage:
|
||||
$(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)"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.1.0"
|
||||
version = "0.3.5"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
@@ -16,8 +16,8 @@ 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"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = "0.3.20"
|
||||
axum = "0.8.4"
|
||||
serde_json = "1.0.145"
|
||||
|
||||
25
PMOMusic/Cargo.toml.backup
Normal file
25
PMOMusic/Cargo.toml.backup
Normal file
@@ -0,0 +1,25 @@
|
||||
[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"
|
||||
@@ -71,11 +71,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.await
|
||||
.expect("Failed to register MediaRenderer");
|
||||
|
||||
info!(
|
||||
"✅ MediaRenderer ready at {}{}",
|
||||
renderer_instance.base_url(),
|
||||
renderer_instance.description_route()
|
||||
);
|
||||
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()
|
||||
|
||||
38
blackboard-template.html
Normal file
38
blackboard-template.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!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>
|
||||
7
fix-mermaid.sh
Executable file
7
fix-mermaid.sh
Executable file
@@ -0,0 +1,7 @@
|
||||
#!/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"
|
||||
15
pmoapp/Cargo.toml.backup
Normal file
15
pmoapp/Cargo.toml.backup
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "pmoapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
rust-embed = "8.5.0"
|
||||
|
||||
[dependencies.pmoserver]
|
||||
path = "../pmoserver"
|
||||
optional = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
pmoserver = ["dep:pmoserver"]
|
||||
@@ -3,12 +3,17 @@
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>🎚️ Playlist Manager</h2>
|
||||
<p class="subtitle">Inspect pmoplaylist state, debug lazy PKs and tune capacities.</p>
|
||||
<p class="subtitle">
|
||||
Inspect pmoplaylist state, debug lazy PKs and tune
|
||||
capacities.
|
||||
</p>
|
||||
</div>
|
||||
<div class="header-stats">
|
||||
<span>{{ playlists.length }} playlists</span>
|
||||
<span>{{ totalTrackCount }} tracks</span>
|
||||
<span v-if="persistentCount > 0">{{ persistentCount }} persistent</span>
|
||||
<span v-if="persistentCount > 0"
|
||||
>{{ persistentCount }} persistent</span
|
||||
>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button @click="refreshPlaylists" :disabled="listLoading">
|
||||
@@ -24,21 +29,37 @@
|
||||
<form @submit.prevent="handleCreatePlaylist">
|
||||
<label class="field">
|
||||
<span>ID</span>
|
||||
<input v-model.trim="createForm.id" type="text" placeholder="unique id" required />
|
||||
<input
|
||||
v-model.trim="createForm.id"
|
||||
type="text"
|
||||
placeholder="unique id"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input v-model.trim="createForm.title" type="text" placeholder="Friendly label" />
|
||||
<input
|
||||
v-model.trim="createForm.title"
|
||||
type="text"
|
||||
placeholder="Friendly label"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Role</span>
|
||||
<select v-model="createForm.role">
|
||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
||||
<option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="createForm.role === 'custom'" class="field">
|
||||
<label
|
||||
v-if="createForm.role === 'custom'"
|
||||
class="field"
|
||||
>
|
||||
<span>Custom role</span>
|
||||
<input
|
||||
v-model.trim="createForm.customRole"
|
||||
@@ -54,14 +75,28 @@
|
||||
placeholder="cover cache pk"
|
||||
/>
|
||||
</label>
|
||||
<div class="cover-preview-row" v-if="createCoverPreview">
|
||||
<div
|
||||
class="cover-preview-row"
|
||||
v-if="createCoverPreview"
|
||||
>
|
||||
<div class="cover-preview small">
|
||||
<img :src="createCoverPreview" alt="Cover preview" loading="lazy" />
|
||||
<img
|
||||
:src="createCoverPreview"
|
||||
alt="Cover preview"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<span class="small muted">Preview sourced from /covers/image/{{ createForm.coverPk }}</span>
|
||||
<span class="small muted"
|
||||
>Preview sourced from /covers/image/{{
|
||||
createForm.coverPk
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="createForm.persistent" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="createForm.persistent"
|
||||
/>
|
||||
Persistent (stored in SQLite)
|
||||
</label>
|
||||
<div class="field-group">
|
||||
@@ -85,11 +120,23 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-messages">
|
||||
<p v-if="createState.error" class="error">❌ {{ createState.error }}</p>
|
||||
<p v-if="createState.success" class="success">✅ {{ createState.success }}</p>
|
||||
<p v-if="createState.error" class="error">
|
||||
❌ {{ createState.error }}
|
||||
</p>
|
||||
<p v-if="createState.success" class="success">
|
||||
✅ {{ createState.success }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="primary" :disabled="createState.busy">
|
||||
{{ createState.busy ? "Creating..." : "Create playlist" }}
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
:disabled="createState.busy"
|
||||
>
|
||||
{{
|
||||
createState.busy
|
||||
? "Creating..."
|
||||
: "Create playlist"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -97,9 +144,14 @@
|
||||
<div class="section-card list-section">
|
||||
<div class="section-header">
|
||||
<h3>📚 Playlists</h3>
|
||||
<span v-if="listError" class="error small">❌ {{ listError }}</span>
|
||||
<span v-if="listError" class="error small"
|
||||
>❌ {{ listError }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="listLoading && playlists.length === 0" class="empty-state">
|
||||
<div
|
||||
v-if="listLoading && playlists.length === 0"
|
||||
class="empty-state"
|
||||
>
|
||||
Loading playlists...
|
||||
</div>
|
||||
<div v-else-if="playlists.length === 0" class="empty-state">
|
||||
@@ -109,40 +161,94 @@
|
||||
<article
|
||||
v-for="playlist in sortedPlaylists"
|
||||
:key="playlist.id"
|
||||
:class="['playlist-card', { selected: playlist.id === selectedPlaylistId }]"
|
||||
:class="[
|
||||
'playlist-card',
|
||||
{
|
||||
selected:
|
||||
playlist.id === selectedPlaylistId,
|
||||
},
|
||||
]"
|
||||
@click="selectedPlaylistId = playlist.id"
|
||||
>
|
||||
<div class="card-title">
|
||||
<strong>{{ playlist.title || playlist.id }}</strong>
|
||||
<strong>{{
|
||||
playlist.title || playlist.id
|
||||
}}</strong>
|
||||
<small>#{{ playlist.id }}</small>
|
||||
</div>
|
||||
<div class="card-metrics">
|
||||
<span>{{ playlist.track_count }} tracks</span>
|
||||
<span>{{ formatRoleLabel(playlist.role) }}</span>
|
||||
<span v-if="playlist.persistent" class="pill">Persistent</span>
|
||||
<span v-else class="pill transient">Transient</span>
|
||||
<span>{{
|
||||
formatRoleLabel(playlist.role)
|
||||
}}</span>
|
||||
<span v-if="playlist.persistent" class="pill"
|
||||
>Persistent</span
|
||||
>
|
||||
<span v-else class="pill transient"
|
||||
>Transient</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="playlist.artist" class="card-artist">
|
||||
<span class="artist-label"
|
||||
>🎤 {{ playlist.artist }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>Max: {{ formatCapacity(playlist.max_size) }}</span>
|
||||
<span>TTL: {{ formatTtl(playlist.default_ttl_secs) }}</span>
|
||||
<span
|
||||
>Max:
|
||||
{{
|
||||
formatCapacity(playlist.max_size)
|
||||
}}</span
|
||||
>
|
||||
<span
|
||||
>TTL:
|
||||
{{
|
||||
formatTtl(playlist.default_ttl_secs)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>Updated {{ formatRelativeDate(playlist.last_change) }}</span>
|
||||
<span
|
||||
>Updated
|
||||
{{
|
||||
formatRelativeDate(playlist.last_change)
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
@click.stop="handleFlushPlaylist(playlist.id)"
|
||||
:disabled="flushState.busy && flushState.targetId === playlist.id"
|
||||
@click.stop="
|
||||
handleFlushPlaylist(playlist.id)
|
||||
"
|
||||
:disabled="
|
||||
flushState.busy &&
|
||||
flushState.targetId === playlist.id
|
||||
"
|
||||
>
|
||||
{{ flushState.busy && flushState.targetId === playlist.id ? "Flushing..." : "Flush" }}
|
||||
{{
|
||||
flushState.busy &&
|
||||
flushState.targetId === playlist.id
|
||||
? "Flushing..."
|
||||
: "Flush"
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
@click.stop="handleDeletePlaylist(playlist.id)"
|
||||
:disabled="deleteState.busy && deleteState.targetId === playlist.id"
|
||||
@click.stop="
|
||||
handleDeletePlaylist(playlist.id)
|
||||
"
|
||||
:disabled="
|
||||
deleteState.busy &&
|
||||
deleteState.targetId === playlist.id
|
||||
"
|
||||
>
|
||||
{{ deleteState.busy && deleteState.targetId === playlist.id ? "Deleting..." : "Delete" }}
|
||||
{{
|
||||
deleteState.busy &&
|
||||
deleteState.targetId === playlist.id
|
||||
? "Deleting..."
|
||||
: "Delete"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -153,42 +259,88 @@
|
||||
<section class="detail-panel">
|
||||
<div class="section-card detail-card">
|
||||
<template v-if="!selectedSummary">
|
||||
<div class="empty-state">Select a playlist to inspect details.</div>
|
||||
<div class="empty-state">
|
||||
Select a playlist to inspect details.
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="detail-header">
|
||||
<div class="detail-cover">
|
||||
<img v-if="summaryCoverUrl" :src="summaryCoverUrl" alt="Playlist cover" />
|
||||
<div v-else class="cover-placeholder large">🎵</div>
|
||||
<img
|
||||
v-if="summaryCoverUrl"
|
||||
:src="summaryCoverUrl"
|
||||
alt="Playlist cover"
|
||||
/>
|
||||
<div v-else class="cover-placeholder large">
|
||||
🎵
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-header-main">
|
||||
<h3>
|
||||
{{ detailTitle }}
|
||||
<span class="pill">{{ selectedSummary ? formatRoleLabel(selectedSummary.role) : "Unknown" }}</span>
|
||||
<span class="pill">{{
|
||||
selectedSummary
|
||||
? formatRoleLabel(
|
||||
selectedSummary.role,
|
||||
)
|
||||
: "Unknown"
|
||||
}}</span>
|
||||
</h3>
|
||||
<p class="detail-id">
|
||||
{{ detailId ? `#${detailId}` : "—" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="detail-header-actions">
|
||||
<button @click="loadSelectedPlaylist(false)" :disabled="detailLoading">
|
||||
{{ detailLoading ? "Reloading..." : "Reload" }}
|
||||
<button
|
||||
@click="loadSelectedPlaylist(false)"
|
||||
:disabled="detailLoading"
|
||||
>
|
||||
{{
|
||||
detailLoading
|
||||
? "Reloading..."
|
||||
: "Reload"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-stats">
|
||||
<span><strong>{{ selectedSummary ? selectedSummary.track_count : 0 }}</strong> tracks</span>
|
||||
<span
|
||||
><strong>{{
|
||||
selectedSummary
|
||||
? selectedSummary.track_count
|
||||
: 0
|
||||
}}</strong>
|
||||
tracks</span
|
||||
>
|
||||
<span>
|
||||
<strong>{{ formatCapacity(selectedSummary ? selectedSummary.max_size : undefined) }}</strong>
|
||||
<strong>{{
|
||||
formatCapacity(
|
||||
selectedSummary
|
||||
? selectedSummary.max_size
|
||||
: undefined,
|
||||
)
|
||||
}}</strong>
|
||||
capacity
|
||||
</span>
|
||||
<span>
|
||||
<strong>{{ formatTtl(selectedSummary ? selectedSummary.default_ttl_secs : undefined) }}</strong>
|
||||
<strong>{{
|
||||
formatTtl(
|
||||
selectedSummary
|
||||
? selectedSummary.default_ttl_secs
|
||||
: undefined,
|
||||
)
|
||||
}}</strong>
|
||||
TTL
|
||||
</span>
|
||||
<span>
|
||||
Last change:
|
||||
{{ selectedSummary ? formatRelativeDate(selectedSummary.last_change) : "—" }}
|
||||
{{
|
||||
selectedSummary
|
||||
? formatRelativeDate(
|
||||
selectedSummary.last_change,
|
||||
)
|
||||
: "—"
|
||||
}}
|
||||
</span>
|
||||
<span class="cover-stat">
|
||||
Cover:
|
||||
@@ -196,7 +348,10 @@
|
||||
<code>{{ selectedSummary.cover_pk }}</code>
|
||||
<button
|
||||
class="btn-secondary btn-compact"
|
||||
@click="selectedSummary?.cover_pk && copyPk(selectedSummary.cover_pk)"
|
||||
@click="
|
||||
selectedSummary?.cover_pk &&
|
||||
copyPk(selectedSummary.cover_pk)
|
||||
"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
@@ -207,12 +362,21 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="detail-messages">
|
||||
<p v-if="detailError" class="error">❌ {{ detailError }}</p>
|
||||
<p v-if="updateState.message" class="success">✅ {{ updateState.message }}</p>
|
||||
<p v-if="addTracksState.success" class="success">✅ {{ addTracksState.success }}</p>
|
||||
<p v-if="detailError" class="error">
|
||||
❌ {{ detailError }}
|
||||
</p>
|
||||
<p v-if="updateState.message" class="success">
|
||||
✅ {{ updateState.message }}
|
||||
</p>
|
||||
<p v-if="addTracksState.success" class="success">
|
||||
✅ {{ addTracksState.success }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading && !selectedPlaylist" class="empty-state">
|
||||
<div
|
||||
v-if="detailLoading && !selectedPlaylist"
|
||||
class="empty-state"
|
||||
>
|
||||
Loading playlist details...
|
||||
</div>
|
||||
<div v-else-if="selectedPlaylist">
|
||||
@@ -222,23 +386,43 @@
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>Title</span>
|
||||
<input v-model.trim="updateForm.title" type="text" />
|
||||
<input
|
||||
v-model.trim="updateForm.title"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Role</span>
|
||||
<select v-model="updateForm.role">
|
||||
<option v-for="option in roleOptions" :key="option.value" :value="option.value">
|
||||
<option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="updateForm.role === 'custom'" class="field">
|
||||
<label
|
||||
v-if="updateForm.role === 'custom'"
|
||||
class="field"
|
||||
>
|
||||
<span>Custom role</span>
|
||||
<input v-model.trim="updateForm.customRole" type="text" />
|
||||
<input
|
||||
v-model.trim="
|
||||
updateForm.customRole
|
||||
"
|
||||
type="text"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Max size</span>
|
||||
<input v-model="updateForm.maxSize" type="number" min="0" placeholder="Unlimited" />
|
||||
<input
|
||||
v-model="updateForm.maxSize"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Unlimited"
|
||||
/>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Default TTL (s)</span>
|
||||
@@ -260,8 +444,18 @@
|
||||
</label>
|
||||
<div class="cover-preview-row">
|
||||
<div class="cover-preview">
|
||||
<img v-if="updateCoverPreview" :src="updateCoverPreview" alt="Cover preview" loading="lazy" />
|
||||
<div v-else class="cover-placeholder small">No cover</div>
|
||||
<img
|
||||
v-if="updateCoverPreview"
|
||||
:src="updateCoverPreview"
|
||||
alt="Cover preview"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="cover-placeholder small"
|
||||
>
|
||||
No cover
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -272,10 +466,23 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-messages">
|
||||
<p v-if="updateState.error" class="error">❌ {{ updateState.error }}</p>
|
||||
<p
|
||||
v-if="updateState.error"
|
||||
class="error"
|
||||
>
|
||||
❌ {{ updateState.error }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="primary" :disabled="updateState.busy">
|
||||
{{ updateState.busy ? "Saving..." : "Apply changes" }}
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
:disabled="updateState.busy"
|
||||
>
|
||||
{{
|
||||
updateState.busy
|
||||
? "Saving..."
|
||||
: "Apply changes"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -284,7 +491,10 @@
|
||||
<h4>Add Tracks</h4>
|
||||
<form @submit.prevent="handleAddTracks">
|
||||
<label class="field">
|
||||
<span>Cache PKs (comma or newline separated)</span>
|
||||
<span
|
||||
>Cache PKs (comma or newline
|
||||
separated)</span
|
||||
>
|
||||
<textarea
|
||||
v-model.trim="addTracksForm.pks"
|
||||
rows="3"
|
||||
@@ -294,18 +504,39 @@
|
||||
<div class="field-group">
|
||||
<label class="field">
|
||||
<span>Force TTL (s)</span>
|
||||
<input v-model="addTracksForm.ttl" type="number" min="0" placeholder="Default" />
|
||||
<input
|
||||
v-model="addTracksForm.ttl"
|
||||
type="number"
|
||||
min="0"
|
||||
placeholder="Default"
|
||||
/>
|
||||
</label>
|
||||
<label class="checkbox">
|
||||
<input type="checkbox" v-model="addTracksForm.lazy" />
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="addTracksForm.lazy"
|
||||
/>
|
||||
Insert as lazy references
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-messages">
|
||||
<p v-if="addTracksState.error" class="error">❌ {{ addTracksState.error }}</p>
|
||||
<p
|
||||
v-if="addTracksState.error"
|
||||
class="error"
|
||||
>
|
||||
❌ {{ addTracksState.error }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="primary" :disabled="addTracksState.busy">
|
||||
{{ addTracksState.busy ? "Adding..." : "Add tracks" }}
|
||||
<button
|
||||
type="submit"
|
||||
class="primary"
|
||||
:disabled="addTracksState.busy"
|
||||
>
|
||||
{{
|
||||
addTracksState.busy
|
||||
? "Adding..."
|
||||
: "Add tracks"
|
||||
}}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -313,13 +544,25 @@
|
||||
<div class="section-subcard tracks">
|
||||
<div class="section-header">
|
||||
<h4>Tracks</h4>
|
||||
<span v-if="selectedPlaylist.tracks.length === 0" class="small">Empty playlist</span>
|
||||
<span
|
||||
v-if="
|
||||
selectedPlaylist.tracks.length === 0
|
||||
"
|
||||
class="small"
|
||||
>Empty playlist</span
|
||||
>
|
||||
<span v-else class="small">
|
||||
{{ selectedPlaylist.tracks.length }} entries
|
||||
<span v-if="lazyTracksCount > 0">· {{ lazyTracksCount }} lazy</span>
|
||||
{{ selectedPlaylist.tracks.length }}
|
||||
entries
|
||||
<span v-if="lazyTracksCount > 0"
|
||||
>· {{ lazyTracksCount }} lazy</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
<div class="track-grid" v-if="sortedTracks.length > 0">
|
||||
<div
|
||||
class="track-grid"
|
||||
v-if="sortedTracks.length > 0"
|
||||
>
|
||||
<article
|
||||
v-for="track in sortedTracks"
|
||||
:key="`${track.cache_pk}-${track.added_at}`"
|
||||
@@ -333,55 +576,148 @@
|
||||
:alt="trackTitle(track)"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">🎵</div>
|
||||
<span v-if="isLazyTrack(track) || trackLazyReference(track)" class="cover-lazy-pill">
|
||||
{{ isLazyTrack(track) ? "Lazy entry" : "Lazy ref" }}
|
||||
<div
|
||||
v-else
|
||||
class="cover-placeholder"
|
||||
>
|
||||
🎵
|
||||
</div>
|
||||
<span
|
||||
v-if="
|
||||
isLazyTrack(track) ||
|
||||
trackLazyReference(track)
|
||||
"
|
||||
class="cover-lazy-pill"
|
||||
>
|
||||
{{
|
||||
isLazyTrack(track)
|
||||
? "Lazy entry"
|
||||
: "Lazy ref"
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="track-info">
|
||||
<div class="track-title">
|
||||
{{ trackTitle(track) }}
|
||||
</div>
|
||||
<div class="track-artist" v-if="trackArtist(track)">
|
||||
<div
|
||||
class="track-artist"
|
||||
v-if="trackArtist(track)"
|
||||
>
|
||||
{{ trackArtist(track) }}
|
||||
</div>
|
||||
<div class="track-album" v-if="trackAlbum(track)">
|
||||
<div
|
||||
class="track-album"
|
||||
v-if="trackAlbum(track)"
|
||||
>
|
||||
{{ trackAlbum(track) }}
|
||||
</div>
|
||||
<div class="track-meta">
|
||||
<span v-if="trackDurationLabel(track)">
|
||||
{{ trackDurationLabel(track) }}
|
||||
<span
|
||||
v-if="
|
||||
trackDurationLabel(
|
||||
track,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
trackDurationLabel(
|
||||
track,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-if="trackSampleRateLabel(track)">
|
||||
{{ trackSampleRateLabel(track) }}
|
||||
<span
|
||||
v-if="
|
||||
trackSampleRateLabel(
|
||||
track,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{
|
||||
trackSampleRateLabel(
|
||||
track,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span v-if="trackBitrateLabel(track)">
|
||||
{{ trackBitrateLabel(track) }}
|
||||
<span
|
||||
v-if="
|
||||
trackBitrateLabel(track)
|
||||
"
|
||||
>
|
||||
{{
|
||||
trackBitrateLabel(track)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="pk-info">
|
||||
<div class="pk-line">
|
||||
<span class="label">PK</span>
|
||||
<code>{{ track.cache_pk }}</code>
|
||||
<span class="label"
|
||||
>PK</span
|
||||
>
|
||||
<code>{{
|
||||
track.cache_pk
|
||||
}}</code>
|
||||
</div>
|
||||
<div class="pk-line" v-if="trackLazyReference(track)">
|
||||
<span class="label">Lazy</span>
|
||||
<code>{{ trackLazyReference(track) }}</code>
|
||||
<div
|
||||
class="pk-line"
|
||||
v-if="
|
||||
trackLazyReference(
|
||||
track,
|
||||
)
|
||||
"
|
||||
>
|
||||
<span class="label"
|
||||
>Lazy</span
|
||||
>
|
||||
<code>{{
|
||||
trackLazyReference(
|
||||
track,
|
||||
)
|
||||
}}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="added-info">
|
||||
Added {{ formatRelativeDate(track.added_at) }} · TTL
|
||||
{{ trackTtlLabel(track.ttl_secs) }}
|
||||
Added
|
||||
{{
|
||||
formatRelativeDate(
|
||||
track.added_at,
|
||||
)
|
||||
}}
|
||||
· TTL
|
||||
{{
|
||||
trackTtlLabel(
|
||||
track.ttl_secs,
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-actions">
|
||||
<button class="btn-secondary" @click="copyPk(track.cache_pk)">Copy PK</button>
|
||||
<button
|
||||
class="btn-secondary"
|
||||
@click="copyPk(track.cache_pk)"
|
||||
>
|
||||
Copy PK
|
||||
</button>
|
||||
<button
|
||||
class="btn-danger"
|
||||
@click="handleRemoveTrack(track.cache_pk)"
|
||||
:disabled="isRemovingTrack(track.cache_pk)"
|
||||
@click="
|
||||
handleRemoveTrack(
|
||||
track.cache_pk,
|
||||
)
|
||||
"
|
||||
:disabled="
|
||||
isRemovingTrack(
|
||||
track.cache_pk,
|
||||
)
|
||||
"
|
||||
>
|
||||
{{ isRemovingTrack(track.cache_pk) ? "..." : "Remove" }}
|
||||
{{
|
||||
isRemovingTrack(
|
||||
track.cache_pk,
|
||||
)
|
||||
? "..."
|
||||
: "Remove"
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
@@ -397,7 +733,11 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { PlaylistDetail, PlaylistSummary, PlaylistTrack } from "../services/playlists";
|
||||
import type {
|
||||
PlaylistDetail,
|
||||
PlaylistSummary,
|
||||
PlaylistTrack,
|
||||
} from "../services/playlists";
|
||||
import {
|
||||
addTracksToPlaylist,
|
||||
createPlaylist,
|
||||
@@ -490,20 +830,27 @@ const deleteState = reactive({
|
||||
const removingTracks = ref(new Set<string>());
|
||||
|
||||
const totalTrackCount = computed(() =>
|
||||
playlists.value.reduce((sum, item) => sum + item.track_count, 0)
|
||||
playlists.value.reduce((sum, item) => sum + item.track_count, 0),
|
||||
);
|
||||
const persistentCount = computed(
|
||||
() => playlists.value.filter((p) => p.persistent).length,
|
||||
);
|
||||
const persistentCount = computed(() => playlists.value.filter((p) => p.persistent).length);
|
||||
|
||||
const selectedSummary = computed(() => {
|
||||
const detailSummary = selectedPlaylist.value?.summary;
|
||||
if (
|
||||
detailSummary &&
|
||||
(!selectedPlaylistId.value || detailSummary.id === selectedPlaylistId.value)
|
||||
(!selectedPlaylistId.value ||
|
||||
detailSummary.id === selectedPlaylistId.value)
|
||||
) {
|
||||
return detailSummary;
|
||||
}
|
||||
|
||||
return playlists.value.find((p) => p.id === selectedPlaylistId.value) ?? detailSummary ?? null;
|
||||
return (
|
||||
playlists.value.find((p) => p.id === selectedPlaylistId.value) ??
|
||||
detailSummary ??
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
const detailTitle = computed(() => {
|
||||
@@ -527,7 +874,9 @@ const summaryCoverUrl = computed(() => {
|
||||
|
||||
const sortedPlaylists = computed(() => {
|
||||
return [...playlists.value].sort(
|
||||
(a, b) => new Date(b.last_change).getTime() - new Date(a.last_change).getTime()
|
||||
(a, b) =>
|
||||
new Date(b.last_change).getTime() -
|
||||
new Date(a.last_change).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -535,17 +884,21 @@ const sortedTracks = computed(() => {
|
||||
const detail = selectedPlaylist.value;
|
||||
if (!detail) return [];
|
||||
return [...detail.tracks].sort(
|
||||
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
|
||||
(a, b) =>
|
||||
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
||||
);
|
||||
});
|
||||
|
||||
const lazyTracksCount = computed(() =>
|
||||
selectedPlaylist.value
|
||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track)).length
|
||||
: 0
|
||||
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
|
||||
.length
|
||||
: 0,
|
||||
);
|
||||
|
||||
const updateCoverPreview = computed(() => coverUrlFromPk(updateForm.coverPk) ?? summaryCoverUrl.value);
|
||||
const updateCoverPreview = computed(
|
||||
() => coverUrlFromPk(updateForm.coverPk) ?? summaryCoverUrl.value,
|
||||
);
|
||||
const createCoverPreview = computed(() => coverUrlFromPk(createForm.coverPk));
|
||||
|
||||
function formatRelativeDate(dateString: string) {
|
||||
@@ -622,7 +975,9 @@ async function refreshPlaylists() {
|
||||
}
|
||||
if (
|
||||
selectedPlaylistId.value &&
|
||||
!data.some((playlist) => playlist.id === selectedPlaylistId.value)
|
||||
!data.some(
|
||||
(playlist) => playlist.id === selectedPlaylistId.value,
|
||||
)
|
||||
) {
|
||||
selectedPlaylistId.value = first.id;
|
||||
}
|
||||
@@ -649,7 +1004,7 @@ watch(
|
||||
} else {
|
||||
selectedPlaylist.value = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function loadSelectedPlaylist(syncForms = true) {
|
||||
@@ -761,11 +1116,13 @@ function syncUpdateForm(detail: PlaylistDetail | null) {
|
||||
}
|
||||
updateForm.coverPk = detail.summary.cover_pk ?? "";
|
||||
updateForm.maxSize =
|
||||
detail.summary.max_size === null || detail.summary.max_size === undefined
|
||||
detail.summary.max_size === null ||
|
||||
detail.summary.max_size === undefined
|
||||
? ""
|
||||
: String(detail.summary.max_size);
|
||||
updateForm.defaultTtl =
|
||||
detail.summary.default_ttl_secs === null || detail.summary.default_ttl_secs === undefined
|
||||
detail.summary.default_ttl_secs === null ||
|
||||
detail.summary.default_ttl_secs === undefined
|
||||
? ""
|
||||
: String(detail.summary.default_ttl_secs);
|
||||
}
|
||||
@@ -791,7 +1148,10 @@ function parseOptionalNumber(value: string): number | undefined {
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function resolveRoleValue(roleValue: string, customValue: string): string | undefined {
|
||||
function resolveRoleValue(
|
||||
roleValue: string,
|
||||
customValue: string,
|
||||
): string | undefined {
|
||||
if (roleValue === "custom") {
|
||||
const trimmed = customValue.trim();
|
||||
return trimmed || undefined;
|
||||
@@ -818,7 +1178,8 @@ async function handleCreatePlaylist() {
|
||||
maxSize = parseOptionalNumber(createForm.maxSize) ?? undefined;
|
||||
}
|
||||
if (createForm.defaultTtl.trim()) {
|
||||
defaultTtl = parseOptionalNumber(createForm.defaultTtl) ?? undefined;
|
||||
defaultTtl =
|
||||
parseOptionalNumber(createForm.defaultTtl) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
createState.error = (error as Error).message;
|
||||
@@ -864,7 +1225,9 @@ async function handleUpdatePlaylist() {
|
||||
try {
|
||||
if (updateForm.maxSize.trim() === "") {
|
||||
maxSizePayload =
|
||||
summary.max_size === null || summary.max_size === undefined ? undefined : null;
|
||||
summary.max_size === null || summary.max_size === undefined
|
||||
? undefined
|
||||
: null;
|
||||
} else {
|
||||
const value = parseOptionalNumber(updateForm.maxSize);
|
||||
if (value !== summary.max_size) {
|
||||
@@ -874,7 +1237,8 @@ async function handleUpdatePlaylist() {
|
||||
|
||||
if (updateForm.defaultTtl.trim() === "") {
|
||||
defaultTtlPayload =
|
||||
summary.default_ttl_secs === null || summary.default_ttl_secs === undefined
|
||||
summary.default_ttl_secs === null ||
|
||||
summary.default_ttl_secs === undefined
|
||||
? undefined
|
||||
: null;
|
||||
} else {
|
||||
@@ -894,7 +1258,10 @@ async function handleUpdatePlaylist() {
|
||||
payload.title = trimmedTitle;
|
||||
}
|
||||
|
||||
const resolvedRole = resolveRoleValue(updateForm.role, updateForm.customRole);
|
||||
const resolvedRole = resolveRoleValue(
|
||||
updateForm.role,
|
||||
updateForm.customRole,
|
||||
);
|
||||
if (updateForm.role === "custom" && !updateForm.customRole.trim()) {
|
||||
updateState.error = "Provide a custom role name";
|
||||
return;
|
||||
@@ -989,7 +1356,10 @@ async function handleRemoveTrack(cachePk: string) {
|
||||
if (!selectedSummary.value || !cachePk) return;
|
||||
removingTracks.value.add(cachePk);
|
||||
try {
|
||||
const detail = await removeTrackFromPlaylist(selectedSummary.value.id, cachePk);
|
||||
const detail = await removeTrackFromPlaylist(
|
||||
selectedSummary.value.id,
|
||||
cachePk,
|
||||
);
|
||||
selectedPlaylist.value = detail;
|
||||
await refreshPlaylists();
|
||||
} catch (error) {
|
||||
@@ -1164,7 +1534,9 @@ onMounted(() => {
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.playlist-card.selected {
|
||||
@@ -1188,6 +1560,16 @@ onMounted(() => {
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
.card-artist {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.artist-label {
|
||||
font-size: 0.85rem;
|
||||
color: #90caf9;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1590,8 +1972,4 @@ button:disabled {
|
||||
color: #6adf8b;
|
||||
}
|
||||
</style>
|
||||
.detail-header-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.detail-header-main { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
|
||||
@@ -1,47 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { Folder, Music } from 'lucide-vue-next'
|
||||
import ActionMenu from './ActionMenu.vue'
|
||||
import { computed } from "vue";
|
||||
import type { ContainerEntry } from "@/services/pmocontrol/types";
|
||||
import { Folder, Music } from "lucide-vue-next";
|
||||
import ActionMenu from "./ActionMenu.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
entry: ContainerEntry
|
||||
serverId: string
|
||||
showActions?: boolean
|
||||
}>()
|
||||
entry: ContainerEntry;
|
||||
serverId: string;
|
||||
showActions?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
browse: [containerId: string]
|
||||
playNow: [containerId: string, rendererId: string]
|
||||
addToQueue: [containerId: string, rendererId: string]
|
||||
}>()
|
||||
browse: [containerId: string];
|
||||
playNow: [containerId: string, rendererId: string];
|
||||
addToQueue: [containerId: string, rendererId: string];
|
||||
}>();
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return Music
|
||||
if (cls.includes('album')) return Music
|
||||
return Folder
|
||||
})
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
if (cls.includes("playlist")) return Music;
|
||||
if (cls.includes("album")) return Music;
|
||||
return Folder;
|
||||
});
|
||||
|
||||
const containerType = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase()
|
||||
if (cls.includes('playlist')) return 'Playlist'
|
||||
if (cls.includes('album')) return 'Album'
|
||||
if (cls.includes('artist')) return 'Artiste'
|
||||
if (cls.includes('genre')) return 'Genre'
|
||||
return 'Dossier'
|
||||
})
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
if (cls.includes("playlist")) return "Playlist";
|
||||
if (cls.includes("album")) return "Album";
|
||||
if (cls.includes("artist")) return "Artiste";
|
||||
if (cls.includes("genre")) return "Genre";
|
||||
return "Dossier";
|
||||
});
|
||||
|
||||
const isPlayable = computed(() => {
|
||||
const cls = props.entry.class.toLowerCase();
|
||||
return cls.includes("playlist") || cls.includes("album");
|
||||
});
|
||||
|
||||
function handleBrowse() {
|
||||
emit('browse', props.entry.id)
|
||||
emit("browse", props.entry.id);
|
||||
}
|
||||
|
||||
function handlePlayNow(rendererId: string) {
|
||||
emit('playNow', props.entry.id, rendererId)
|
||||
emit("playNow", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleAddToQueue(rendererId: string) {
|
||||
emit('addToQueue', props.entry.id, rendererId)
|
||||
emit("addToQueue", props.entry.id, rendererId);
|
||||
}
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
const placeholder = img.nextElementSibling;
|
||||
if (placeholder && placeholder instanceof HTMLElement) {
|
||||
placeholder.style.display = "flex";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -49,15 +63,45 @@ function handleAddToQueue(rendererId: string) {
|
||||
<div class="container-item">
|
||||
<!-- Main content (clickable) -->
|
||||
<button class="container-content" @click="handleBrowse">
|
||||
<div class="container-icon">
|
||||
<component :is="iconComponent" :size="24" />
|
||||
<!-- Cover avec icône de type en overlay -->
|
||||
<div class="container-cover">
|
||||
<img
|
||||
v-if="entry.album_art_uri"
|
||||
:src="entry.album_art_uri"
|
||||
:alt="entry.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
<div
|
||||
class="cover-placeholder"
|
||||
:style="{
|
||||
display: entry.album_art_uri ? 'none' : 'flex',
|
||||
}"
|
||||
>
|
||||
<component :is="iconComponent" :size="28" />
|
||||
</div>
|
||||
<!-- Petite icône de type dans le coin inférieur droit -->
|
||||
<div v-if="isPlayable" class="type-badge">
|
||||
<Folder :size="14" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Métadonnées -->
|
||||
<div class="container-metadata">
|
||||
<div class="container-title">{{ entry.title }}</div>
|
||||
<div class="container-details">
|
||||
<span v-if="entry.artist" class="container-artist">{{
|
||||
entry.artist
|
||||
}}</span>
|
||||
<span class="container-type">{{ containerType }}</span>
|
||||
<span v-if="entry.child_count !== null" class="container-count">
|
||||
{{ entry.child_count }} élément{{ entry.child_count > 1 ? 's' : '' }}
|
||||
<span
|
||||
v-if="entry.child_count !== null"
|
||||
class="container-count"
|
||||
>
|
||||
{{ entry.child_count }} élément{{
|
||||
entry.child_count > 1 ? "s" : ""
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,21 +149,57 @@ function handleAddToQueue(rendererId: string) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.container-icon {
|
||||
/* Cover avec image et icône de type */
|
||||
.container-cover {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--color-bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 4px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Métadonnées */
|
||||
.container-metadata {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.container-title {
|
||||
@@ -129,14 +209,21 @@ function handleAddToQueue(rendererId: string) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.container-details {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs) var(--spacing-sm);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.container-artist {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.container-type {
|
||||
@@ -144,7 +231,7 @@ function handleAddToQueue(rendererId: string) {
|
||||
}
|
||||
|
||||
.container-count::before {
|
||||
content: '•';
|
||||
content: "•";
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,78 +1,201 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef, ref } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import { Music, X } from 'lucide-vue-next'
|
||||
import { computed, toRef, ref } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import { Music, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const metadata = computed(() => state.value?.current_track)
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const uiStore = useUIStore();
|
||||
const metadata = computed(() => state.value?.current_track);
|
||||
const isSeeking = ref(false);
|
||||
const isDragging = ref(false);
|
||||
const dragProgress = ref(0);
|
||||
const seekTargetMs = ref<number | null>(null);
|
||||
|
||||
// État pour l'overlay de la cover
|
||||
const showCoverOverlay = ref(false)
|
||||
const showMetadata = ref(false)
|
||||
const showCoverOverlay = ref(false);
|
||||
const showMetadata = ref(false);
|
||||
|
||||
function openCoverOverlay() {
|
||||
if (hasCover.value) {
|
||||
showCoverOverlay.value = true
|
||||
showMetadata.value = false // Reset metadata visibility when opening
|
||||
showCoverOverlay.value = true;
|
||||
showMetadata.value = false; // Reset metadata visibility when opening
|
||||
}
|
||||
}
|
||||
|
||||
function closeCoverOverlay() {
|
||||
showCoverOverlay.value = false
|
||||
showMetadata.value = false
|
||||
showCoverOverlay.value = false;
|
||||
showMetadata.value = false;
|
||||
}
|
||||
|
||||
// Détecter le clic sur la partie basse de l'image
|
||||
function handleOverlayContentClick(event: MouseEvent) {
|
||||
const target = event.currentTarget as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const clickY = event.clientY - rect.top
|
||||
const height = rect.height
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const rect = target.getBoundingClientRect();
|
||||
const clickY = event.clientY - rect.top;
|
||||
const height = rect.height;
|
||||
|
||||
// Si le clic est dans le tiers inférieur, toggle les métadonnées
|
||||
if (clickY > height * 0.66) {
|
||||
showMetadata.value = !showMetadata.value
|
||||
showMetadata.value = !showMetadata.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Calcul du pourcentage de progression
|
||||
const progressPercent = computed(() => {
|
||||
const position = state.value?.position_ms
|
||||
const duration = state.value?.duration_ms
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100
|
||||
// Si on est en train de drag, utiliser la position du drag
|
||||
if (isDragging.value) {
|
||||
return dragProgress.value;
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
// Si on a une cible de seek active
|
||||
if (seekTargetMs.value !== null) {
|
||||
const currentPosition = state.value?.position_ms || 0;
|
||||
|
||||
// Si la position actuelle est proche de la cible (tolérance de 2 secondes)
|
||||
if (Math.abs(currentPosition - seekTargetMs.value) < 2000) {
|
||||
// On peut libérer le contrôle
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} else {
|
||||
// Sinon, continuer à afficher la position cible
|
||||
return dragProgress.value;
|
||||
}
|
||||
}
|
||||
|
||||
const position = state.value?.position_ms;
|
||||
const duration = state.value?.duration_ms;
|
||||
if (position && duration && duration > 0) {
|
||||
return (position / duration) * 100;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Formater la durée en MM:SS
|
||||
function formatTime(ms: number | null | undefined): string {
|
||||
if (!ms) return '--:--'
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`
|
||||
if (!ms) return "--:--";
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms))
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms))
|
||||
const currentTime = computed(() => formatTime(state.value?.position_ms));
|
||||
const totalTime = computed(() => formatTime(state.value?.duration_ms));
|
||||
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri)
|
||||
const hasCover = computed(() => !!metadata.value?.album_art_uri);
|
||||
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
img.style.display = 'none'
|
||||
const img = event.target as HTMLImageElement;
|
||||
img.style.display = "none";
|
||||
}
|
||||
|
||||
// Calculer le pourcentage à partir d'une position X dans la barre
|
||||
function calculateProgressFromX(
|
||||
clientX: number,
|
||||
progressBar: HTMLElement,
|
||||
): number {
|
||||
const rect = progressBar.getBoundingClientRect();
|
||||
const x = Math.max(0, Math.min(clientX - rect.left, rect.width));
|
||||
return (x / rect.width) * 100;
|
||||
}
|
||||
|
||||
// Gestion du drag sur la progress bar
|
||||
function handleProgressBarMouseDown(event: MouseEvent) {
|
||||
const duration = state.value?.duration_ms;
|
||||
if (!duration || duration === 0 || isSeeking.value) return;
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement;
|
||||
isDragging.value = true;
|
||||
dragProgress.value = calculateProgressFromX(event.clientX, progressBar);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
dragProgress.value = calculateProgressFromX(e.clientX, progressBar);
|
||||
};
|
||||
|
||||
const handleMouseUp = async (e: MouseEvent) => {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
const finalPercent = calculateProgressFromX(e.clientX, progressBar);
|
||||
const newPositionMs = (finalPercent / 100) * duration;
|
||||
const newPositionSeconds = Math.floor(newPositionMs / 1000);
|
||||
|
||||
// Garder dragProgress à jour et définir la cible
|
||||
dragProgress.value = finalPercent;
|
||||
seekTargetMs.value = newPositionMs;
|
||||
|
||||
// D'abord arrêter le drag pour éviter les mouvements pendant le seek
|
||||
isDragging.value = false;
|
||||
|
||||
try {
|
||||
isSeeking.value = true;
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} finally {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}
|
||||
|
||||
// Gestion du seek sur la progress bar (clic simple)
|
||||
async function handleProgressBarClick(event: MouseEvent) {
|
||||
// Ne rien faire si on vient de drag (géré par mouseup)
|
||||
if (isDragging.value) return;
|
||||
|
||||
const duration = state.value?.duration_ms;
|
||||
if (!duration || duration === 0 || isSeeking.value) return;
|
||||
|
||||
const progressBar = event.currentTarget as HTMLElement;
|
||||
const percent = calculateProgressFromX(event.clientX, progressBar);
|
||||
const newPositionMs = (percent / 100) * duration;
|
||||
const newPositionSeconds = Math.floor(newPositionMs / 1000);
|
||||
|
||||
// Définir la cible et la position visuelle
|
||||
dragProgress.value = percent;
|
||||
seekTargetMs.value = newPositionMs;
|
||||
|
||||
try {
|
||||
isSeeking.value = true;
|
||||
await api.seekTo(props.rendererId, newPositionSeconds);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(
|
||||
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
// En cas d'erreur, réinitialiser
|
||||
seekTargetMs.value = null;
|
||||
dragProgress.value = 0;
|
||||
} finally {
|
||||
isSeeking.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-track">
|
||||
<!-- Cover Art -->
|
||||
<div class="cover-container" :class="{ clickable: hasCover }" @click="openCoverOverlay">
|
||||
<div
|
||||
class="cover-container"
|
||||
:class="{ clickable: hasCover }"
|
||||
@click="openCoverOverlay"
|
||||
>
|
||||
<img
|
||||
v-if="hasCover"
|
||||
:src="metadata?.album_art_uri!"
|
||||
@@ -88,18 +211,25 @@ function handleImageError(event: Event) {
|
||||
|
||||
<!-- Metadata -->
|
||||
<div class="metadata">
|
||||
<h2 class="title">{{ metadata?.title || 'Aucun titre' }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || 'Artiste inconnu' }}</p>
|
||||
<h2 class="title">{{ metadata?.title || "Aucun titre" }}</h2>
|
||||
<p class="artist">{{ metadata?.artist || "Artiste inconnu" }}</p>
|
||||
<p class="album" v-if="metadata?.album">{{ metadata.album }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-bar"
|
||||
@click="handleProgressBarClick"
|
||||
@mousedown="handleProgressBarMouseDown"
|
||||
:class="{ seeking: isSeeking, dragging: isDragging }"
|
||||
>
|
||||
<div
|
||||
class="progress-bar-fill"
|
||||
:style="{ width: `${progressPercent}%` }"
|
||||
></div>
|
||||
>
|
||||
<div class="progress-bar-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="time-display">
|
||||
<span>{{ currentTime }}</span>
|
||||
@@ -110,9 +240,20 @@ function handleImageError(event: Event) {
|
||||
<!-- Overlay cover en grand avec effet glassmorphism -->
|
||||
<Teleport to="body">
|
||||
<Transition name="cover-overlay">
|
||||
<div v-if="showCoverOverlay" class="cover-overlay" @click="closeCoverOverlay">
|
||||
<div class="cover-overlay-content" @click.stop="handleOverlayContentClick">
|
||||
<button class="cover-overlay-close" @click="closeCoverOverlay" title="Fermer">
|
||||
<div
|
||||
v-if="showCoverOverlay"
|
||||
class="cover-overlay"
|
||||
@click="closeCoverOverlay"
|
||||
>
|
||||
<div
|
||||
class="cover-overlay-content"
|
||||
@click.stop="handleOverlayContentClick"
|
||||
>
|
||||
<button
|
||||
class="cover-overlay-close"
|
||||
@click="closeCoverOverlay"
|
||||
title="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
<img
|
||||
@@ -122,10 +263,15 @@ function handleImageError(event: Event) {
|
||||
class="cover-overlay-image"
|
||||
/>
|
||||
<Transition name="metadata-fade">
|
||||
<div v-if="metadata && showMetadata" class="cover-overlay-metadata">
|
||||
<div
|
||||
v-if="metadata && showMetadata"
|
||||
class="cover-overlay-metadata"
|
||||
>
|
||||
<h2>{{ metadata.title }}</h2>
|
||||
<p class="artist">{{ metadata.artist }}</p>
|
||||
<p v-if="metadata.album" class="album">{{ metadata.album }}</p>
|
||||
<p v-if="metadata.album" class="album">
|
||||
{{ metadata.album }}
|
||||
</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
@@ -151,7 +297,9 @@ function handleImageError(event: Event) {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-secondary);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
transition:
|
||||
transform 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.cover-container.clickable {
|
||||
@@ -208,6 +356,99 @@ function handleImageError(event: Event) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.15) 0%,
|
||||
rgba(255, 255, 255, 0.25) 50%,
|
||||
rgba(255, 255, 255, 0.15) 100%
|
||||
);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-bar:hover {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar.dragging {
|
||||
cursor: grabbing;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0.35) 50%,
|
||||
rgba(255, 255, 255, 0.2) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.progress-bar.seeking {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.progress-bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #059669 0%, #10b981 50%, #34d399 100%);
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s linear;
|
||||
z-index: 1;
|
||||
box-shadow: 0 0 8px rgba(16, 185, 129, 0.5);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.progress-bar.dragging .progress-bar-fill {
|
||||
transition: width 0s;
|
||||
box-shadow: 0 0 12px rgba(16, 185, 129, 0.8);
|
||||
}
|
||||
|
||||
.progress-bar-thumb {
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: white;
|
||||
border: 3px solid rgba(0, 0, 0, 0.8);
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 2px 8px rgba(0, 0, 0, 0.6),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.progress-bar:hover .progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.2);
|
||||
box-shadow:
|
||||
0 3px 12px rgba(0, 0, 0, 0.7),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-bar.dragging .progress-bar-thumb {
|
||||
transform: translateY(-50%) scale(1.3);
|
||||
cursor: grabbing;
|
||||
box-shadow:
|
||||
0 4px 16px rgba(0, 0, 0, 0.8),
|
||||
inset 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.time-display {
|
||||
@@ -346,7 +587,7 @@ function handleImageError(event: Event) {
|
||||
}
|
||||
|
||||
.cover-overlay-close:active {
|
||||
transform: scale(1.0) rotate(90deg);
|
||||
transform: scale(1) rotate(90deg);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
@@ -1,157 +1,136 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import Breadcrumb from './Breadcrumb.vue'
|
||||
import ContainerItem from './ContainerItem.vue'
|
||||
import MediaItem from './MediaItem.vue'
|
||||
import { Loader2 } from 'lucide-vue-next'
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import Breadcrumb from "./Breadcrumb.vue";
|
||||
import ContainerItem from "./ContainerItem.vue";
|
||||
import MediaItem from "./MediaItem.vue";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
containerId: string
|
||||
}>()
|
||||
serverId: string;
|
||||
containerId: string;
|
||||
}>();
|
||||
|
||||
const {
|
||||
getBrowseCached,
|
||||
browseContainer,
|
||||
currentPath: breadcrumbPath,
|
||||
loading,
|
||||
error
|
||||
} = useMediaServers()
|
||||
error,
|
||||
} = useMediaServers();
|
||||
|
||||
const {
|
||||
playContent,
|
||||
addToQueue,
|
||||
attachAndPlayPlaylist,
|
||||
attachPlaylist,
|
||||
} = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// 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 = 5000 // Ne pas recharger plus d'une fois toutes les 5 secondes
|
||||
// Flag pour gérer le rechargement automatique
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId)
|
||||
)
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
);
|
||||
|
||||
const containers = computed(() =>
|
||||
browseData.value?.entries.filter((e) => e.is_container) || []
|
||||
)
|
||||
const containers = computed(
|
||||
() => browseData.value?.entries.filter((e) => e.is_container) || [],
|
||||
);
|
||||
|
||||
const items = computed(() =>
|
||||
browseData.value?.entries.filter((e) => !e.is_container) || []
|
||||
)
|
||||
const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
// Charger le container au montage et quand containerId change
|
||||
watch(
|
||||
() => props.containerId,
|
||||
async (newContainerId) => {
|
||||
if (newContainerId) {
|
||||
await browseContainer(props.serverId, newContainerId)
|
||||
await browseContainer(props.serverId, newContainerId);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// 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
|
||||
// Le serveur contrôle déjà le flux SSE, pas besoin de debouncing côté client
|
||||
watch(
|
||||
() => browseData.value,
|
||||
(data) => {
|
||||
async (data) => {
|
||||
// Si browseData devient undefined alors que containerId est présent,
|
||||
// et qu'on n'est pas déjà en train de charger, planifier un rechargement
|
||||
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) {
|
||||
// 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é mais cooldown actif (${Math.round((REFRESH_COOLDOWN_MS - timeSinceLastRefresh) / 1000)}s restantes), rechargement ignoré`
|
||||
)
|
||||
return
|
||||
`[MediaBrowser] Cache invalidé pour ${props.serverId}/${props.containerId}, rechargement...`,
|
||||
);
|
||||
isRefreshing.value = true;
|
||||
await browseContainer(props.serverId, props.containerId, false);
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
|
||||
// Annuler tout timeout en cours
|
||||
if (refreshTimeoutId.value !== null) {
|
||||
clearTimeout(refreshTimeoutId.value)
|
||||
}
|
||||
|
||||
// Planifier le rechargement après 3 secondes
|
||||
// Cela permet de regrouper plusieurs événements SSE successifs
|
||||
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() // Enregistrer le moment du rechargement
|
||||
isRefreshing.value = false
|
||||
refreshTimeoutId.value = null
|
||||
}
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [containerId: string]
|
||||
}>()
|
||||
navigate: [containerId: string];
|
||||
}>();
|
||||
|
||||
function handleNavigate(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
emit("navigate", containerId);
|
||||
}
|
||||
|
||||
function handleBrowseContainer(containerId: string) {
|
||||
emit('navigate', containerId)
|
||||
emit("navigate", containerId);
|
||||
}
|
||||
|
||||
// Actions handlers pour les containers (playlists/albums)
|
||||
async function handlePlayContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachAndPlayPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Lecture de la playlist démarrée !')
|
||||
await attachAndPlayPlaylist(rendererId, props.serverId, containerId);
|
||||
uiStore.notifySuccess("Lecture de la playlist démarrée !");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture de la playlist: ${message}`)
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
uiStore.notifyError(
|
||||
`Erreur lors de la lecture de la playlist: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueContainer(containerId: string, rendererId: string) {
|
||||
try {
|
||||
await attachPlaylist(rendererId, props.serverId, containerId)
|
||||
uiStore.notifySuccess('Playlist attachée à la queue !')
|
||||
await attachPlaylist(rendererId, props.serverId, containerId);
|
||||
uiStore.notifySuccess("Playlist attachée à la queue !");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout de la playlist: ${message}`)
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
uiStore.notifyError(
|
||||
`Erreur lors de l'ajout de la playlist: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Actions handlers pour les items (tracks)
|
||||
async function handlePlayItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await playContent(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Lecture démarrée !')
|
||||
await playContent(rendererId, props.serverId, itemId);
|
||||
uiStore.notifySuccess("Lecture démarrée !");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de la lecture: ${message}`)
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
uiStore.notifyError(`Erreur lors de la lecture: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
try {
|
||||
await addToQueue(rendererId, props.serverId, itemId)
|
||||
uiStore.notifySuccess('Ajouté à la queue !')
|
||||
await addToQueue(rendererId, props.serverId, itemId);
|
||||
uiStore.notifySuccess("Ajouté à la queue !");
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Erreur inconnue'
|
||||
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`)
|
||||
const message = err instanceof Error ? err.message : "Erreur inconnue";
|
||||
uiStore.notifyError(`Erreur lors de l'ajout à la queue: ${message}`);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -174,7 +153,10 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
<!-- Error state -->
|
||||
<div v-else-if="error" class="browser-error">
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="btn btn-secondary" @click="browseContainer(serverId, containerId, false)">
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
@click="browseContainer(serverId, containerId, false)"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
@@ -213,7 +195,10 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!containers.length && !items.length" class="browser-empty">
|
||||
<div
|
||||
v-if="!containers.length && !items.length"
|
||||
class="browser-empty"
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, nextTick, toRef } from 'vue'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import QueueItem from './QueueItem.vue'
|
||||
import { Link } from 'lucide-vue-next'
|
||||
import type { QueueItem as QueueItemType } from '@/services/pmocontrol/types'
|
||||
import { computed, ref, watch, nextTick, toRef } from "vue";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import QueueItem from "./QueueItem.vue";
|
||||
import { Link } from "lucide-vue-next";
|
||||
import type { QueueItem as QueueItemType } from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
clickItem: [item: QueueItemType]
|
||||
}>()
|
||||
clickItem: [item: QueueItemType];
|
||||
}>();
|
||||
|
||||
const { queue, binding } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { queue, binding } = useRenderer(toRef(props, "rendererId"));
|
||||
|
||||
const isAttached = computed(() => !!binding.value)
|
||||
const isAttached = computed(() => !!binding.value);
|
||||
|
||||
const queueContainer = ref<HTMLElement | null>(null)
|
||||
const queueContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
function handleItemClick(item: QueueItemType) {
|
||||
emit('clickItem', item)
|
||||
emit("clickItem", item);
|
||||
}
|
||||
|
||||
// Auto-scroll vers la piste courante lors de l'ouverture
|
||||
watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
|
||||
await nextTick()
|
||||
const currentItem = queueContainer.value.querySelector('.queue-item.current')
|
||||
watch(
|
||||
() => queue.value?.current_index,
|
||||
async (currentIndex) => {
|
||||
if (
|
||||
currentIndex !== null &&
|
||||
currentIndex !== undefined &&
|
||||
queueContainer.value
|
||||
) {
|
||||
await nextTick();
|
||||
const currentItem = queueContainer.value.querySelector(
|
||||
".queue-item.current",
|
||||
);
|
||||
if (currentItem) {
|
||||
currentItem.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
currentItem.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,9 +62,7 @@ watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
<!-- Indicateur playlist attachée -->
|
||||
<div v-if="isAttached" class="binding-indicator">
|
||||
<Link :size="16" />
|
||||
<span class="binding-text">
|
||||
Attachée à une playlist
|
||||
</span>
|
||||
<span class="binding-text"> Attachée à une playlist </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -127,6 +138,14 @@ watch(() => queue.value?.current_index, async (currentIndex) => {
|
||||
padding-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
|
||||
.queue-list::after {
|
||||
content: "";
|
||||
display: block;
|
||||
height: 80px; /* Espace pour la barre fixe en bas */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.queue-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
108
pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue
Normal file
108
pmoapp/webapp/src/components/pmocontrol/ShuffleControl.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div class="shuffle-control">
|
||||
<button
|
||||
class="shuffle-button"
|
||||
:class="{ loading: isLoading }"
|
||||
@click.stop="handleShuffle"
|
||||
:disabled="isLoading"
|
||||
title="Mélanger la queue"
|
||||
>
|
||||
<Shuffle :size="24" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { Shuffle } from "lucide-vue-next";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
|
||||
async function handleShuffle() {
|
||||
if (isLoading.value) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await api.shuffleQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
console.error("Erreur lors du shuffle de la queue:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shuffle-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.shuffle-button {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.shuffle-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.shuffle-button:active:not(:disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.shuffle-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.shuffle-button.loading {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.shuffle-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.shuffle-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.shuffle-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
527
pmoapp/webapp/src/components/pmocontrol/TimerControl.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="timer-control">
|
||||
<button
|
||||
class="timer-button"
|
||||
:class="{ active: timerState?.active }"
|
||||
@click.stop="toggleTimerDialog"
|
||||
:title="buttonTitle"
|
||||
ref="buttonRef"
|
||||
>
|
||||
<Clock :size="24" />
|
||||
<span
|
||||
v-if="timerState?.active && remainingMinutes !== null"
|
||||
class="timer-badge"
|
||||
>
|
||||
{{ remainingMinutes }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Backdrop et Dialog (téléportés au body) -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showDialog"
|
||||
class="timer-backdrop"
|
||||
@click="closeDialog"
|
||||
></div>
|
||||
|
||||
<div v-if="showDialog" class="timer-dialog" @click.stop>
|
||||
<div class="timer-dialog-content">
|
||||
<div class="timer-header">
|
||||
<h3>Sleep Timer</h3>
|
||||
<button class="close-button" @click="closeDialog">
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="timer-body">
|
||||
<!-- Affichage compact du temps restant -->
|
||||
<div v-if="timerState?.active" class="time-display">
|
||||
{{ formatTime(remainingSeconds) }}
|
||||
</div>
|
||||
|
||||
<!-- Slider compact -->
|
||||
<div class="slider-section">
|
||||
<div class="slider-value">
|
||||
{{ sliderValue }} min
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="120"
|
||||
step="5"
|
||||
v-model.number="sliderValue"
|
||||
class="timer-slider"
|
||||
@change="handleSliderChange"
|
||||
/>
|
||||
<div class="slider-marks">
|
||||
<span>0</span>
|
||||
<span>60</span>
|
||||
<span>120</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton annuler (seulement si actif) -->
|
||||
<button
|
||||
v-if="timerState?.active"
|
||||
class="btn-cancel"
|
||||
@click="handleCancel"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
Annuler le timer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import { Clock, X } from "lucide-vue-next";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import { sse } from "@/services/pmocontrol/sse";
|
||||
import type {
|
||||
SleepTimerState,
|
||||
RendererEventPayload,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const showDialog = ref(false);
|
||||
const sliderValue = ref(0);
|
||||
const timerState = ref<SleepTimerState | null>(null);
|
||||
const isLoading = ref(false);
|
||||
const buttonRef = ref<HTMLElement | null>(null);
|
||||
const localRemainingSeconds = ref<number | null>(null);
|
||||
let countdownInterval: number | null = null;
|
||||
|
||||
const remainingSeconds = computed(
|
||||
() =>
|
||||
localRemainingSeconds.value ??
|
||||
timerState.value?.remaining_seconds ??
|
||||
null,
|
||||
);
|
||||
|
||||
const remainingMinutes = computed(() => {
|
||||
if (remainingSeconds.value === null) return null;
|
||||
return Math.ceil(remainingSeconds.value / 60);
|
||||
});
|
||||
|
||||
const buttonTitle = computed(() => {
|
||||
if (timerState.value?.active && remainingMinutes.value !== null) {
|
||||
return `Sleep timer: ${remainingMinutes.value} min restantes`;
|
||||
}
|
||||
return "Sleep timer";
|
||||
});
|
||||
|
||||
function formatTime(seconds: number | null): string {
|
||||
if (seconds === null) return "--:--";
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
if (countdownInterval !== null) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
|
||||
if (timerState.value?.active && timerState.value.remaining_seconds) {
|
||||
localRemainingSeconds.value = timerState.value.remaining_seconds;
|
||||
|
||||
countdownInterval = window.setInterval(() => {
|
||||
if (
|
||||
localRemainingSeconds.value !== null &&
|
||||
localRemainingSeconds.value > 0
|
||||
) {
|
||||
localRemainingSeconds.value--;
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopCountdown() {
|
||||
if (countdownInterval !== null) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
localRemainingSeconds.value = null;
|
||||
}
|
||||
|
||||
async function fetchTimerState() {
|
||||
try {
|
||||
const state = await api.getSleepTimer(props.rendererId);
|
||||
timerState.value = state;
|
||||
if (state.active && state.duration_seconds) {
|
||||
sliderValue.value = Math.round(state.duration_seconds / 60);
|
||||
startCountdown();
|
||||
} else {
|
||||
stopCountdown();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la récupération du timer:", error);
|
||||
timerState.value = null;
|
||||
stopCountdown();
|
||||
}
|
||||
}
|
||||
|
||||
// Le slider modifie le timer en temps réel
|
||||
async function handleSliderChange() {
|
||||
if (sliderValue.value === 0) {
|
||||
// Si on met à 0, on annule
|
||||
await handleCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const durationSeconds = sliderValue.value * 60;
|
||||
|
||||
if (timerState.value?.active) {
|
||||
await api.updateSleepTimer(props.rendererId, durationSeconds);
|
||||
} else {
|
||||
await api.startSleepTimer(props.rendererId, durationSeconds);
|
||||
}
|
||||
|
||||
await fetchTimerState();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de la configuration du timer:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await api.cancelSleepTimer(props.rendererId);
|
||||
timerState.value = {
|
||||
active: false,
|
||||
duration_seconds: 0,
|
||||
remaining_seconds: null,
|
||||
};
|
||||
sliderValue.value = 0;
|
||||
closeDialog();
|
||||
} catch (error) {
|
||||
console.error("Erreur lors de l'annulation du timer:", error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTimerDialog() {
|
||||
showDialog.value = !showDialog.value;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
showDialog.value = false;
|
||||
}
|
||||
|
||||
let sseUnsubscribe: (() => void) | null = null;
|
||||
|
||||
function handleTimerEvent(event: RendererEventPayload) {
|
||||
if (event.renderer_id !== props.rendererId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case "timer_started":
|
||||
case "timer_updated":
|
||||
timerState.value = {
|
||||
active: true,
|
||||
duration_seconds: event.duration_seconds,
|
||||
remaining_seconds: event.remaining_seconds,
|
||||
};
|
||||
startCountdown();
|
||||
break;
|
||||
|
||||
case "timer_tick":
|
||||
if (timerState.value?.active) {
|
||||
timerState.value = {
|
||||
...timerState.value,
|
||||
remaining_seconds: event.remaining_seconds,
|
||||
};
|
||||
// Resynchroniser le countdown local
|
||||
localRemainingSeconds.value = event.remaining_seconds;
|
||||
}
|
||||
break;
|
||||
|
||||
case "timer_expired":
|
||||
case "timer_cancelled":
|
||||
timerState.value = {
|
||||
active: false,
|
||||
duration_seconds: 0,
|
||||
remaining_seconds: null,
|
||||
};
|
||||
sliderValue.value = 0;
|
||||
stopCountdown();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Surveiller les changements de renderer
|
||||
watch(
|
||||
() => props.rendererId,
|
||||
() => {
|
||||
// Réinitialiser l'état quand on change de renderer
|
||||
stopCountdown();
|
||||
showDialog.value = false;
|
||||
sliderValue.value = 0;
|
||||
timerState.value = null;
|
||||
// Charger l'état du nouveau renderer
|
||||
fetchTimerState();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
fetchTimerState();
|
||||
sseUnsubscribe = sse.onRendererEvent(handleTimerEvent);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (sseUnsubscribe) {
|
||||
sseUnsubscribe();
|
||||
}
|
||||
stopCountdown();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.timer-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timer-button {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.timer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.timer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.timer-button.active {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.timer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.timer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.timer-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: rgba(34, 197, 94, 0.9);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.timer-backdrop {
|
||||
position: fixed;
|
||||
top: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timer-dialog {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.timer-dialog-content {
|
||||
background: var(--background-secondary, #1f2937);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.timer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
|
||||
}
|
||||
|
||||
.timer-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: var(--glass-background, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.timer-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.time-display {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--status-playing, #22c55e);
|
||||
margin-bottom: 0.75rem;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.slider-section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.timer-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--glass-background, rgba(255, 255, 255, 0.1));
|
||||
outline: none;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timer-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-playing, #22c55e);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timer-slider::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.timer-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--status-playing, #22c55e);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.timer-slider::-moz-range-thumb:hover {
|
||||
transform: scale(1.2);
|
||||
}
|
||||
|
||||
.slider-marks {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary, #9ca3af);
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: var(--glass-background, rgba(255, 255, 255, 0.1));
|
||||
color: var(--text-primary, #ffffff);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-cancel:hover:not(:disabled) {
|
||||
background: var(--glass-background-hover, rgba(255, 255, 255, 0.15));
|
||||
}
|
||||
|
||||
.btn-cancel:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.timer-dialog-content {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.timer-button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,50 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Play, Pause, Square, SkipForward } from 'lucide-vue-next'
|
||||
import { computed, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Play, Pause, Square, SkipForward } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { resumeOrPlayFromQueue, pause, stop, next } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const isPlaying = computed(() => state.value?.transport_state === 'PLAYING')
|
||||
const isPaused = computed(() => state.value?.transport_state === 'PAUSED')
|
||||
const isStopped = computed(() => state.value?.transport_state === 'STOPPED' || state.value?.transport_state === 'NO_MEDIA')
|
||||
const isPlaying = computed(() => state.value?.transport_state === "PLAYING");
|
||||
const isPaused = computed(() => state.value?.transport_state === "PAUSED");
|
||||
const isStopped = computed(
|
||||
() =>
|
||||
state.value?.transport_state === "STOPPED" ||
|
||||
state.value?.transport_state === "NO_MEDIA",
|
||||
);
|
||||
|
||||
async function handlePlay() {
|
||||
try {
|
||||
await resumeOrPlayFromQueue(props.rendererId)
|
||||
await resumeOrPlayFromQueue(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause() {
|
||||
try {
|
||||
await pause(props.rendererId)
|
||||
await pause(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de mettre en pause: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
try {
|
||||
await stop(props.rendererId)
|
||||
await stop(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleNext() {
|
||||
try {
|
||||
await next(props.rendererId)
|
||||
await next(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, toRef } from 'vue'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { Volume2, VolumeX } from 'lucide-vue-next'
|
||||
import { ref, watch, toRef } from "vue";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { Volume2, VolumeX } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { state } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { setVolume, toggleMute } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { state } = useRenderer(toRef(props, "rendererId"));
|
||||
const { setVolume, toggleMute } = useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const localVolume = ref(state.value?.volume ?? 50)
|
||||
const localVolume = ref(state.value?.volume ?? 50);
|
||||
|
||||
// Synchroniser localVolume avec le state
|
||||
watch(() => state.value?.volume, (newVolume) => {
|
||||
watch(
|
||||
() => state.value?.volume,
|
||||
(newVolume) => {
|
||||
if (newVolume !== undefined && newVolume !== null) {
|
||||
localVolume.value = newVolume
|
||||
localVolume.value = newVolume;
|
||||
}
|
||||
}, { immediate: true })
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
// Debounce pour le slider
|
||||
let debounceTimer: number | null = null
|
||||
let debounceTimer: number | null = null;
|
||||
function handleVolumeChange(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
localVolume.value = parseInt(target.value, 10)
|
||||
const target = event.target as HTMLInputElement;
|
||||
localVolume.value = parseInt(target.value, 10);
|
||||
|
||||
// Debounce: attendre 300ms avant d'envoyer à l'API
|
||||
if (debounceTimer !== null) {
|
||||
clearTimeout(debounceTimer)
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = window.setTimeout(async () => {
|
||||
try {
|
||||
await setVolume(props.rendererId, localVolume.value)
|
||||
await setVolume(props.rendererId, localVolume.value);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de régler le volume: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
debounceTimer = null
|
||||
}, 300)
|
||||
debounceTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await toggleMute(props.rendererId)
|
||||
await toggleMute(props.rendererId);
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
uiStore.notifyError(
|
||||
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -81,8 +89,6 @@ async function handleToggleMute() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
@@ -120,7 +126,7 @@ async function handleToggleMute() {
|
||||
}
|
||||
|
||||
.volume-control .btn-icon:active {
|
||||
transform: scale(1.0);
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Slider avec effet glass */
|
||||
|
||||
@@ -1,371 +1,377 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { X, ChevronLeft, ChevronRight, Server } from 'lucide-vue-next'
|
||||
import { useTabs } from '@/composables/useTabs'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
import { computed } from "vue";
|
||||
import { Server, Music2 } from "lucide-vue-next";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import TimerControl from "@/components/pmocontrol/TimerControl.vue";
|
||||
import ShuffleControl from "@/components/pmocontrol/ShuffleControl.vue";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
defineProps<{
|
||||
onlineServersCount?: number
|
||||
}>()
|
||||
const props = defineProps<{
|
||||
onlineServersCount?: number;
|
||||
onlineRenderersCount?: number;
|
||||
activeRenderer?: RendererSummary | null;
|
||||
activeRendererState?: RendererState | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'open-drawer': []
|
||||
}>()
|
||||
"open-drawer": [];
|
||||
"open-renderer-drawer": [];
|
||||
}>();
|
||||
|
||||
const { tabs, activeTabId, switchTab, closeTab, nextTab, previousTab, compactMode } = useTabs()
|
||||
|
||||
const tabBarRef = ref<HTMLElement | null>(null)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
|
||||
// Gestion du swipe pour changer d'onglet
|
||||
useSwipe(tabBarRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
if (swipeDirection === 'left') {
|
||||
nextTab()
|
||||
} else if (swipeDirection === 'right') {
|
||||
previousTab()
|
||||
// Label du protocole
|
||||
const protocolLabel = computed(() => {
|
||||
if (!props.activeRenderer) return "";
|
||||
switch (props.activeRenderer.protocol) {
|
||||
case "upnp":
|
||||
return "UPnP";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return props.activeRenderer.protocol;
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Scroll vers l'onglet actif
|
||||
function scrollToActiveTab() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const activeTabElement = scrollContainerRef.value.querySelector('.tab-item.active')
|
||||
if (activeTabElement) {
|
||||
activeTabElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
})
|
||||
// Classe CSS du protocole
|
||||
const protocolClass = computed(() => {
|
||||
if (!props.activeRenderer) return "";
|
||||
switch (props.activeRenderer.protocol) {
|
||||
case "upnp":
|
||||
return "protocol-upnp";
|
||||
case "openhome":
|
||||
return "protocol-openhome";
|
||||
case "hybrid":
|
||||
return "protocol-hybrid";
|
||||
case "chromecast":
|
||||
return "protocol-chromecast";
|
||||
default:
|
||||
return "protocol-unknown";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll vers l'onglet actif quand il change
|
||||
watch(() => activeTabId.value, () => {
|
||||
scrollToActiveTab()
|
||||
})
|
||||
|
||||
// Gestion des boutons de scroll
|
||||
const showLeftScroll = ref(false)
|
||||
const showRightScroll = ref(false)
|
||||
|
||||
function updateScrollButtons() {
|
||||
if (!scrollContainerRef.value) return
|
||||
|
||||
const { scrollLeft, scrollWidth, clientWidth } = scrollContainerRef.value
|
||||
showLeftScroll.value = scrollLeft > 10
|
||||
showRightScroll.value = scrollLeft < scrollWidth - clientWidth - 10
|
||||
}
|
||||
|
||||
function scrollLeft() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: -200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function scrollRight() {
|
||||
if (!scrollContainerRef.value) return
|
||||
scrollContainerRef.value.scrollBy({ left: 200, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.addEventListener('scroll', updateScrollButtons)
|
||||
updateScrollButtons()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (scrollContainerRef.value) {
|
||||
scrollContainerRef.value.removeEventListener('scroll', updateScrollButtons)
|
||||
}
|
||||
})
|
||||
|
||||
// Gestion du clic sur un onglet
|
||||
function handleTabClick(tabId: string) {
|
||||
switchTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton fermer
|
||||
function handleCloseClick(event: Event, tabId: string) {
|
||||
event.stopPropagation()
|
||||
closeTab(tabId)
|
||||
}
|
||||
|
||||
// Gestion du clic sur le bouton server drawer
|
||||
function handleServerDrawerClick() {
|
||||
emit('open-drawer')
|
||||
emit("open-drawer");
|
||||
}
|
||||
|
||||
function handleRendererDrawerClick() {
|
||||
emit("open-renderer-drawer");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="tabBarRef" class="bottom-tab-bar">
|
||||
<!-- Bouton pour ouvrir le drawer des servers -->
|
||||
<div class="bottom-bar">
|
||||
<!-- Bouton pour ouvrir le drawer des servers (gauche) -->
|
||||
<button
|
||||
class="server-drawer-button"
|
||||
class="drawer-button server-drawer-button"
|
||||
@click="handleServerDrawerClick"
|
||||
:aria-label="`Open media servers (${onlineServersCount || 0} online)`"
|
||||
:title="`Media Servers (${onlineServersCount || 0} online)`"
|
||||
>
|
||||
<Server :size="24" />
|
||||
<span v-if="onlineServersCount && onlineServersCount > 0" class="server-badge">{{ onlineServersCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Bouton scroll gauche -->
|
||||
<button
|
||||
v-if="showLeftScroll"
|
||||
class="scroll-button scroll-left"
|
||||
@click="scrollLeft"
|
||||
aria-label="Scroll left"
|
||||
<span
|
||||
v-if="onlineServersCount && onlineServersCount > 0"
|
||||
class="badge server-badge"
|
||||
>{{ onlineServersCount }}</span
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
|
||||
<!-- Container avec scroll horizontal -->
|
||||
<div ref="scrollContainerRef" class="tabs-scroll-container">
|
||||
<div class="tabs-container">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="tab-item"
|
||||
:class="{
|
||||
active: tab.id === activeTabId,
|
||||
compact: compactMode
|
||||
}"
|
||||
@click="handleTabClick(tab.id)"
|
||||
:title="tab.fullTitle"
|
||||
:aria-label="`Switch to ${tab.fullTitle} tab`"
|
||||
:aria-current="tab.id === activeTabId ? 'page' : undefined"
|
||||
<!-- Zone centrale avec les infos du renderer -->
|
||||
<div class="renderer-info-section">
|
||||
<div v-if="activeRenderer" class="renderer-info-content">
|
||||
<div class="renderer-name-row">
|
||||
<h2 class="renderer-name">
|
||||
{{ activeRenderer.friendly_name }}
|
||||
</h2>
|
||||
<span
|
||||
v-if="activeRenderer.protocol"
|
||||
:class="['protocol-badge', protocolClass]"
|
||||
>
|
||||
<!-- Icône (réduite pour plus d'espace pour le texte) -->
|
||||
<component :is="tab.icon" class="tab-icon" :size="compactMode ? 24 : 20" />
|
||||
|
||||
<!-- Titre (masqué en mode compact) -->
|
||||
<span v-if="!compactMode" class="tab-title">{{ tab.title }}</span>
|
||||
|
||||
<!-- Bouton fermer (seulement pour les onglets fermables, masqué en mode compact) -->
|
||||
<button
|
||||
v-if="tab.closeable && !compactMode"
|
||||
class="tab-close"
|
||||
@click="(e) => handleCloseClick(e, tab.id)"
|
||||
:aria-label="`Close ${tab.fullTitle} tab`"
|
||||
>
|
||||
<X :size="16" />
|
||||
</button>
|
||||
</button>
|
||||
{{ protocolLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details-row">
|
||||
<p v-if="activeRenderer.model_name" class="renderer-model">
|
||||
{{ activeRenderer.model_name }}
|
||||
</p>
|
||||
<StatusBadge
|
||||
v-if="activeRendererState"
|
||||
:status="activeRendererState.transport_state"
|
||||
class="status-badge"
|
||||
/>
|
||||
<span v-if="!activeRenderer.online" class="offline-badge">
|
||||
OFFLINE
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="renderer-info-content empty">
|
||||
<p class="no-renderer">Aucun renderer sélectionné</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bouton scroll droite -->
|
||||
<!-- Shuffle et Sleep Timer (si un renderer est actif) -->
|
||||
<div v-if="activeRenderer" class="controls-section">
|
||||
<ShuffleControl :renderer-id="activeRenderer.id" />
|
||||
<TimerControl :renderer-id="activeRenderer.id" />
|
||||
</div>
|
||||
|
||||
<!-- Bouton pour ouvrir le drawer des renderers (droite) -->
|
||||
<button
|
||||
v-if="showRightScroll"
|
||||
class="scroll-button scroll-right"
|
||||
@click="scrollRight"
|
||||
aria-label="Scroll right"
|
||||
class="drawer-button renderer-drawer-button"
|
||||
@click="handleRendererDrawerClick"
|
||||
:aria-label="`Open renderers (${onlineRenderersCount || 0} online)`"
|
||||
:title="`Renderers (${onlineRenderersCount || 0} online)`"
|
||||
>
|
||||
<Music2 :size="24" />
|
||||
<span
|
||||
v-if="onlineRenderersCount && onlineRenderersCount > 0"
|
||||
class="badge renderer-badge"
|
||||
>{{ onlineRenderersCount }}</span
|
||||
>
|
||||
<ChevronRight :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 64px;
|
||||
gap: var(--spacing-md);
|
||||
height: 72px;
|
||||
padding: 0 var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Support pour thème sombre */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-scroll-container {
|
||||
flex: 1;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
}
|
||||
|
||||
.tabs-scroll-container::-webkit-scrollbar {
|
||||
display: none; /* Chrome/Safari */
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 8px;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
/* Boutons drawer */
|
||||
.drawer-button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 140px; /* Largeur fixe pour éviter l'espace blanc */
|
||||
height: 64px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 4px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px; /* Réduit de var(--text-sm) pour plus d'espace */
|
||||
font-family: inherit;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mode compact: icônes seulement */
|
||||
.tab-item.compact {
|
||||
width: 60px;
|
||||
padding: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom-color: var(--color-primary);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.tab-item:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
flex-shrink: 0;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left;
|
||||
/* Permettre au texte de se replier sur 2 lignes */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
line-height: 1.2;
|
||||
max-height: 2.4em; /* 2 lignes × 1.2 line-height */
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.tab-close:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
color: var(--color-text);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.tab-close:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Boutons de scroll */
|
||||
.scroll-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.scroll-button:hover {
|
||||
.drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.scroll-button:active {
|
||||
transform: translateY(-50%) scale(0.95);
|
||||
.drawer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.scroll-left {
|
||||
left: 8px;
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.drawer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-right {
|
||||
right: 8px;
|
||||
/* Badges */
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.tab-item {
|
||||
width: 110px; /* Largeur fixe plus petite sur mobile */
|
||||
padding: 6px 10px;
|
||||
.server-badge {
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
}
|
||||
|
||||
.renderer-badge {
|
||||
background: rgba(234, 102, 126, 0.9);
|
||||
}
|
||||
|
||||
/* Zone centrale */
|
||||
.renderer-info-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-info-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px; /* Encore un peu plus petit sur mobile */
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.renderer-info-content.empty {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.renderer-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.renderer-details-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-renderer {
|
||||
font-size: var(--text-base);
|
||||
color: var(--color-text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
background-color: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* Controls section (shuffle + timer) */
|
||||
.controls-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.bottom-bar {
|
||||
height: 64px;
|
||||
padding: 0 var(--spacing-sm);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 12px;
|
||||
line-height: 1.3;
|
||||
max-height: 2.6em; /* 2 lignes × 1.3 line-height */
|
||||
.drawer-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.tab-close {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.renderer-name {
|
||||
font-size: var(--text-base);
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,72 +387,7 @@ function handleServerDrawerClick() {
|
||||
}
|
||||
}
|
||||
|
||||
.bottom-tab-bar {
|
||||
.bottom-bar {
|
||||
animation: slideInUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
/* Effet de swipe visuel */
|
||||
.tabs-scroll-container {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
|
||||
/* Bouton server drawer */
|
||||
.server-drawer-button {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin: 0 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.server-drawer-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.server-drawer-button {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.server-drawer-button:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge pour le nombre de servers online */
|
||||
.server-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
background: rgba(102, 126, 234, 0.9);
|
||||
border: 2px solid var(--color-bg);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
|
||||
977
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal file
977
pmoapp/webapp/src/components/unified/RendererDrawer.vue
Normal file
@@ -0,0 +1,977 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, ref, onUnmounted } from "vue";
|
||||
import {
|
||||
X,
|
||||
Music2,
|
||||
Circle,
|
||||
Settings,
|
||||
Play,
|
||||
Pause,
|
||||
MoreVertical,
|
||||
ArrowRightLeft,
|
||||
} from "lucide-vue-next";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useRouter } from "vue-router";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import type { RendererSummary } from "@/services/pmocontrol/types";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
selectedRendererId?: string | null; // ID du renderer actuellement sélectionné
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
"select-renderer": [rendererId: string];
|
||||
}>();
|
||||
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const router = useRouter();
|
||||
|
||||
// Gestion du menu déroulant
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
// Gestionnaire pour fermer le menu quand on clique en dehors
|
||||
let clickOutsideHandler: ((event: MouseEvent) => void) | null = null;
|
||||
|
||||
watch(openMenuId, (newValue) => {
|
||||
if (newValue) {
|
||||
// Ajouter l'écouteur après un petit délai pour éviter la fermeture immédiate
|
||||
setTimeout(() => {
|
||||
clickOutsideHandler = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest(".action-menu-container")) {
|
||||
closeMenu();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", clickOutsideHandler);
|
||||
}, 100);
|
||||
} else {
|
||||
// Retirer l'écouteur
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
clickOutsideHandler = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
}
|
||||
});
|
||||
|
||||
// Fonction pour obtenir l'état d'un renderer
|
||||
function getRendererState(rendererId: string) {
|
||||
return getStateById(rendererId);
|
||||
}
|
||||
|
||||
// Fonction pour obtenir le label du protocole
|
||||
function getProtocolLabel(protocol: string): string {
|
||||
switch (protocol) {
|
||||
case "upnp":
|
||||
return "UPnP";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return protocol;
|
||||
}
|
||||
}
|
||||
|
||||
// Fonction pour obtenir la classe CSS du protocole
|
||||
function getProtocolClass(protocol: string): string {
|
||||
switch (protocol) {
|
||||
case "upnp":
|
||||
return "protocol-upnp";
|
||||
case "openhome":
|
||||
return "protocol-openhome";
|
||||
case "hybrid":
|
||||
return "protocol-hybrid";
|
||||
case "chromecast":
|
||||
return "protocol-chromecast";
|
||||
default:
|
||||
return "protocol-unknown";
|
||||
}
|
||||
}
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchRenderers();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => r.online),
|
||||
);
|
||||
const offlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r: RendererSummary) => !r.online),
|
||||
);
|
||||
|
||||
function close() {
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
function handleRendererClick(renderer: RendererSummary) {
|
||||
if (!renderer.online) return;
|
||||
|
||||
// Sélectionner le renderer et fermer le drawer
|
||||
emit("select-renderer", renderer.id);
|
||||
close();
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push("/debug/api-dashboard");
|
||||
close();
|
||||
}
|
||||
|
||||
// Handlers pour les contrôles de transport
|
||||
async function handlePlay(event: Event, rendererId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await api.play(rendererId);
|
||||
} catch (error) {
|
||||
console.error("[RendererDrawer] Error playing:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePause(event: Event, rendererId: string) {
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await api.pause(rendererId);
|
||||
} catch (error) {
|
||||
console.error("[RendererDrawer] Error pausing:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion du menu déroulant
|
||||
function toggleMenu(event: Event, rendererId: string) {
|
||||
event.stopPropagation();
|
||||
openMenuId.value = openMenuId.value === rendererId ? null : rendererId;
|
||||
}
|
||||
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
// Transfert de la queue
|
||||
async function handleTransferQueue(event: Event, targetRendererId: string) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!props.selectedRendererId) {
|
||||
console.warn("[RendererDrawer] No source renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.selectedRendererId === targetRendererId) {
|
||||
console.warn("[RendererDrawer] Cannot transfer to the same renderer");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(
|
||||
`[RendererDrawer] Transferring queue from ${props.selectedRendererId} to ${targetRendererId}`,
|
||||
);
|
||||
await api.transferQueue(props.selectedRendererId, targetRendererId);
|
||||
|
||||
// Sélectionner le nouveau renderer et fermer le drawer
|
||||
emit("select-renderer", targetRendererId);
|
||||
close();
|
||||
} catch (error) {
|
||||
console.error("[RendererDrawer] Error transferring queue:", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Backdrop -->
|
||||
<Transition name="backdrop">
|
||||
<div v-if="modelValue" class="drawer-backdrop" @click="close"></div>
|
||||
</Transition>
|
||||
|
||||
<!-- Drawer -->
|
||||
<Transition name="drawer">
|
||||
<aside v-if="modelValue" class="renderer-drawer">
|
||||
<!-- Header -->
|
||||
<header class="drawer-header">
|
||||
<div class="drawer-title-section">
|
||||
<Music2 :size="24" />
|
||||
<h2 class="drawer-title">Renderers</h2>
|
||||
</div>
|
||||
<button
|
||||
class="drawer-close-btn"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div class="drawer-content">
|
||||
<!-- Renderers online -->
|
||||
<section
|
||||
v-if="onlineRenderers.length > 0"
|
||||
class="renderer-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Disponibles ({{ onlineRenderers.length }})
|
||||
</h3>
|
||||
<ul class="renderer-list">
|
||||
<li
|
||||
v-for="renderer in onlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="renderer-item online"
|
||||
:class="{
|
||||
selected:
|
||||
renderer.id === selectedRendererId,
|
||||
}"
|
||||
@click="handleRendererClick(renderer)"
|
||||
>
|
||||
<!-- Bouton transport à gauche -->
|
||||
<button
|
||||
v-if="getRendererState(renderer.id)"
|
||||
class="transport-btn"
|
||||
@click="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
? handlePause($event, renderer.id)
|
||||
: getRendererState(renderer.id)
|
||||
?.transport_state ===
|
||||
'PAUSED'
|
||||
? handlePlay($event, renderer.id)
|
||||
: handlePlay($event, renderer.id)
|
||||
"
|
||||
:title="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
? 'Pause'
|
||||
: 'Play'
|
||||
"
|
||||
>
|
||||
<Pause
|
||||
v-if="
|
||||
getRendererState(renderer.id)
|
||||
?.transport_state === 'PLAYING'
|
||||
"
|
||||
:size="18"
|
||||
/>
|
||||
<Play v-else :size="18" />
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="transport-btn disabled"
|
||||
disabled
|
||||
>
|
||||
<Play :size="18" />
|
||||
</button>
|
||||
|
||||
<div class="renderer-icon">
|
||||
<Music2 :size="20" />
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<div class="renderer-name-row">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
</p>
|
||||
<span
|
||||
:class="[
|
||||
'protocol-badge',
|
||||
getProtocolClass(
|
||||
renderer.protocol,
|
||||
),
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getProtocolLabel(
|
||||
renderer.protocol,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="renderer-details">
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
class="renderer-model"
|
||||
>
|
||||
{{ renderer.model_name }}
|
||||
</p>
|
||||
<StatusBadge
|
||||
v-if="getRendererState(renderer.id)"
|
||||
:status="
|
||||
getRendererState(renderer.id)!
|
||||
.transport_state
|
||||
"
|
||||
class="renderer-state-badge"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Menu actions (uniquement si ce n'est pas le renderer sélectionné) -->
|
||||
<div
|
||||
v-if="
|
||||
selectedRendererId &&
|
||||
renderer.id !== selectedRendererId
|
||||
"
|
||||
class="action-menu-container"
|
||||
>
|
||||
<button
|
||||
class="menu-btn"
|
||||
@click="toggleMenu($event, renderer.id)"
|
||||
:aria-label="`Actions pour ${renderer.friendly_name}`"
|
||||
>
|
||||
<MoreVertical :size="18" />
|
||||
</button>
|
||||
|
||||
<Transition name="menu-fade">
|
||||
<div
|
||||
v-if="openMenuId === renderer.id"
|
||||
class="action-dropdown"
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
handleTransferQueue(
|
||||
$event,
|
||||
renderer.id,
|
||||
)
|
||||
"
|
||||
>
|
||||
<ArrowRightLeft :size="16" />
|
||||
<span
|
||||
>Transférer la lecture
|
||||
ici</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<div class="renderer-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Renderers offline -->
|
||||
<section
|
||||
v-if="offlineRenderers.length > 0"
|
||||
class="renderer-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Hors ligne ({{ offlineRenderers.length }})
|
||||
</h3>
|
||||
<ul class="renderer-list">
|
||||
<li
|
||||
v-for="renderer in offlineRenderers"
|
||||
:key="renderer.id"
|
||||
class="renderer-item offline"
|
||||
>
|
||||
<div class="renderer-icon">
|
||||
<Music2 :size="20" />
|
||||
</div>
|
||||
<div class="renderer-info">
|
||||
<p class="renderer-name">
|
||||
{{ renderer.friendly_name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="renderer.model_name"
|
||||
class="renderer-model"
|
||||
>
|
||||
{{ renderer.model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="renderer-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- Aucun renderer -->
|
||||
<div
|
||||
v-if="allRenderers.length === 0"
|
||||
class="empty-renderers"
|
||||
>
|
||||
<Music2 :size="48" />
|
||||
<p>Aucun renderer détecté</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer avec bouton settings -->
|
||||
<footer class="drawer-footer">
|
||||
<button
|
||||
class="settings-btn"
|
||||
@click="handleSettingsClick"
|
||||
title="Ouvrir le menu Debug"
|
||||
>
|
||||
<Settings :size="20" />
|
||||
<span>Debug & Config</span>
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.drawer-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 50vw; /* Commence avant le drawer (desktop: jusqu'à 50vw) */
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.35); /* Moins sombre */
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
right: 0; /* Mobile portrait: backdrop prend tout l'écran */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
}
|
||||
}
|
||||
|
||||
.renderer-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: -4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.renderer-drawer {
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
border-left-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.drawer-title-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.drawer-close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.drawer-close-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.drawer-close-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.drawer-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0 0 var(--spacing-sm) 0;
|
||||
padding: 0 var(--spacing-sm);
|
||||
}
|
||||
|
||||
.renderer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.renderer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.renderer-item.online {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.renderer-item.online:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.renderer-item.online:active {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.renderer-item.selected {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 16px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.renderer-item.selected:hover {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
}
|
||||
|
||||
.renderer-item.offline {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.renderer-item.selected .renderer-icon {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.renderer-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.renderer-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.renderer-state-badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Protocol badge */
|
||||
.protocol-badge {
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.protocol-upnp {
|
||||
background-color: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-openhome {
|
||||
background-color: rgba(139, 92, 246, 0.15);
|
||||
color: #8b5cf6;
|
||||
border: 1px solid rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.protocol-hybrid {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.protocol-chromecast {
|
||||
background-color: rgba(244, 114, 182, 0.15);
|
||||
color: #f472b6;
|
||||
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||
}
|
||||
|
||||
/* Transport button */
|
||||
.transport-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
border: 1px solid var(--color-primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.transport-btn:hover:not(.disabled) {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.transport-btn:active:not(.disabled) {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.transport-btn.disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* Menu actions */
|
||||
.action-menu-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-secondary);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.action-dropdown {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
min-width: 200px;
|
||||
background: var(--color-surface-elevated);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.action-dropdown {
|
||||
background: white;
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: none;
|
||||
border: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.dropdown-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Transitions pour le menu */
|
||||
.menu-fade-enter-active {
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.menu-fade-leave-active {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.menu-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.renderer-status {
|
||||
flex-shrink: 0;
|
||||
color: var(--status-playing);
|
||||
}
|
||||
|
||||
.renderer-item.offline .renderer-status {
|
||||
color: var(--status-offline);
|
||||
}
|
||||
|
||||
.empty-renderers {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-2xl);
|
||||
text-align: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-renderers p {
|
||||
margin: 0;
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.drawer-footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--spacing-md);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.settings-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-md);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.settings-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: var(--color-text);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.settings-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.3s ease-out;
|
||||
transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */
|
||||
}
|
||||
|
||||
.backdrop-leave-active {
|
||||
transition: opacity 0.25s ease-in;
|
||||
}
|
||||
|
||||
.backdrop-enter-from,
|
||||
.backdrop-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Courbe d'animation fluide (easeOutExpo) */
|
||||
}
|
||||
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.7, 0, 0.84, 0); /* Courbe d'animation de sortie (easeInExpo) */
|
||||
}
|
||||
|
||||
.drawer-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.drawer-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.renderer-drawer {
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.drawer-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.renderer-item {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.renderer-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
@supports not (backdrop-filter: blur(30px)) {
|
||||
.renderer-drawer {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.renderer-drawer {
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,103 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, toRef, onMounted } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useRenderer } from '@/composables/useRenderers'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ChevronUp, ChevronDown, Link } from 'lucide-vue-next'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { QueueItem } from '@/services/pmocontrol/types'
|
||||
import { ref, computed, toRef, onMounted } from "vue";
|
||||
import { useMediaQuery } from "@vueuse/core";
|
||||
import { useRenderer } from "@/composables/useRenderers";
|
||||
import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
|
||||
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
|
||||
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
|
||||
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
|
||||
import { ChevronUp, ChevronDown } from "lucide-vue-next";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import { api } from "@/services/pmocontrol/api";
|
||||
import type { QueueItem } from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
rendererId: string
|
||||
}>()
|
||||
rendererId: string;
|
||||
}>();
|
||||
|
||||
const { renderer, state, queue, binding, refresh } = useRenderer(toRef(props, 'rendererId'))
|
||||
const { detachPlaylist } = useRenderers()
|
||||
const uiStore = useUIStore()
|
||||
const { renderer, state, queue, refresh } = useRenderer(
|
||||
toRef(props, "rendererId"),
|
||||
);
|
||||
const uiStore = useUIStore();
|
||||
|
||||
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
|
||||
const isMobilePortrait = useMediaQuery('(max-width: 768px) and (orientation: portrait)')
|
||||
const isMobilePortrait = useMediaQuery(
|
||||
"(max-width: 768px) and (orientation: portrait)",
|
||||
);
|
||||
|
||||
// État du drawer queue sur mobile
|
||||
const queueDrawerOpen = ref(false)
|
||||
const queueDrawerOpen = ref(false);
|
||||
|
||||
function toggleQueueDrawer() {
|
||||
queueDrawerOpen.value = !queueDrawerOpen.value
|
||||
queueDrawerOpen.value = !queueDrawerOpen.value;
|
||||
}
|
||||
|
||||
// Charger les données au montage
|
||||
onMounted(async () => {
|
||||
await refresh()
|
||||
})
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// État du renderer pour affichage
|
||||
const isOnline = computed(() => renderer.value?.online ?? false)
|
||||
const transportState = computed(() => state.value?.transport_state ?? 'STOPPED')
|
||||
const hasPlaylistBinding = computed(() => !!binding.value)
|
||||
|
||||
// Détacher la playlist
|
||||
async function handleDetachPlaylist() {
|
||||
try {
|
||||
await detachPlaylist(props.rendererId)
|
||||
uiStore.notifySuccess('Playlist détachée')
|
||||
} catch (error) {
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Erreur inconnue'}`)
|
||||
}
|
||||
}
|
||||
const isOnline = computed(() => renderer.value?.online ?? false);
|
||||
|
||||
// Gérer le clic sur un item de la queue
|
||||
async function handleQueueItemClick(item: QueueItem) {
|
||||
try {
|
||||
await api.seekQueueIndex(props.rendererId, item.index)
|
||||
console.log('[RendererTabContent] Jumped to queue index:', item.index, item.title)
|
||||
await api.seekQueueIndex(props.rendererId, item.index);
|
||||
console.log(
|
||||
"[RendererTabContent] Jumped to queue index:",
|
||||
item.index,
|
||||
item.title,
|
||||
);
|
||||
|
||||
// Force un refetch immédiat pour synchroniser la cover affichée
|
||||
// sans attendre l'événement SSE qui peut avoir un délai
|
||||
await refresh(true)
|
||||
await refresh(true);
|
||||
} catch (error) {
|
||||
console.error('[RendererTabContent] Error seeking to queue index:', error)
|
||||
uiStore.notifyError(`Erreur: ${error instanceof Error ? error.message : 'Impossible de sauter à cet item'}`)
|
||||
console.error(
|
||||
"[RendererTabContent] Error seeking to queue index:",
|
||||
error,
|
||||
);
|
||||
uiStore.notifyError(
|
||||
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-tab-content">
|
||||
<!-- Header avec nom du renderer et état -->
|
||||
<header class="renderer-header">
|
||||
<div class="header-info">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Renderer' }}</h1>
|
||||
<p v-if="renderer?.model_name" class="renderer-model">{{ renderer.model_name }}</p>
|
||||
</div>
|
||||
<div class="header-badges">
|
||||
<StatusBadge v-if="state" :status="transportState" />
|
||||
<span v-if="renderer?.protocol" class="protocol-badge">
|
||||
{{ renderer.protocol.toUpperCase() }}
|
||||
</span>
|
||||
|
||||
<!-- Badge playlist binding avec tooltip -->
|
||||
<div v-if="hasPlaylistBinding" class="playlist-badge" :title="`Playlist liée\nServeur: ${binding?.server_id}\nContainer: ${binding?.container_id}`">
|
||||
<button class="playlist-badge-btn" @click="handleDetachPlaylist" title="Cliquer pour détacher">
|
||||
<Link :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span v-if="!isOnline" class="offline-badge">OFFLINE</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Layout principal -->
|
||||
<!-- Layout principal (header supprimé, infos déplacées dans BottomTabBar) -->
|
||||
<div class="renderer-layout" :class="{ 'queue-open': queueDrawerOpen }">
|
||||
<!-- Colonne gauche: Contrôles -->
|
||||
<div class="controls-column">
|
||||
<!-- Pochette + infos track -->
|
||||
<CurrentTrack v-if="state" :renderer-id="rendererId" class="current-track-section" />
|
||||
<CurrentTrack
|
||||
v-if="state"
|
||||
:renderer-id="rendererId"
|
||||
class="current-track-section"
|
||||
/>
|
||||
|
||||
<!-- Contrôles de transport -->
|
||||
<div v-if="state" class="controls-section">
|
||||
@@ -117,11 +96,19 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
|
||||
<!-- Colonne droite: Queue (desktop et landscape uniquement) -->
|
||||
<div v-if="!isMobilePortrait" class="queue-column">
|
||||
<QueueViewer :renderer-id="rendererId" class="queue-viewer" @click-item="handleQueueItemClick" />
|
||||
<QueueViewer
|
||||
:renderer-id="rendererId"
|
||||
class="queue-viewer"
|
||||
@click-item="handleQueueItemClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Drawer queue (mobile portrait uniquement) -->
|
||||
<div v-if="isMobilePortrait" class="queue-drawer" :class="{ open: queueDrawerOpen }">
|
||||
<div
|
||||
v-if="isMobilePortrait"
|
||||
class="queue-drawer"
|
||||
:class="{ open: queueDrawerOpen }"
|
||||
>
|
||||
<!-- Toggle button -->
|
||||
<button class="queue-drawer-toggle" @click="toggleQueueDrawer">
|
||||
<ChevronUp v-if="queueDrawerOpen" :size="24" />
|
||||
@@ -131,7 +118,10 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
|
||||
<!-- Contenu du drawer -->
|
||||
<div class="queue-drawer-content">
|
||||
<QueueViewer :renderer-id="rendererId" @click-item="handleQueueItemClick" />
|
||||
<QueueViewer
|
||||
:renderer-id="rendererId"
|
||||
@click-item="handleQueueItemClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,114 +144,13 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.renderer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.renderer-name {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.renderer-model {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-secondary);
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.header-badges {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.protocol-badge,
|
||||
.offline-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.protocol-badge {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.offline-badge {
|
||||
background: var(--status-offline);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Badge playlist binding compact */
|
||||
.playlist-badge {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playlist-badge-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.4);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
color: rgba(102, 126, 234, 1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.3);
|
||||
border-color: rgba(102, 126, 234, 0.6);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:active {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.playlist-badge-btn {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.playlist-badge-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
/* Layout principal - 800x600 landscape (2 colonnes) */
|
||||
.renderer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
padding: var(--spacing-lg) 0 var(--spacing-lg) var(--spacing-lg);
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -272,7 +161,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
overflow-y: auto;
|
||||
padding-right: var(--spacing-sm);
|
||||
padding-right: 16px; /* Dégager la scrollbar */
|
||||
}
|
||||
|
||||
.current-track-section,
|
||||
@@ -329,14 +218,14 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
.renderer-layout {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-xl); /* Espace pour éviter que la tab bar cache les contrôles */
|
||||
padding: var(--spacing-md) 0 var(--spacing-xl) var(--spacing-md);
|
||||
/* padding-right: 0 pour coller la scrollbar au bord */
|
||||
}
|
||||
|
||||
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
|
||||
.queue-drawer {
|
||||
position: fixed;
|
||||
bottom: 64px; /* Hauteur de la tab bar */
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
@@ -344,14 +233,21 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(calc(100% - 56px));
|
||||
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
|
||||
transform: translateY(calc(100% - 56px - 64px));
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 90;
|
||||
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
|
||||
max-height: 70vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Fermé: ne bloque pas les clics en dehors du toggle */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.queue-drawer.open {
|
||||
transform: translateY(0);
|
||||
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
|
||||
transform: translateY(64px);
|
||||
pointer-events: auto; /* Ouvert: capture les clics */
|
||||
}
|
||||
|
||||
.queue-drawer-toggle {
|
||||
@@ -368,6 +264,7 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
pointer-events: auto; /* Le bouton est toujours cliquable */
|
||||
}
|
||||
|
||||
.queue-drawer-content {
|
||||
@@ -382,15 +279,15 @@ async function handleQueueItemClick(item: QueueItem) {
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 64px;
|
||||
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
z-index: 89;
|
||||
z-index: 94; /* Entre la BottomTabBar et le drawer */
|
||||
pointer-events: auto; /* Capture les clics pour fermer le drawer */
|
||||
}
|
||||
|
||||
.controls-column {
|
||||
padding-right: 0;
|
||||
padding-bottom: 100px; /* Espace supplémentaire pour éviter que la tab bar (64px) cache le volume */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +1,294 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { X, Server as ServerIcon, Circle, ChevronLeft, Folder, Music, ChevronRight, Play, Plus, Settings } from 'lucide-vue-next'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import type { MediaServerSummary, BrowseResponse, ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
X,
|
||||
Server as ServerIcon,
|
||||
Circle,
|
||||
ChevronLeft,
|
||||
Folder,
|
||||
Music,
|
||||
ChevronRight,
|
||||
Play,
|
||||
Plus,
|
||||
Settings,
|
||||
MoreVertical,
|
||||
} from "lucide-vue-next";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import type {
|
||||
MediaServerSummary,
|
||||
BrowseResponse,
|
||||
ContainerEntry,
|
||||
} from "@/services/pmocontrol/types";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean // v-model pour contrôler l'ouverture
|
||||
}>()
|
||||
modelValue: boolean; // v-model pour contrôler l'ouverture
|
||||
selectedRendererId?: string | null; // ID du renderer sélectionné
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
'play-item': [item: ContainerEntry, serverId: string]
|
||||
'queue-item': [item: ContainerEntry, serverId: string]
|
||||
}>()
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const { allServers, fetchServers, browseContainer, currentPath, setPath, clearPath } = useMediaServers()
|
||||
const router = useRouter()
|
||||
const {
|
||||
allServers,
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
currentPath,
|
||||
setPath,
|
||||
clearPath,
|
||||
} = useMediaServers();
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
useRenderers();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// État de navigation
|
||||
const currentServer = ref<MediaServerSummary | null>(null)
|
||||
const browseData = ref<BrowseResponse | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const currentServer = ref<MediaServerSummary | null>(null);
|
||||
const browseData = ref<BrowseResponse | null>(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
|
||||
const openMenuId = ref<string | null>(null);
|
||||
|
||||
// Rafraîchir la liste quand le drawer s'ouvre
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(isOpen) => {
|
||||
if (isOpen) {
|
||||
fetchServers()
|
||||
fetchServers();
|
||||
} else {
|
||||
// Reset navigation quand on ferme
|
||||
currentServer.value = null
|
||||
browseData.value = null
|
||||
clearPath()
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
clearPath();
|
||||
closeMenu();
|
||||
}
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
const onlineServers = computed(() => allServers.value.filter((s: MediaServerSummary) => s.online))
|
||||
const offlineServers = computed(() => allServers.value.filter((s: MediaServerSummary) => !s.online))
|
||||
// Fermer le menu quand on clique ailleurs (utilise un seul listener global)
|
||||
let clickOutsideHandler: ((e: MouseEvent) => void) | null = null;
|
||||
|
||||
watch(
|
||||
() => openMenuId.value,
|
||||
(menuId) => {
|
||||
// Nettoyer l'ancien listener s'il existe
|
||||
if (clickOutsideHandler) {
|
||||
document.removeEventListener("click", clickOutsideHandler);
|
||||
clickOutsideHandler = null;
|
||||
}
|
||||
|
||||
// Ajouter un nouveau listener seulement si un menu est ouvert
|
||||
if (menuId) {
|
||||
clickOutsideHandler = () => {
|
||||
closeMenu();
|
||||
};
|
||||
// Utiliser setTimeout pour éviter que le clic qui ouvre le menu le ferme immédiatement
|
||||
setTimeout(() => {
|
||||
if (clickOutsideHandler) {
|
||||
document.addEventListener("click", clickOutsideHandler);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const onlineServers = computed(() =>
|
||||
allServers.value.filter((s: MediaServerSummary) => s.online),
|
||||
);
|
||||
const offlineServers = computed(() =>
|
||||
allServers.value.filter((s: MediaServerSummary) => !s.online),
|
||||
);
|
||||
|
||||
// Mode du drawer
|
||||
const isNavigating = computed(() => currentServer.value !== null)
|
||||
const isNavigating = computed(() => currentServer.value !== null);
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit("update:modelValue", false);
|
||||
}
|
||||
|
||||
async function handleServerClick(server: MediaServerSummary) {
|
||||
if (!server.online) return
|
||||
if (!server.online) return;
|
||||
|
||||
// Commencer la navigation dans ce serveur
|
||||
currentServer.value = server
|
||||
isLoading.value = true
|
||||
currentServer.value = server;
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
// Browse racine (containerId = "0")
|
||||
browseData.value = await browseContainer(server.id, '0')
|
||||
setPath([{ id: '0', title: server.friendly_name }])
|
||||
browseData.value = await browseContainer(server.id, "0");
|
||||
setPath([{ id: "0", title: server.friendly_name }]);
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur browse racine:', error)
|
||||
browseData.value = null
|
||||
console.error("[ServerDrawer] Erreur browse racine:", error);
|
||||
browseData.value = null;
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
currentServer.value = null
|
||||
browseData.value = null
|
||||
clearPath()
|
||||
currentServer.value = null;
|
||||
browseData.value = null;
|
||||
clearPath();
|
||||
}
|
||||
|
||||
async function handleContainerClick(item: ContainerEntry) {
|
||||
if (!currentServer.value) return
|
||||
if (!currentServer.value) return;
|
||||
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
try {
|
||||
browseData.value = await browseContainer(currentServer.value.id, item.id)
|
||||
setPath([...currentPath.value, { id: item.id, title: item.title }])
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
setPath([...currentPath.value, { id: item.id, title: item.title }]);
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur browse container:', error)
|
||||
console.error("[ServerDrawer] Erreur browse container:", error);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBreadcrumbClick(index: number) {
|
||||
if (!currentServer.value || index >= currentPath.value.length) return
|
||||
if (!currentServer.value || index >= currentPath.value.length) return;
|
||||
|
||||
const targetCrumb = currentPath.value[index]
|
||||
if (!targetCrumb) return
|
||||
const targetCrumb = currentPath.value[index];
|
||||
if (!targetCrumb) return;
|
||||
|
||||
isLoading.value = true
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
browseData.value = await browseContainer(currentServer.value.id, targetCrumb.id)
|
||||
setPath(currentPath.value.slice(0, index + 1))
|
||||
browseData.value = await browseContainer(
|
||||
currentServer.value.id,
|
||||
targetCrumb.id,
|
||||
);
|
||||
setPath(currentPath.value.slice(0, index + 1));
|
||||
} catch (error) {
|
||||
console.error('[ServerDrawer] Erreur breadcrumb navigation:', error)
|
||||
console.error("[ServerDrawer] Erreur breadcrumb navigation:", error);
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Détermine si un item est jouable (track ou container jouable comme album/playlist)
|
||||
function isPlayable(item: ContainerEntry): boolean {
|
||||
if (!item.is_container) return true // Les tracks sont toujours jouables
|
||||
if (!item.is_container) return true; // Les tracks sont toujours jouables
|
||||
|
||||
// Containers jouables basés sur la classe UPnP
|
||||
const playableClasses = [
|
||||
'object.container.album',
|
||||
'object.container.playlist',
|
||||
'object.container.person.musicartist'
|
||||
]
|
||||
"object.container.album",
|
||||
"object.container.playlist",
|
||||
"object.container.person.musicartist",
|
||||
];
|
||||
|
||||
return playableClasses.some(cls => item.class.toLowerCase().includes(cls))
|
||||
return playableClasses.some((cls) =>
|
||||
item.class.toLowerCase().includes(cls),
|
||||
);
|
||||
}
|
||||
|
||||
// Détermine si un container est navigable
|
||||
function isNavigable(item: ContainerEntry): boolean {
|
||||
// Tous les containers sont navigables (on laisse le serveur décider si vide)
|
||||
return item.is_container
|
||||
return item.is_container;
|
||||
}
|
||||
|
||||
function handleItemClick(item: ContainerEntry) {
|
||||
if (!currentServer.value) return
|
||||
if (!currentServer.value) return;
|
||||
|
||||
// Les containers sont toujours navigables
|
||||
if (item.is_container) {
|
||||
handleContainerClick(item)
|
||||
handleContainerClick(item);
|
||||
}
|
||||
// Les tracks individuels : on ne fait rien (actions via boutons)
|
||||
}
|
||||
|
||||
function handlePlayItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation()
|
||||
if (!currentServer.value) return
|
||||
emit('play-item', item, currentServer.value.id)
|
||||
function toggleMenu(itemId: string, event: Event) {
|
||||
event.stopPropagation();
|
||||
openMenuId.value = openMenuId.value === itemId ? null : itemId;
|
||||
}
|
||||
|
||||
function handleQueueItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation()
|
||||
if (!currentServer.value) return
|
||||
emit('queue-item', item, currentServer.value.id)
|
||||
function closeMenu() {
|
||||
openMenuId.value = null;
|
||||
}
|
||||
|
||||
async function handlePlayItem(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (item.is_container) {
|
||||
// Container : attacher comme playlist avec auto_play
|
||||
await attachAndPlayPlaylist(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} else {
|
||||
// Item : vider queue + ajouter + jouer
|
||||
await playContent(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error playing item:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddToQueue(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Détacher le binding (fait côté serveur) + ajouter à la fin
|
||||
await addToQueue(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error adding to queue:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddAfterCurrent(event: Event, item: ContainerEntry) {
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
|
||||
if (!currentServer.value || !props.selectedRendererId) {
|
||||
console.warn("[ServerDrawer] No server or renderer selected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Détacher le binding + insérer après current
|
||||
await addAfterCurrent(
|
||||
props.selectedRendererId,
|
||||
currentServer.value.id,
|
||||
item.id,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[ServerDrawer] Error adding after current:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSettingsClick() {
|
||||
router.push('/debug/api-dashboard')
|
||||
close()
|
||||
router.push("/debug/api-dashboard");
|
||||
close();
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -166,27 +308,44 @@ function handleSettingsClick() {
|
||||
<ServerIcon :size="24" />
|
||||
<h2 class="drawer-title">Media Servers</h2>
|
||||
</div>
|
||||
<button class="drawer-close-btn" @click="close" aria-label="Fermer">
|
||||
<button
|
||||
class="drawer-close-btn"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Header - Navigation dans un serveur -->
|
||||
<header v-else class="drawer-header">
|
||||
<button class="back-btn" @click="goBack" aria-label="Retour aux serveurs">
|
||||
<button
|
||||
class="back-btn"
|
||||
@click="goBack"
|
||||
aria-label="Retour aux serveurs"
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
<div class="drawer-title-section">
|
||||
<ServerIcon :size="20" />
|
||||
<h2 class="drawer-title small">{{ currentServer?.friendly_name }}</h2>
|
||||
<h2 class="drawer-title small">
|
||||
{{ currentServer?.friendly_name }}
|
||||
</h2>
|
||||
</div>
|
||||
<button class="drawer-close-btn" @click="close" aria-label="Fermer">
|
||||
<button
|
||||
class="drawer-close-btn"
|
||||
@click="close"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X :size="24" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="isNavigating && currentPath.length > 1" class="breadcrumb">
|
||||
<nav
|
||||
v-if="isNavigating && currentPath.length > 1"
|
||||
class="breadcrumb"
|
||||
>
|
||||
<button
|
||||
v-for="(crumb, index) in currentPath"
|
||||
:key="crumb.id"
|
||||
@@ -195,7 +354,10 @@ function handleSettingsClick() {
|
||||
@click="handleBreadcrumbClick(index)"
|
||||
>
|
||||
{{ crumb.title }}
|
||||
<ChevronRight v-if="index < currentPath.length - 1" :size="14" />
|
||||
<ChevronRight
|
||||
v-if="index < currentPath.length - 1"
|
||||
:size="14"
|
||||
/>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@@ -204,8 +366,13 @@ function handleSettingsClick() {
|
||||
<!-- Liste des serveurs -->
|
||||
<div v-if="!isNavigating">
|
||||
<!-- Servers online -->
|
||||
<section v-if="onlineServers.length > 0" class="server-section">
|
||||
<h3 class="section-title">Disponibles ({{ onlineServers.length }})</h3>
|
||||
<section
|
||||
v-if="onlineServers.length > 0"
|
||||
class="server-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Disponibles ({{ onlineServers.length }})
|
||||
</h3>
|
||||
<ul class="server-list">
|
||||
<li
|
||||
v-for="server in onlineServers"
|
||||
@@ -217,8 +384,15 @@ function handleSettingsClick() {
|
||||
<ServerIcon :size="20" />
|
||||
</div>
|
||||
<div class="server-info">
|
||||
<p class="server-name">{{ server.friendly_name }}</p>
|
||||
<p v-if="server.model_name" class="server-model">{{ server.model_name }}</p>
|
||||
<p class="server-name">
|
||||
{{ server.friendly_name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="server.model_name"
|
||||
class="server-model"
|
||||
>
|
||||
{{ server.model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="server-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
@@ -228,8 +402,13 @@ function handleSettingsClick() {
|
||||
</section>
|
||||
|
||||
<!-- Servers offline -->
|
||||
<section v-if="offlineServers.length > 0" class="server-section">
|
||||
<h3 class="section-title">Hors ligne ({{ offlineServers.length }})</h3>
|
||||
<section
|
||||
v-if="offlineServers.length > 0"
|
||||
class="server-section"
|
||||
>
|
||||
<h3 class="section-title">
|
||||
Hors ligne ({{ offlineServers.length }})
|
||||
</h3>
|
||||
<ul class="server-list">
|
||||
<li
|
||||
v-for="server in offlineServers"
|
||||
@@ -240,8 +419,15 @@ function handleSettingsClick() {
|
||||
<ServerIcon :size="20" />
|
||||
</div>
|
||||
<div class="server-info">
|
||||
<p class="server-name">{{ server.friendly_name }}</p>
|
||||
<p v-if="server.model_name" class="server-model">{{ server.model_name }}</p>
|
||||
<p class="server-name">
|
||||
{{ server.friendly_name }}
|
||||
</p>
|
||||
<p
|
||||
v-if="server.model_name"
|
||||
class="server-model"
|
||||
>
|
||||
{{ server.model_name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="server-status">
|
||||
<Circle :size="8" fill="currentColor" />
|
||||
@@ -251,7 +437,10 @@ function handleSettingsClick() {
|
||||
</section>
|
||||
|
||||
<!-- Aucun server -->
|
||||
<div v-if="allServers.length === 0" class="empty-servers">
|
||||
<div
|
||||
v-if="allServers.length === 0"
|
||||
class="empty-servers"
|
||||
>
|
||||
<ServerIcon :size="48" />
|
||||
<p>Aucun serveur multimédia détecté</p>
|
||||
</div>
|
||||
@@ -271,40 +460,152 @@ function handleSettingsClick() {
|
||||
v-for="item in browseData.entries"
|
||||
:key="item.id"
|
||||
class="content-item"
|
||||
:class="{ navigable: item.is_container && isNavigable(item) }"
|
||||
:class="{
|
||||
navigable:
|
||||
item.is_container && isNavigable(item),
|
||||
'menu-open': openMenuId === item.id,
|
||||
}"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="content-icon">
|
||||
<Folder v-if="item.is_container" :size="20" />
|
||||
<Music v-else :size="20" />
|
||||
<!-- Cover avec image ou icône -->
|
||||
<div class="content-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-image"
|
||||
loading="lazy"
|
||||
@error="
|
||||
(e) => {
|
||||
(
|
||||
e.target as HTMLImageElement
|
||||
).style.display = 'none';
|
||||
const placeholder = (
|
||||
e.target as HTMLElement
|
||||
)
|
||||
.nextElementSibling as HTMLElement;
|
||||
if (placeholder)
|
||||
placeholder.style.display =
|
||||
'flex';
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div
|
||||
class="cover-placeholder"
|
||||
:style="{
|
||||
display: item.album_art_uri
|
||||
? 'none'
|
||||
: 'flex',
|
||||
}"
|
||||
>
|
||||
<Folder
|
||||
v-if="item.is_container"
|
||||
:size="24"
|
||||
/>
|
||||
<Music v-else :size="24" />
|
||||
</div>
|
||||
<!-- Petite icône de type pour containers jouables -->
|
||||
<div
|
||||
v-if="
|
||||
item.is_container &&
|
||||
isPlayable(item)
|
||||
"
|
||||
class="type-badge"
|
||||
>
|
||||
<Folder :size="12" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-info">
|
||||
<p class="content-title">{{ item.title }}</p>
|
||||
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
|
||||
<p v-else-if="item.album" class="content-subtitle">{{ item.album }}</p>
|
||||
<p class="content-title">
|
||||
{{ item.title }}
|
||||
</p>
|
||||
<p
|
||||
v-if="item.artist"
|
||||
class="content-subtitle artist"
|
||||
>
|
||||
{{ item.artist }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="item.album"
|
||||
class="content-subtitle"
|
||||
>
|
||||
{{ item.album }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions pour items jouables -->
|
||||
<div v-if="isPlayable(item)" class="content-actions">
|
||||
<div
|
||||
v-if="isPlayable(item)"
|
||||
class="content-actions"
|
||||
>
|
||||
<!-- Bouton Play -->
|
||||
<button
|
||||
class="action-btn play-btn"
|
||||
:title="item.is_container ? 'Jouer tout' : 'Jouer'"
|
||||
:title="
|
||||
item.is_container
|
||||
? 'Jouer tout'
|
||||
: 'Jouer'
|
||||
"
|
||||
@click="handlePlayItem($event, item)"
|
||||
>
|
||||
<Play :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Bouton Menu avec dropdown -->
|
||||
<div class="action-menu-container">
|
||||
<button
|
||||
class="action-btn queue-btn"
|
||||
:title="item.is_container ? 'Ajouter tout à la queue' : 'Ajouter à la queue'"
|
||||
@click="handleQueueItem($event, item)"
|
||||
class="action-btn menu-btn"
|
||||
:title="'Plus d\'actions'"
|
||||
@click="toggleMenu(item.id, $event)"
|
||||
>
|
||||
<Plus :size="16" />
|
||||
<MoreVertical :size="16" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<Transition name="menu-fade">
|
||||
<div
|
||||
v-if="openMenuId === item.id"
|
||||
class="action-dropdown"
|
||||
@click.stop
|
||||
>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
handleAddToQueue(
|
||||
$event,
|
||||
item,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
<span
|
||||
>Ajouter à la
|
||||
queue</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
class="dropdown-item"
|
||||
@click="
|
||||
handleAddAfterCurrent(
|
||||
$event,
|
||||
item,
|
||||
)
|
||||
"
|
||||
>
|
||||
<Plus :size="14" />
|
||||
<span>Ajouter après</span>
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chevron pour containers navigables (même si jouables) -->
|
||||
<ChevronRight
|
||||
v-if="item.is_container && isNavigable(item)"
|
||||
v-if="
|
||||
item.is_container && isNavigable(item)
|
||||
"
|
||||
:size="16"
|
||||
class="content-chevron"
|
||||
/>
|
||||
@@ -321,7 +622,11 @@ function handleSettingsClick() {
|
||||
|
||||
<!-- Footer avec bouton settings -->
|
||||
<footer class="drawer-footer">
|
||||
<button class="settings-btn" @click="handleSettingsClick" title="Ouvrir le menu Debug">
|
||||
<button
|
||||
class="settings-btn"
|
||||
@click="handleSettingsClick"
|
||||
title="Ouvrir le menu Debug"
|
||||
>
|
||||
<Settings :size="20" />
|
||||
<span>Debug & Config</span>
|
||||
</button>
|
||||
@@ -346,7 +651,8 @@ function handleSettingsClick() {
|
||||
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.drawer-backdrop {
|
||||
left: 80vw; /* Mobile portrait: 80vw */
|
||||
left: 0; /* Mobile portrait: backdrop commence à gauche car drawer prend 100vw */
|
||||
background: rgba(0, 0, 0, 0.4); /* Plus sombre sur mobile */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,11 +662,11 @@ function handleSettingsClick() {
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 50vw; /* Desktop/landscape: 50% de l'écran */
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(30px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.2);
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08); /* Plus transparent */
|
||||
backdrop-filter: blur(40px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(40px) saturate(180%);
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.15);
|
||||
box-shadow: 4px 0 32px rgba(0, 0, 0, 0.25);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -633,16 +939,56 @@ function handleSettingsClick() {
|
||||
transform: translateX(1px);
|
||||
}
|
||||
|
||||
.content-icon {
|
||||
.content-item.menu-open {
|
||||
z-index: 200; /* Passe au-dessus des autres items quand son menu est ouvert */
|
||||
}
|
||||
|
||||
/* Cover avec image */
|
||||
.content-cover {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--color-text-secondary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.65);
|
||||
backdrop-filter: blur(4px);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.content-info {
|
||||
@@ -669,6 +1015,11 @@ function handleSettingsClick() {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.content-subtitle.artist {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.content-chevron {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-text-tertiary);
|
||||
@@ -682,7 +1033,8 @@ function handleSettingsClick() {
|
||||
flex-shrink: 0;
|
||||
opacity: 1; /* Toujours visible pour le tactile */
|
||||
transition: all 0.2s ease;
|
||||
z-index: 1; /* Au-dessus pour capturer les clicks */
|
||||
z-index: 10; /* Au-dessus pour capturer les clicks */
|
||||
position: relative; /* Crée un contexte de stacking */
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
@@ -721,15 +1073,92 @@ function handleSettingsClick() {
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
.queue-btn {
|
||||
.menu-btn {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.queue-btn:hover {
|
||||
.menu-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Menu dropdown container */
|
||||
.action-menu-container {
|
||||
position: relative;
|
||||
z-index: 100; /* Plus élevé que les content-item pour que le dropdown passe au-dessus */
|
||||
}
|
||||
|
||||
/* Dropdown menu */
|
||||
.action-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 180px;
|
||||
background: rgba(20, 20, 30, 0.98);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||
padding: 4px;
|
||||
z-index: 10000; /* Très haut pour passer au-dessus de tout */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
.action-dropdown {
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dropdown items */
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dropdown-item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.dropdown-item:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.dropdown-item span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Menu fade animation */
|
||||
.menu-fade-enter-active {
|
||||
transition: all 0.15s ease-out;
|
||||
}
|
||||
|
||||
.menu-fade-leave-active {
|
||||
transition: all 0.1s ease-in;
|
||||
}
|
||||
|
||||
.menu-fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px) scale(0.95);
|
||||
}
|
||||
|
||||
.menu-fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px) scale(0.98);
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.loading-state {
|
||||
display: flex;
|
||||
@@ -815,12 +1244,12 @@ function handleSettingsClick() {
|
||||
|
||||
/* Animations */
|
||||
.backdrop-enter-active {
|
||||
transition: opacity 0.2s ease;
|
||||
transition-delay: 0.15s; /* Attend que le drawer soit à moitié visible */
|
||||
transition: opacity 0.3s ease-out;
|
||||
transition-delay: 0.1s; /* Attend que le drawer soit un peu visible */
|
||||
}
|
||||
|
||||
.backdrop-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
transition: opacity 0.25s ease-in;
|
||||
/* Pas de delay au leave - disparaît en même temps que le drawer */
|
||||
}
|
||||
|
||||
@@ -829,16 +1258,26 @@ function handleSettingsClick() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-active,
|
||||
.drawer-leave-active {
|
||||
transition: transform 0.3s ease;
|
||||
.drawer-enter-active {
|
||||
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1); /* Courbe d'animation fluide (easeOutExpo) */
|
||||
}
|
||||
|
||||
.drawer-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.7, 0, 0.84, 0); /* Courbe d'animation de sortie (easeInExpo) */
|
||||
}
|
||||
|
||||
.drawer-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.drawer-enter-from,
|
||||
.drawer-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Animation des contenus - désactivée pour éviter les problèmes de z-index en escalier */
|
||||
|
||||
/* Scrollbar styling */
|
||||
.drawer-content::-webkit-scrollbar,
|
||||
.breadcrumb::-webkit-scrollbar {
|
||||
@@ -866,7 +1305,14 @@ function handleSettingsClick() {
|
||||
/* Mobile responsive - portrait */
|
||||
@media (max-width: 768px) and (orientation: portrait) {
|
||||
.server-drawer {
|
||||
width: 80vw; /* Mobile portrait: 80% de l'écran */
|
||||
width: 100vw; /* Mobile portrait: 100% de l'écran */
|
||||
background: rgba(
|
||||
255,
|
||||
255,
|
||||
255,
|
||||
0.06
|
||||
); /* Encore plus transparent sur mobile */
|
||||
box-shadow: none; /* Pas d'ombre sur les côtés */
|
||||
}
|
||||
|
||||
.drawer-header {
|
||||
@@ -876,6 +1322,16 @@ function handleSettingsClick() {
|
||||
.drawer-title {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* Ajuster les items pour mobile */
|
||||
.content-item {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
|
||||
.content-cover {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback pour navigateurs sans backdrop-filter */
|
||||
|
||||
@@ -4,28 +4,28 @@
|
||||
* - Les snapshots complets proviennent de /renderers/{id}/full
|
||||
* - Les événements SSE ne servent qu'à déclencher un refetch.
|
||||
*/
|
||||
import { ref, reactive, computed, type Ref } from 'vue'
|
||||
import { api } from '../services/pmocontrol/api'
|
||||
import { sse } from '../services/pmocontrol/sse'
|
||||
import { ref, reactive, computed, type Ref } from "vue";
|
||||
import { api } from "../services/pmocontrol/api";
|
||||
import { sse } from "../services/pmocontrol/sse";
|
||||
import type {
|
||||
RendererSummary,
|
||||
RendererState,
|
||||
QueueSnapshot,
|
||||
AttachedPlaylistInfo,
|
||||
FullRendererSnapshot,
|
||||
} from '../services/pmocontrol/types'
|
||||
} from "../services/pmocontrol/types";
|
||||
|
||||
interface RendererSnapshotState {
|
||||
snapshots: Map<string, FullRendererSnapshot>
|
||||
lastSnapshotAt: Map<string, number>
|
||||
lastEventAt: Map<string, number>
|
||||
loadingIds: Set<string>
|
||||
selectedRendererId: string | null
|
||||
snapshots: Map<string, FullRendererSnapshot>;
|
||||
lastSnapshotAt: Map<string, number>;
|
||||
lastEventAt: Map<string, number>;
|
||||
loadingIds: Set<string>;
|
||||
selectedRendererId: string | null;
|
||||
}
|
||||
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map())
|
||||
const RENDERERS_CACHE_MS = 2000
|
||||
const lastRenderersFetch = ref(0)
|
||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||
const RENDERERS_CACHE_MS = 2000;
|
||||
const lastRenderersFetch = ref(0);
|
||||
|
||||
const snapshotState = reactive<RendererSnapshotState>({
|
||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||
@@ -33,23 +33,25 @@ const snapshotState = reactive<RendererSnapshotState>({
|
||||
lastEventAt: reactive(new Map<string, number>()),
|
||||
loadingIds: reactive(new Set<string>()),
|
||||
selectedRendererId: null,
|
||||
})
|
||||
});
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
let sseConnected = false
|
||||
let sseConnected = false;
|
||||
function ensureSSEConnected() {
|
||||
if (sseConnected) return
|
||||
if (sseConnected) return;
|
||||
|
||||
sse.onRendererEvent((event) => {
|
||||
const rendererId = event.renderer_id
|
||||
const timestamp = Date.parse(event.timestamp ?? '') || Date.now()
|
||||
const rendererId = event.renderer_id;
|
||||
const timestamp = Date.parse(event.timestamp ?? "") || Date.now();
|
||||
|
||||
// Gérer les événements Online/Offline différemment
|
||||
if (event.type === 'online') {
|
||||
if (event.type === "online") {
|
||||
// Nouveau renderer découvert
|
||||
console.log(`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`)
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} (${event.friendly_name}) est maintenant en ligne`,
|
||||
);
|
||||
|
||||
// Ajouter au cache avec les infos disponibles
|
||||
// Note: on n'a pas toutes les infos (capabilities, protocol) donc on fetch ensuite
|
||||
@@ -57,7 +59,7 @@ function ensureSSEConnected() {
|
||||
id: rendererId,
|
||||
friendly_name: event.friendly_name,
|
||||
model_name: event.model_name,
|
||||
protocol: 'upnp', // Valeur par défaut, sera mise à jour par le fetch
|
||||
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
|
||||
capabilities: {
|
||||
has_avtransport: false,
|
||||
has_avtransport_set_next: false,
|
||||
@@ -72,187 +74,291 @@ function ensureSSEConnected() {
|
||||
has_oh_radio: false,
|
||||
},
|
||||
online: true,
|
||||
}
|
||||
renderersCache.value.set(rendererId, renderer)
|
||||
};
|
||||
renderersCache.value.set(rendererId, renderer);
|
||||
|
||||
// Fetch la liste complète pour avoir les bonnes infos
|
||||
void fetchRenderers(true)
|
||||
void fetchRenderers(true);
|
||||
|
||||
// Fetch le snapshot complet pour ce renderer
|
||||
void fetchRendererSnapshot(rendererId, { force: true })
|
||||
return
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'offline') {
|
||||
if (event.type === "offline") {
|
||||
// Renderer déconnecté
|
||||
console.log(`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`)
|
||||
console.log(
|
||||
`[useRenderers] Renderer ${rendererId} est maintenant hors ligne`,
|
||||
);
|
||||
|
||||
// Marquer comme offline dans le cache
|
||||
const renderer = renderersCache.value.get(rendererId)
|
||||
const renderer = renderersCache.value.get(rendererId);
|
||||
if (renderer) {
|
||||
renderer.online = false
|
||||
renderersCache.value.set(rendererId, renderer)
|
||||
renderer.online = false;
|
||||
renderersCache.value.set(rendererId, renderer);
|
||||
}
|
||||
|
||||
// Supprimer le snapshot (il n'est plus valide)
|
||||
snapshotState.snapshots.delete(rendererId)
|
||||
snapshotState.lastSnapshotAt.delete(rendererId)
|
||||
snapshotState.lastEventAt.delete(rendererId)
|
||||
return
|
||||
snapshotState.snapshots.delete(rendererId);
|
||||
snapshotState.lastSnapshotAt.delete(rendererId);
|
||||
snapshotState.lastEventAt.delete(rendererId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pour les autres événements, comportement existant
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp)
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
if (!snapshotState.snapshots.has(rendererId) || timestamp > lastSnapshot) {
|
||||
void fetchRendererSnapshot(rendererId, { force: true })
|
||||
}
|
||||
})
|
||||
// Pour les autres événements, mettre à jour le snapshot local directement
|
||||
snapshotState.lastEventAt.set(rendererId, timestamp);
|
||||
|
||||
sseConnected = true
|
||||
const snapshot = snapshotState.snapshots.get(rendererId);
|
||||
|
||||
// Si pas de snapshot, on doit fetch
|
||||
if (!snapshot) {
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon, mettre à jour le snapshot localement selon le type d'événement
|
||||
switch (event.type) {
|
||||
case "state_changed":
|
||||
snapshot.state.transport_state = event.state as any;
|
||||
break;
|
||||
|
||||
case "position_changed":
|
||||
// Convertir rel_time (HH:MM:SS) en millisecondes
|
||||
if (event.rel_time) {
|
||||
const parts = event.rel_time.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.position_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
}
|
||||
// Convertir track_duration (HH:MM:SS) en millisecondes
|
||||
if (event.track_duration) {
|
||||
const parts = event.track_duration.split(":").map(Number);
|
||||
if (parts.length === 3) {
|
||||
snapshot.state.duration_ms =
|
||||
((parts[0] ?? 0) * 3600 +
|
||||
(parts[1] ?? 0) * 60 +
|
||||
(parts[2] ?? 0)) *
|
||||
1000;
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "volume_changed":
|
||||
snapshot.state.volume = event.volume;
|
||||
break;
|
||||
|
||||
case "mute_changed":
|
||||
snapshot.state.mute = event.mute;
|
||||
break;
|
||||
|
||||
case "metadata_changed":
|
||||
if (!snapshot.state.current_track) {
|
||||
snapshot.state.current_track = {
|
||||
title: null,
|
||||
artist: null,
|
||||
album: null,
|
||||
album_art_uri: null,
|
||||
};
|
||||
}
|
||||
snapshot.state.current_track.title = event.title;
|
||||
snapshot.state.current_track.artist = event.artist;
|
||||
snapshot.state.current_track.album = event.album;
|
||||
snapshot.state.current_track.album_art_uri = event.album_art_uri;
|
||||
break;
|
||||
|
||||
case "queue_updated":
|
||||
snapshot.state.queue_len = event.queue_length;
|
||||
// Pour la queue complète, on doit refetch
|
||||
void fetchRendererSnapshot(rendererId, { force: true });
|
||||
break;
|
||||
|
||||
case "binding_changed":
|
||||
if (event.server_id && event.container_id) {
|
||||
snapshot.binding = {
|
||||
server_id: event.server_id,
|
||||
container_id: event.container_id,
|
||||
has_seen_update: false,
|
||||
};
|
||||
snapshot.state.attached_playlist = snapshot.binding;
|
||||
} else {
|
||||
snapshot.binding = null;
|
||||
snapshot.state.attached_playlist = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case "timer_started":
|
||||
case "timer_updated":
|
||||
case "timer_tick":
|
||||
case "timer_expired":
|
||||
case "timer_cancelled":
|
||||
// Les événements de timer ne modifient pas le snapshot renderer
|
||||
// (le timer state est géré séparément)
|
||||
break;
|
||||
}
|
||||
|
||||
// Trigger reactivity
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
});
|
||||
|
||||
sseConnected = true;
|
||||
}
|
||||
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()))
|
||||
const onlineRenderers = computed(() => allRenderers.value.filter((r) => r.online))
|
||||
const allSnapshots = computed(() => Array.from(snapshotState.snapshots.values()))
|
||||
const allRenderers = computed(() => Array.from(renderersCache.value.values()));
|
||||
const onlineRenderers = computed(() =>
|
||||
allRenderers.value.filter((r) => r.online),
|
||||
);
|
||||
const allSnapshots = computed(() =>
|
||||
Array.from(snapshotState.snapshots.values()),
|
||||
);
|
||||
const playingRenderers = computed(() =>
|
||||
allSnapshots.value
|
||||
.filter((snapshot) => snapshot.state.transport_state === 'PLAYING')
|
||||
.filter((snapshot) => snapshot.state.transport_state === "PLAYING")
|
||||
.map((snapshot) => snapshot.state),
|
||||
)
|
||||
);
|
||||
|
||||
function getRendererById(id: string) {
|
||||
return renderersCache.value.get(id)
|
||||
return renderersCache.value.get(id);
|
||||
}
|
||||
|
||||
function getSnapshotById(id: string) {
|
||||
return snapshotState.snapshots.get(id) ?? null
|
||||
return snapshotState.snapshots.get(id) ?? null;
|
||||
}
|
||||
|
||||
function getStateById(id: string): RendererState | null {
|
||||
return snapshotState.snapshots.get(id)?.state ?? null
|
||||
return snapshotState.snapshots.get(id)?.state ?? null;
|
||||
}
|
||||
|
||||
function getQueueById(id: string): QueueSnapshot | null {
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null
|
||||
return snapshotState.snapshots.get(id)?.queue ?? null;
|
||||
}
|
||||
|
||||
function getBindingById(id: string): AttachedPlaylistInfo | null {
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null
|
||||
return snapshotState.snapshots.get(id)?.binding ?? null;
|
||||
}
|
||||
|
||||
function isSnapshotLoading(id: string) {
|
||||
return snapshotState.loadingIds.has(id)
|
||||
return snapshotState.loadingIds.has(id);
|
||||
}
|
||||
|
||||
function selectRenderer(id: string | null) {
|
||||
snapshotState.selectedRendererId = id
|
||||
snapshotState.selectedRendererId = id;
|
||||
}
|
||||
|
||||
async function fetchRenderers(force = false) {
|
||||
ensureSSEConnected()
|
||||
ensureSSEConnected();
|
||||
|
||||
const now = Date.now()
|
||||
const now = Date.now();
|
||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const data = await api.getRenderers()
|
||||
renderersCache.value = new Map(data.map((renderer) => [renderer.id, renderer]))
|
||||
lastRenderersFetch.value = now
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const data = await api.getRenderers();
|
||||
renderersCache.value = new Map(
|
||||
data.map((renderer) => [renderer.id, renderer]),
|
||||
);
|
||||
lastRenderersFetch.value = now;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Erreur fetch renderers'
|
||||
console.error('[useRenderers] Erreur fetch:', err)
|
||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||
console.error("[useRenderers] Erreur fetch:", err);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchRendererSnapshot(rendererId: string, opts?: { force?: boolean }) {
|
||||
ensureSSEConnected()
|
||||
const force = opts?.force ?? false
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId)
|
||||
async function fetchRendererSnapshot(
|
||||
rendererId: string,
|
||||
opts?: { force?: boolean },
|
||||
) {
|
||||
ensureSSEConnected();
|
||||
const force = opts?.force ?? false;
|
||||
const hasSnapshot = snapshotState.snapshots.has(rendererId);
|
||||
|
||||
if (!force && hasSnapshot) {
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0
|
||||
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
|
||||
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
|
||||
if (lastEvent <= lastSnapshot) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (snapshotState.loadingIds.has(rendererId)) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
snapshotState.loadingIds.add(rendererId)
|
||||
snapshotState.loadingIds.add(rendererId);
|
||||
try {
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId)
|
||||
snapshotState.snapshots.set(rendererId, snapshot)
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now())
|
||||
const snapshot = await api.getRendererFullSnapshot(rendererId);
|
||||
snapshotState.snapshots.set(rendererId, snapshot);
|
||||
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
|
||||
} catch (err) {
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err)
|
||||
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
|
||||
} finally {
|
||||
snapshotState.loadingIds.delete(rendererId)
|
||||
snapshotState.loadingIds.delete(rendererId);
|
||||
}
|
||||
}
|
||||
|
||||
// Transport controls
|
||||
async function play(id: string) {
|
||||
await api.play(id)
|
||||
await api.play(id);
|
||||
}
|
||||
|
||||
async function resumeOrPlayFromQueue(id: string) {
|
||||
const snapshot = snapshotState.snapshots.get(id)
|
||||
const snapshot = snapshotState.snapshots.get(id);
|
||||
if (!snapshot) {
|
||||
throw new Error(`Renderer ${id} non trouvé`)
|
||||
throw new Error(`Renderer ${id} non trouvé`);
|
||||
}
|
||||
|
||||
const state = snapshot.state
|
||||
if (state.transport_state === 'PAUSED') {
|
||||
return play(id)
|
||||
const state = snapshot.state;
|
||||
if (state.transport_state === "PAUSED") {
|
||||
return play(id);
|
||||
}
|
||||
|
||||
if (
|
||||
['STOPPED', 'NO_MEDIA'].includes(state.transport_state) &&
|
||||
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
|
||||
snapshot.queue.items.length > 0
|
||||
) {
|
||||
return api.resume(id)
|
||||
return api.resume(id);
|
||||
}
|
||||
|
||||
throw new Error('La file d\'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.')
|
||||
throw new Error(
|
||||
"La file d'attente est vide. Ajoutez des morceaux avant de démarrer la lecture.",
|
||||
);
|
||||
}
|
||||
|
||||
async function pause(id: string) {
|
||||
await api.pause(id)
|
||||
await api.pause(id);
|
||||
}
|
||||
|
||||
async function stop(id: string) {
|
||||
await api.stop(id)
|
||||
await api.stop(id);
|
||||
}
|
||||
|
||||
async function next(id: string) {
|
||||
await api.next(id)
|
||||
await api.next(id);
|
||||
}
|
||||
|
||||
// Volume controls
|
||||
async function setVolume(id: string, volume: number) {
|
||||
await api.setVolume(id, volume)
|
||||
await api.setVolume(id, volume);
|
||||
}
|
||||
|
||||
async function volumeUp(id: string) {
|
||||
await api.volumeUp(id)
|
||||
await api.volumeUp(id);
|
||||
}
|
||||
|
||||
async function volumeDown(id: string) {
|
||||
await api.volumeDown(id)
|
||||
await api.volumeDown(id);
|
||||
}
|
||||
|
||||
async function toggleMute(id: string) {
|
||||
await api.toggleMute(id)
|
||||
await api.toggleMute(id);
|
||||
}
|
||||
|
||||
// Playlist binding
|
||||
@@ -262,11 +368,16 @@ async function attachPlaylist(
|
||||
containerId: string,
|
||||
options?: { autoPlay?: boolean },
|
||||
) {
|
||||
await api.attachPlaylist(rendererId, serverId, containerId, options?.autoPlay ?? false)
|
||||
await api.attachPlaylist(
|
||||
rendererId,
|
||||
serverId,
|
||||
containerId,
|
||||
options?.autoPlay ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
async function detachPlaylist(rendererId: string) {
|
||||
await api.detachPlaylist(rendererId)
|
||||
await api.detachPlaylist(rendererId);
|
||||
}
|
||||
|
||||
async function attachAndPlayPlaylist(
|
||||
@@ -274,20 +385,36 @@ async function attachAndPlayPlaylist(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
) {
|
||||
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true })
|
||||
await attachPlaylist(rendererId, serverId, containerId, { autoPlay: true });
|
||||
}
|
||||
|
||||
// Queue content
|
||||
async function playContent(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.playContent(rendererId, serverId, objectId)
|
||||
async function playContent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string,
|
||||
) {
|
||||
await api.playContent(rendererId, serverId, objectId);
|
||||
}
|
||||
|
||||
async function addToQueue(rendererId: string, serverId: string, objectId: string) {
|
||||
await api.addToQueue(rendererId, serverId, objectId)
|
||||
async function addToQueue(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string,
|
||||
) {
|
||||
await api.addToQueue(rendererId, serverId, objectId);
|
||||
}
|
||||
|
||||
async function addAfterCurrent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string,
|
||||
) {
|
||||
await api.addAfterCurrent(rendererId, serverId, objectId);
|
||||
}
|
||||
|
||||
export function useRenderers() {
|
||||
ensureSSEConnected()
|
||||
ensureSSEConnected();
|
||||
|
||||
return {
|
||||
loading,
|
||||
@@ -326,23 +453,26 @@ export function useRenderers() {
|
||||
// Queue content
|
||||
playContent,
|
||||
addToQueue,
|
||||
}
|
||||
addAfterCurrent,
|
||||
};
|
||||
}
|
||||
|
||||
export function useRenderer(rendererId: Ref<string>) {
|
||||
ensureSSEConnected()
|
||||
ensureSSEConnected();
|
||||
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value))
|
||||
const snapshot = computed(() => snapshotState.snapshots.get(rendererId.value) ?? null)
|
||||
const state = computed(() => snapshot.value?.state ?? null)
|
||||
const queue = computed(() => snapshot.value?.queue ?? null)
|
||||
const binding = computed(() => snapshot.value?.binding ?? null)
|
||||
const renderer = computed(() => renderersCache.value.get(rendererId.value));
|
||||
const snapshot = computed(
|
||||
() => snapshotState.snapshots.get(rendererId.value) ?? null,
|
||||
);
|
||||
const state = computed(() => snapshot.value?.state ?? null);
|
||||
const queue = computed(() => snapshot.value?.queue ?? null);
|
||||
const binding = computed(() => snapshot.value?.binding ?? null);
|
||||
|
||||
async function refresh(force = true) {
|
||||
await Promise.all([
|
||||
fetchRenderers(force),
|
||||
fetchRendererSnapshot(rendererId.value, { force: true }),
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -352,5 +482,5 @@ export function useRenderer(rendererId: Ref<string>) {
|
||||
queue,
|
||||
binding,
|
||||
refresh,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
import type {
|
||||
OpenHomePlaylistAddRequest,
|
||||
OpenHomePlaylistSnapshot,
|
||||
} from '@/services/pmocontrol/types'
|
||||
|
||||
const API_BASE = '/api/control'
|
||||
|
||||
export async function getOpenHomePlaylist(rendererId: string): Promise<OpenHomePlaylistSnapshot> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist`,
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to fetch OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
|
||||
export async function clearOpenHomePlaylist(rendererId: string): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/clear`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to clear OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function addOpenHomeTrack(
|
||||
rendererId: string,
|
||||
payload: OpenHomePlaylistAddRequest,
|
||||
): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(rendererId)}/oh/playlist/add`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to add track to OpenHome playlist: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function playOpenHomeTrack(rendererId: string, trackId: number): Promise<void> {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/renderers/${encodeURIComponent(
|
||||
rendererId,
|
||||
)}/oh/playlist/play/${encodeURIComponent(trackId.toString())}`,
|
||||
{ method: 'POST' },
|
||||
)
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Failed to play OpenHome track ${trackId}: ${resp.status} ${resp.statusText}`)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export interface PlaylistSummary {
|
||||
persistent: boolean;
|
||||
cover_pk?: string | null;
|
||||
cover_url?: string | null;
|
||||
artist?: string | null;
|
||||
track_count: number;
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
@@ -49,6 +50,7 @@ export interface UpdatePlaylistPayload {
|
||||
max_size?: number | null;
|
||||
default_ttl_secs?: number | null;
|
||||
cover_pk?: string | null;
|
||||
artist?: string | null;
|
||||
}
|
||||
|
||||
export interface AddTracksPayload {
|
||||
@@ -99,7 +101,9 @@ export async function getPlaylistDetail(id: string): Promise<PlaylistDetail> {
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function createPlaylist(body: CreatePlaylistPayload): Promise<PlaylistDetail> {
|
||||
export async function createPlaylist(
|
||||
body: CreatePlaylistPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch("/api/playlists", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -112,7 +116,7 @@ export async function createPlaylist(body: CreatePlaylistPayload): Promise<Playl
|
||||
|
||||
export async function updatePlaylist(
|
||||
id: string,
|
||||
body: UpdatePlaylistPayload
|
||||
body: UpdatePlaylistPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
@@ -133,31 +137,40 @@ export async function deletePlaylist(id: string): Promise<void> {
|
||||
|
||||
export async function addTracksToPlaylist(
|
||||
id: string,
|
||||
payload: AddTracksPayload
|
||||
payload: AddTracksPayload,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function flushPlaylist(id: string): Promise<PlaylistDetail> {
|
||||
const response = await fetch(`/api/playlists/${encodeURIComponent(id)}/tracks`, {
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks`,
|
||||
{
|
||||
method: "DELETE",
|
||||
});
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
export async function removeTrackFromPlaylist(id: string, cachePk: string): Promise<PlaylistDetail> {
|
||||
export async function removeTrackFromPlaylist(
|
||||
id: string,
|
||||
cachePk: string,
|
||||
): Promise<PlaylistDetail> {
|
||||
const response = await fetch(
|
||||
`/api/playlists/${encodeURIComponent(id)}/tracks/${encodeURIComponent(cachePk)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
},
|
||||
);
|
||||
return parseJsonOrThrow(response);
|
||||
}
|
||||
|
||||
@@ -12,41 +12,42 @@ import type {
|
||||
VolumeSetRequest,
|
||||
AttachPlaylistRequest,
|
||||
PlayContentRequest,
|
||||
SleepTimerState,
|
||||
SuccessResponse,
|
||||
ErrorResponse
|
||||
} from './types'
|
||||
ErrorResponse,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Client API REST pour le Control Point PMOMusic
|
||||
*/
|
||||
class PMOControlAPI {
|
||||
private readonly baseURL = '/api/control'
|
||||
private readonly baseURL = "/api/control";
|
||||
|
||||
/**
|
||||
* Effectue une requête HTTP générique
|
||||
*/
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: RequestInit = {}
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const url = `${this.baseURL}${path}`
|
||||
const url = `${this.baseURL}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error: ErrorResponse = await response.json().catch(() => ({
|
||||
error: `HTTP ${response.status}: ${response.statusText}`,
|
||||
}))
|
||||
throw new Error(error.error)
|
||||
}));
|
||||
throw new Error(error.error);
|
||||
}
|
||||
|
||||
return response.json()
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -58,7 +59,7 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers
|
||||
*/
|
||||
async getRenderers(): Promise<RendererSummary[]> {
|
||||
return this.request<RendererSummary[]>('/renderers')
|
||||
return this.request<RendererSummary[]>("/renderers");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +67,7 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}
|
||||
*/
|
||||
async getRendererState(id: string): Promise<RendererState> {
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`)
|
||||
return this.request<RendererState>(`/renderers/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,7 +75,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/full
|
||||
*/
|
||||
async getRendererFullSnapshot(id: string): Promise<FullRendererSnapshot> {
|
||||
return this.request<FullRendererSnapshot>(`/renderers/${encodeURIComponent(id)}/full`)
|
||||
return this.request<FullRendererSnapshot>(
|
||||
`/renderers/${encodeURIComponent(id)}/full`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -82,7 +85,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/queue
|
||||
*/
|
||||
async getQueue(id: string): Promise<QueueSnapshot> {
|
||||
return this.request<QueueSnapshot>(`/renderers/${encodeURIComponent(id)}/queue`)
|
||||
return this.request<QueueSnapshot>(
|
||||
`/renderers/${encodeURIComponent(id)}/queue`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +95,9 @@ class PMOControlAPI {
|
||||
* GET /api/control/renderers/{id}/binding
|
||||
*/
|
||||
async getBinding(id: string): Promise<AttachedPlaylistInfo | null> {
|
||||
return this.request<AttachedPlaylistInfo | null>(`/renderers/${encodeURIComponent(id)}/binding`)
|
||||
return this.request<AttachedPlaylistInfo | null>(
|
||||
`/renderers/${encodeURIComponent(id)}/binding`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -102,9 +109,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/play
|
||||
*/
|
||||
async play(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/play`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/play`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +122,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/pause
|
||||
*/
|
||||
async pause(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/pause`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/pause`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,9 +135,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/stop
|
||||
*/
|
||||
async stop(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/stop`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/stop`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,9 +148,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/resume
|
||||
*/
|
||||
async resume(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/resume`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/resume`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -142,9 +161,26 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/next
|
||||
*/
|
||||
async next(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/next`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/next`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Seek à une position spécifique (en secondes)
|
||||
* POST /api/control/renderers/{id}/seek
|
||||
*/
|
||||
async seekTo(id: string, seconds: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/seek`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ seconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -152,10 +188,26 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/queue/seek
|
||||
*/
|
||||
async seekQueueIndex(id: string, index: number): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/queue/seek`, {
|
||||
method: 'POST',
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/queue/seek`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ index }),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mélange la queue de lecture et démarre au premier morceau
|
||||
* POST /api/control/renderers/{id}/queue/shuffle
|
||||
*/
|
||||
async shuffleQueue(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/queue/shuffle`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -167,11 +219,14 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/set
|
||||
*/
|
||||
async setVolume(id: string, volume: number): Promise<SuccessResponse> {
|
||||
const payload: VolumeSetRequest = { volume }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/set`, {
|
||||
method: 'POST',
|
||||
const payload: VolumeSetRequest = { volume };
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/set`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,9 +234,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/up
|
||||
*/
|
||||
async volumeUp(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/up`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/up`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -189,9 +247,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/volume/down
|
||||
*/
|
||||
async volumeDown(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/volume/down`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/volume/down`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -199,9 +260,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/mute/toggle
|
||||
*/
|
||||
async toggleMute(id: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(id)}/mute/toggle`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(id)}/mute/toggle`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -216,17 +280,20 @@ class PMOControlAPI {
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
autoPlay = false
|
||||
autoPlay = false,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: AttachPlaylistRequest = {
|
||||
server_id: serverId,
|
||||
container_id: containerId,
|
||||
auto_play: autoPlay,
|
||||
}
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/attach`, {
|
||||
method: 'POST',
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/binding/attach`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -234,9 +301,12 @@ class PMOControlAPI {
|
||||
* POST /api/control/renderers/{id}/binding/detach
|
||||
*/
|
||||
async detachPlaylist(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/binding/detach`, {
|
||||
method: 'POST',
|
||||
})
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/binding/detach`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -250,13 +320,19 @@ class PMOControlAPI {
|
||||
async playContent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/play`, {
|
||||
method: 'POST',
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/play`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,13 +342,61 @@ class PMOControlAPI {
|
||||
async addToQueue(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = { server_id: serverId, object_id: objectId }
|
||||
return this.request<SuccessResponse>(`/renderers/${encodeURIComponent(rendererId)}/queue/add`, {
|
||||
method: 'POST',
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/add`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajouter du contenu après le morceau actuel
|
||||
* POST /api/control/renderers/{id}/queue/add-after
|
||||
*/
|
||||
async addAfterCurrent(
|
||||
rendererId: string,
|
||||
serverId: string,
|
||||
objectId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload: PlayContentRequest = {
|
||||
server_id: serverId,
|
||||
object_id: objectId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/queue/add-after`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transfère la queue d'un renderer vers un autre
|
||||
* POST /api/control/renderers/{id}/queue/transfer
|
||||
*/
|
||||
async transferQueue(
|
||||
sourceRendererId: string,
|
||||
destinationRendererId: string,
|
||||
): Promise<SuccessResponse> {
|
||||
const payload = {
|
||||
destination_renderer_id: destinationRendererId,
|
||||
};
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(sourceRendererId)}/queue/transfer`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -284,19 +408,83 @@ class PMOControlAPI {
|
||||
* GET /api/control/servers
|
||||
*/
|
||||
async getServers(): Promise<MediaServerSummary[]> {
|
||||
return this.request<MediaServerSummary[]>('/servers')
|
||||
return this.request<MediaServerSummary[]>("/servers");
|
||||
}
|
||||
|
||||
/**
|
||||
* Browse le contenu d'un container sur un serveur
|
||||
* GET /api/control/servers/{serverId}/containers/{containerId}
|
||||
*/
|
||||
async browseContainer(serverId: string, containerId: string): Promise<BrowseResponse> {
|
||||
async browseContainer(
|
||||
serverId: string,
|
||||
containerId: string,
|
||||
): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`
|
||||
)
|
||||
`/servers/${encodeURIComponent(serverId)}/containers/${encodeURIComponent(containerId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Récupère l'état du sleep timer
|
||||
* GET /api/control/renderers/{rendererId}/timer
|
||||
*/
|
||||
async getSleepTimer(rendererId: string): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Démarre le sleep timer
|
||||
* POST /api/control/renderers/{rendererId}/timer/start
|
||||
*/
|
||||
async startSleepTimer(
|
||||
rendererId: string,
|
||||
durationSeconds: number,
|
||||
): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/start`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le sleep timer (modifie la durée)
|
||||
* POST /api/control/renderers/{rendererId}/timer/update
|
||||
*/
|
||||
async updateSleepTimer(
|
||||
rendererId: string,
|
||||
durationSeconds: number,
|
||||
): Promise<SleepTimerState> {
|
||||
return this.request<SleepTimerState>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/update`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ duration_seconds: durationSeconds }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Annule le sleep timer
|
||||
* POST /api/control/renderers/{rendererId}/timer/cancel
|
||||
*/
|
||||
async cancelSleepTimer(rendererId: string): Promise<SuccessResponse> {
|
||||
return this.request<SuccessResponse>(
|
||||
`/renderers/${encodeURIComponent(rendererId)}/timer/cancel`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton
|
||||
export const api = new PMOControlAPI()
|
||||
export const api = new PMOControlAPI();
|
||||
|
||||
@@ -5,55 +5,65 @@
|
||||
// RENDERERS
|
||||
// ============================================================================
|
||||
|
||||
export type RendererProtocolSummary = 'upnp' | 'openhome' | 'hybrid'
|
||||
export type RendererProtocolSummary =
|
||||
| "upnp"
|
||||
| "openhome"
|
||||
| "hybrid"
|
||||
| "chromecast";
|
||||
|
||||
export interface RendererCapabilitiesSummary {
|
||||
has_avtransport: boolean
|
||||
has_avtransport_set_next: boolean
|
||||
has_rendering_control: boolean
|
||||
has_connection_manager: boolean
|
||||
has_linkplay_http: boolean
|
||||
has_arylic_tcp: boolean
|
||||
has_oh_playlist: boolean
|
||||
has_oh_volume: boolean
|
||||
has_oh_info: boolean
|
||||
has_oh_time: boolean
|
||||
has_oh_radio: boolean
|
||||
has_avtransport: boolean;
|
||||
has_avtransport_set_next: boolean;
|
||||
has_rendering_control: boolean;
|
||||
has_connection_manager: boolean;
|
||||
has_linkplay_http: boolean;
|
||||
has_arylic_tcp: boolean;
|
||||
has_oh_playlist: boolean;
|
||||
has_oh_volume: boolean;
|
||||
has_oh_info: boolean;
|
||||
has_oh_time: boolean;
|
||||
has_oh_radio: boolean;
|
||||
}
|
||||
|
||||
export interface RendererSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
protocol: RendererProtocolSummary
|
||||
capabilities: RendererCapabilitiesSummary
|
||||
online: boolean
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
protocol: RendererProtocolSummary;
|
||||
capabilities: RendererCapabilitiesSummary;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface RendererState {
|
||||
id: string
|
||||
friendly_name: string
|
||||
transport_state: 'PLAYING' | 'PAUSED' | 'STOPPED' | 'TRANSITIONING' | 'NO_MEDIA' | 'UNKNOWN'
|
||||
position_ms: number | null
|
||||
duration_ms: number | null
|
||||
volume: number | null // 0-100
|
||||
mute: boolean | null
|
||||
queue_len: number
|
||||
attached_playlist: AttachedPlaylistInfo | null
|
||||
current_track: CurrentTrackMetadata | null
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
transport_state:
|
||||
| "PLAYING"
|
||||
| "PAUSED"
|
||||
| "STOPPED"
|
||||
| "TRANSITIONING"
|
||||
| "NO_MEDIA"
|
||||
| "UNKNOWN";
|
||||
position_ms: number | null;
|
||||
duration_ms: number | null;
|
||||
volume: number | null; // 0-100
|
||||
mute: boolean | null;
|
||||
queue_len: number;
|
||||
attached_playlist: AttachedPlaylistInfo | null;
|
||||
current_track: CurrentTrackMetadata | null;
|
||||
}
|
||||
|
||||
export interface CurrentTrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
}
|
||||
|
||||
export interface AttachedPlaylistInfo {
|
||||
server_id: string
|
||||
container_id: string
|
||||
has_seen_update: boolean
|
||||
server_id: string;
|
||||
container_id: string;
|
||||
has_seen_update: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -61,53 +71,26 @@ export interface AttachedPlaylistInfo {
|
||||
// ============================================================================
|
||||
|
||||
export interface QueueItem {
|
||||
index: number // 0-based
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
server_id: string | null
|
||||
object_id: string | null
|
||||
index: number; // 0-based
|
||||
uri: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
server_id: string | null;
|
||||
object_id: string | null;
|
||||
}
|
||||
|
||||
export interface QueueSnapshot {
|
||||
renderer_id: string
|
||||
items: QueueItem[]
|
||||
current_index: number | null // Index de la piste en cours (null si rien en lecture)
|
||||
renderer_id: string;
|
||||
items: QueueItem[];
|
||||
current_index: number | null; // Index de la piste en cours (null si rien en lecture)
|
||||
}
|
||||
|
||||
export interface FullRendererSnapshot {
|
||||
state: RendererState
|
||||
queue: QueueSnapshot
|
||||
binding: AttachedPlaylistInfo | null
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OPENHOME PLAYLIST
|
||||
// ============================================================================
|
||||
|
||||
export interface OpenHomePlaylistTrack {
|
||||
id: number
|
||||
uri: string
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistSnapshot {
|
||||
renderer_id: string
|
||||
current_id: number | null
|
||||
current_index: number | null
|
||||
tracks: OpenHomePlaylistTrack[]
|
||||
}
|
||||
|
||||
export interface OpenHomePlaylistAddRequest {
|
||||
uri: string
|
||||
metadata: string
|
||||
after_id?: number | null
|
||||
play?: boolean
|
||||
state: RendererState;
|
||||
queue: QueueSnapshot;
|
||||
binding: AttachedPlaylistInfo | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -115,26 +98,26 @@ export interface OpenHomePlaylistAddRequest {
|
||||
// ============================================================================
|
||||
|
||||
export interface MediaServerSummary {
|
||||
id: string
|
||||
friendly_name: string
|
||||
model_name: string
|
||||
online: boolean
|
||||
id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export interface ContainerEntry {
|
||||
id: string
|
||||
title: string
|
||||
class: string // UPnP class
|
||||
is_container: boolean
|
||||
child_count: number | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null // ⚠️ Nom exact: album_art_uri
|
||||
id: string;
|
||||
title: string;
|
||||
class: string; // UPnP class
|
||||
is_container: boolean;
|
||||
child_count: number | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null; // ⚠️ Nom exact: album_art_uri
|
||||
}
|
||||
|
||||
export interface BrowseResponse {
|
||||
container_id: string
|
||||
entries: ContainerEntry[]
|
||||
container_id: string;
|
||||
entries: ContainerEntry[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -142,26 +125,26 @@ export interface BrowseResponse {
|
||||
// ============================================================================
|
||||
|
||||
export interface VolumeSetRequest {
|
||||
volume: number // 0-100
|
||||
volume: number; // 0-100
|
||||
}
|
||||
|
||||
export interface AttachPlaylistRequest {
|
||||
server_id: string
|
||||
container_id: string
|
||||
auto_play?: boolean
|
||||
server_id: string;
|
||||
container_id: string;
|
||||
auto_play?: boolean;
|
||||
}
|
||||
|
||||
export interface PlayContentRequest {
|
||||
server_id: string
|
||||
object_id: string
|
||||
server_id: string;
|
||||
object_id: string;
|
||||
}
|
||||
|
||||
export interface SuccessResponse {
|
||||
message: string
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string
|
||||
error: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -169,40 +152,149 @@ export interface ErrorResponse {
|
||||
// ============================================================================
|
||||
|
||||
export type RendererEventPayload =
|
||||
| { type: 'state_changed'; renderer_id: string; state: string; timestamp: string }
|
||||
| { type: 'position_changed'; renderer_id: string; track: number | null; rel_time: string | null; track_duration: string | null; timestamp: string }
|
||||
| { type: 'volume_changed'; renderer_id: string; volume: number; timestamp: string }
|
||||
| { type: 'mute_changed'; renderer_id: string; mute: boolean; timestamp: string }
|
||||
| { type: 'metadata_changed'; renderer_id: string; title: string | null; artist: string | null; album: string | null; album_art_uri: string | null; timestamp: string }
|
||||
| { type: 'queue_updated'; renderer_id: string; queue_length: number; timestamp: string }
|
||||
| { type: 'binding_changed'; renderer_id: string; server_id: string | null; container_id: string | null; timestamp: string }
|
||||
| { type: 'online'; renderer_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; renderer_id: string; timestamp: string }
|
||||
| {
|
||||
type: "state_changed";
|
||||
renderer_id: string;
|
||||
state: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "position_changed";
|
||||
renderer_id: string;
|
||||
track: number | null;
|
||||
rel_time: string | null;
|
||||
track_duration: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "volume_changed";
|
||||
renderer_id: string;
|
||||
volume: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "mute_changed";
|
||||
renderer_id: string;
|
||||
mute: boolean;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "metadata_changed";
|
||||
renderer_id: string;
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "queue_updated";
|
||||
renderer_id: string;
|
||||
queue_length: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "binding_changed";
|
||||
renderer_id: string;
|
||||
server_id: string | null;
|
||||
container_id: string | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_started";
|
||||
renderer_id: string;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_updated";
|
||||
renderer_id: string;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_tick";
|
||||
renderer_id: string;
|
||||
remaining_seconds: number;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_expired";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "timer_cancelled";
|
||||
renderer_id: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "online";
|
||||
renderer_id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| { type: "offline"; renderer_id: string; timestamp: string };
|
||||
|
||||
export type MediaServerEventPayload =
|
||||
| { type: 'global_updated'; server_id: string; system_update_id: number | null; timestamp: string }
|
||||
| { type: 'containers_updated'; server_id: string; container_ids: string[]; timestamp: string }
|
||||
| { type: 'online'; server_id: string; friendly_name: string; model_name: string; manufacturer: string; timestamp: string }
|
||||
| { type: 'offline'; server_id: string; timestamp: string }
|
||||
| {
|
||||
type: "global_updated";
|
||||
server_id: string;
|
||||
system_update_id: number | null;
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "containers_updated";
|
||||
server_id: string;
|
||||
container_ids: string[];
|
||||
timestamp: string;
|
||||
}
|
||||
| {
|
||||
type: "online";
|
||||
server_id: string;
|
||||
friendly_name: string;
|
||||
model_name: string;
|
||||
manufacturer: string;
|
||||
timestamp: string;
|
||||
}
|
||||
| { type: "offline"; server_id: string; timestamp: string };
|
||||
|
||||
export type UnifiedEventPayload =
|
||||
| { category: 'renderer' } & RendererEventPayload
|
||||
| { category: 'media_server' } & MediaServerEventPayload
|
||||
| ({ category: "renderer" } & RendererEventPayload)
|
||||
| ({ category: "media_server" } & MediaServerEventPayload);
|
||||
|
||||
// ============================================================================
|
||||
// MÉTADONNÉES PISTE
|
||||
// ============================================================================
|
||||
|
||||
export interface TrackMetadata {
|
||||
title: string | null
|
||||
artist: string | null
|
||||
album: string | null
|
||||
album_art_uri: string | null
|
||||
duration_ms: number | null
|
||||
title: string | null;
|
||||
artist: string | null;
|
||||
album: string | null;
|
||||
album_art_uri: string | null;
|
||||
duration_ms: number | null;
|
||||
}
|
||||
|
||||
export interface PositionInfo {
|
||||
track: number | null
|
||||
rel_time: string | null // Format HH:MM:SS
|
||||
track_duration: string | null // Format HH:MM:SS
|
||||
track: number | null;
|
||||
rel_time: string | null; // Format HH:MM:SS
|
||||
track_duration: string | null; // Format HH:MM:SS
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
export interface SleepTimerState {
|
||||
active: boolean;
|
||||
duration_seconds: number;
|
||||
remaining_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface SleepTimerRequest {
|
||||
duration_seconds: number; // 0-7200 (0-2 heures)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ button:focus-visible {
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
zoom: 0.85;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
|
||||
@@ -1,169 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch, toRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRenderer, useRenderers } from '@/composables/useRenderers'
|
||||
import { useUIStore } from '@/stores/ui'
|
||||
import CurrentTrack from '@/components/pmocontrol/CurrentTrack.vue'
|
||||
import TransportControls from '@/components/pmocontrol/TransportControls.vue'
|
||||
import VolumeControl from '@/components/pmocontrol/VolumeControl.vue'
|
||||
import QueueViewer from '@/components/pmocontrol/QueueViewer.vue'
|
||||
import PlaylistBindingPanel from '@/components/pmocontrol/PlaylistBindingPanel.vue'
|
||||
import StatusBadge from '@/components/pmocontrol/StatusBadge.vue'
|
||||
import { ArrowLeft, Radio } from 'lucide-vue-next'
|
||||
import {
|
||||
addOpenHomeTrack,
|
||||
clearOpenHomePlaylist,
|
||||
getOpenHomePlaylist,
|
||||
playOpenHomeTrack,
|
||||
} from '@/services/openhomePlaylist'
|
||||
import type { OpenHomePlaylistSnapshot } from '@/services/pmocontrol/types'
|
||||
import { computed, onMounted, onUnmounted, toRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useRenderer, useRenderers } from "@/composables/useRenderers";
|
||||
import { useUIStore } from "@/stores/ui";
|
||||
import CurrentTrack from "@/components/pmocontrol/CurrentTrack.vue";
|
||||
import TransportControls from "@/components/pmocontrol/TransportControls.vue";
|
||||
import VolumeControl from "@/components/pmocontrol/VolumeControl.vue";
|
||||
import QueueViewer from "@/components/pmocontrol/QueueViewer.vue";
|
||||
import PlaylistBindingPanel from "@/components/pmocontrol/PlaylistBindingPanel.vue";
|
||||
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
|
||||
import { ArrowLeft, Radio } from "lucide-vue-next";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const uiStore = useUIStore()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const uiStore = useUIStore();
|
||||
|
||||
const rendererId = computed(() => route.params.id as string)
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value))
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } = useRenderers()
|
||||
const openHomeSupported = computed(() => {
|
||||
const current = renderer.value
|
||||
if (!current) return false
|
||||
const caps = current.capabilities
|
||||
return (
|
||||
current.protocol === 'openhome' ||
|
||||
current.protocol === 'hybrid' ||
|
||||
caps?.has_oh_playlist === true
|
||||
)
|
||||
})
|
||||
const ohPlaylist = ref<OpenHomePlaylistSnapshot | null>(null)
|
||||
const ohLoading = ref(false)
|
||||
const ohError = ref<string | null>(null)
|
||||
const newOhUri = ref('')
|
||||
const newOhMeta = ref('')
|
||||
const canAddOhTrack = computed(() => newOhUri.value.trim().length > 0)
|
||||
const rendererId = computed(() => route.params.id as string);
|
||||
const { renderer, state, refresh } = useRenderer(toRef(() => rendererId.value));
|
||||
const { fetchRenderers, selectRenderer: selectRendererSnapshot } =
|
||||
useRenderers();
|
||||
|
||||
// Charger les données au montage si nécessaire
|
||||
onMounted(async () => {
|
||||
uiStore.selectRenderer(rendererId.value)
|
||||
selectRendererSnapshot(rendererId.value)
|
||||
uiStore.selectRenderer(rendererId.value);
|
||||
selectRendererSnapshot(rendererId.value);
|
||||
|
||||
// Charger toutes les données du renderer
|
||||
if (!renderer.value) {
|
||||
await fetchRenderers()
|
||||
await fetchRenderers();
|
||||
}
|
||||
await refresh()
|
||||
})
|
||||
|
||||
watch(
|
||||
openHomeSupported,
|
||||
async supported => {
|
||||
if (supported) {
|
||||
await refreshOhPlaylist()
|
||||
} else {
|
||||
ohPlaylist.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(rendererId, () => {
|
||||
ohPlaylist.value = null
|
||||
ohError.value = null
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
})
|
||||
await refresh();
|
||||
});
|
||||
|
||||
// Nettoyer la sélection au démontage
|
||||
onUnmounted(() => {
|
||||
uiStore.selectRenderer(null)
|
||||
selectRendererSnapshot(null)
|
||||
})
|
||||
uiStore.selectRenderer(null);
|
||||
selectRendererSnapshot(null);
|
||||
});
|
||||
|
||||
function goBack() {
|
||||
router.push('/')
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
const protocolLabel = computed(() => {
|
||||
if (!renderer.value) return ''
|
||||
if (!renderer.value) return "";
|
||||
switch (renderer.value.protocol) {
|
||||
case 'upnp':
|
||||
return 'UPnP AV'
|
||||
case 'openhome':
|
||||
return 'OpenHome'
|
||||
case 'hybrid':
|
||||
return 'Hybrid (UPnP + OpenHome)'
|
||||
case "upnp":
|
||||
return "UPnP AV";
|
||||
case "openhome":
|
||||
return "OpenHome";
|
||||
case "hybrid":
|
||||
return "Hybrid (UPnP + OpenHome)";
|
||||
case "chromecast":
|
||||
return "Chromecast";
|
||||
default:
|
||||
return 'Inconnu'
|
||||
return "Inconnu";
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshOhPlaylist() {
|
||||
if (!renderer.value || !openHomeSupported.value) return
|
||||
ohLoading.value = true
|
||||
ohError.value = null
|
||||
try {
|
||||
ohPlaylist.value = await getOpenHomePlaylist(renderer.value.id)
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to load OpenHome playlist'
|
||||
} finally {
|
||||
ohLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhClear() {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await clearOpenHomePlaylist(renderer.value.id)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to clear OpenHome playlist'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhPlay(trackId: number) {
|
||||
if (!renderer.value) return
|
||||
try {
|
||||
await playOpenHomeTrack(renderer.value.id, trackId)
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : `Failed to play OpenHome track ${trackId}`
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOhAdd() {
|
||||
if (!renderer.value || !canAddOhTrack.value) return
|
||||
try {
|
||||
await addOpenHomeTrack(renderer.value.id, {
|
||||
uri: newOhUri.value.trim(),
|
||||
metadata: newOhMeta.value,
|
||||
play: false,
|
||||
})
|
||||
newOhUri.value = ''
|
||||
newOhMeta.value = ''
|
||||
await refreshOhPlaylist()
|
||||
} catch (e) {
|
||||
ohError.value =
|
||||
e instanceof Error ? e.message : 'Failed to add track to OpenHome playlist'
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="renderer-view">
|
||||
<!-- Header -->
|
||||
<header class="renderer-header">
|
||||
<button class="btn-back" @click="goBack" title="Retour au dashboard">
|
||||
<button
|
||||
class="btn-back"
|
||||
@click="goBack"
|
||||
title="Retour au dashboard"
|
||||
>
|
||||
<ArrowLeft :size="20" />
|
||||
</button>
|
||||
<div class="header-content">
|
||||
<div class="renderer-info">
|
||||
<Radio :size="24" class="renderer-icon" />
|
||||
<div class="renderer-details">
|
||||
<h1 class="renderer-name">{{ renderer?.friendly_name || 'Chargement...' }}</h1>
|
||||
<p class="renderer-model">{{ renderer?.model_name }} • {{ protocolLabel }}</p>
|
||||
<h1 class="renderer-name">
|
||||
{{ renderer?.friendly_name || "Chargement..." }}
|
||||
</h1>
|
||||
<p class="renderer-model">
|
||||
{{ renderer?.model_name }} • {{ protocolLabel }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<StatusBadge v-if="state" :status="state.transport_state" />
|
||||
@@ -199,65 +115,6 @@ async function handleOhAdd() {
|
||||
<section class="content-section">
|
||||
<PlaylistBindingPanel :rendererId="rendererId" />
|
||||
</section>
|
||||
|
||||
<section v-if="openHomeSupported" class="content-section openhome-playlist">
|
||||
<h2>OpenHome Playlist</h2>
|
||||
|
||||
<div v-if="ohLoading">Chargement de la playlist…</div>
|
||||
<div v-else-if="ohError" class="error">{{ ohError }}</div>
|
||||
|
||||
<div v-else-if="ohPlaylist && ohPlaylist.tracks.length === 0">
|
||||
Playlist vide.
|
||||
</div>
|
||||
|
||||
<div v-else-if="ohPlaylist">
|
||||
<table class="oh-playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Titre</th>
|
||||
<th>Artiste</th>
|
||||
<th>Album</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="track in ohPlaylist.tracks"
|
||||
:key="track.id"
|
||||
:class="{ current: ohPlaylist.current_id === track.id }"
|
||||
>
|
||||
<td>{{ track.id }}</td>
|
||||
<td>{{ track.title || '—' }}</td>
|
||||
<td>{{ track.artist || '—' }}</td>
|
||||
<td>{{ track.album || '—' }}</td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-secondary btn-icon" @click="handleOhPlay(track.id)" title="Lire ce morceau">
|
||||
▶
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="oh-controls">
|
||||
<button class="btn btn-secondary" @click="refreshOhPlaylist">
|
||||
🔁 Rafraîchir
|
||||
</button>
|
||||
<button class="btn btn-danger" @click="handleOhClear">
|
||||
🗑 Effacer la playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oh-add-form">
|
||||
<input v-model="newOhUri" placeholder="URI à ajouter" />
|
||||
<textarea v-model="newOhMeta" placeholder="DIDL-Lite (optionnel)" rows="2"></textarea>
|
||||
<button class="btn btn-primary" @click="handleOhAdd" :disabled="!canAddOhTrack">
|
||||
➕ Ajouter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right column (Desktop) / Bottom (Mobile) -->
|
||||
@@ -396,77 +253,6 @@ async function handleOhAdd() {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
}
|
||||
|
||||
.openhome-playlist h2 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.oh-playlist-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-playlist-table th,
|
||||
.oh-playlist-table td {
|
||||
padding: var(--spacing-xs);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.oh-playlist-table tbody tr:hover {
|
||||
background-color: var(--color-bg-tertiary);
|
||||
}
|
||||
|
||||
.oh-playlist-table tr.current {
|
||||
background-color: rgba(16, 185, 129, 0.15);
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oh-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.oh-add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.oh-add-form input,
|
||||
.oh-add-form textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--color-bg-tertiary);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oh-add-form button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--status-error, #dc2626);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive - Desktop */
|
||||
@media (min-width: 1024px) {
|
||||
.renderer-content {
|
||||
|
||||
@@ -1,186 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useTabs } from '@/composables/useTabs'
|
||||
import { useRenderers } from '@/composables/useRenderers'
|
||||
import { useMediaServers } from '@/composables/useMediaServers'
|
||||
import { useSwipe } from '@vueuse/core'
|
||||
import { api } from '@/services/pmocontrol/api'
|
||||
import type { ContainerEntry } from '@/services/pmocontrol/types'
|
||||
import { ref, watch, onMounted, computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useTabs } from "@/composables/useTabs";
|
||||
import { useRenderers } from "@/composables/useRenderers";
|
||||
import { useMediaServers } from "@/composables/useMediaServers";
|
||||
import { useSwipe } from "@vueuse/core";
|
||||
|
||||
// Import des composants
|
||||
import BottomTabBar from '@/components/unified/BottomTabBar.vue'
|
||||
import EmptyState from '@/components/unified/EmptyState.vue'
|
||||
import ServerDrawer from '@/components/unified/ServerDrawer.vue'
|
||||
import RendererTabContent from '@/components/unified/RendererTabContent.vue'
|
||||
import ServerTabContent from '@/components/unified/ServerTabContent.vue'
|
||||
import BottomTabBar from "@/components/unified/BottomTabBar.vue";
|
||||
import EmptyState from "@/components/unified/EmptyState.vue";
|
||||
import ServerDrawer from "@/components/unified/ServerDrawer.vue";
|
||||
import RendererDrawer from "@/components/unified/RendererDrawer.vue";
|
||||
import RendererTabContent from "@/components/unified/RendererTabContent.vue";
|
||||
import ServerTabContent from "@/components/unified/ServerTabContent.vue";
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } = useTabs()
|
||||
const { allRenderers, fetchRenderers } = useRenderers()
|
||||
const { allServers, fetchServers } = useMediaServers()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { tabs, activeTabId, switchTab, activeTab, syncWithRenderers, isEmpty } =
|
||||
useTabs();
|
||||
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
|
||||
const { allServers, fetchServers } = useMediaServers();
|
||||
|
||||
// État du drawer server
|
||||
const drawerOpen = ref(false)
|
||||
// État des drawers
|
||||
const drawerOpen = ref(false);
|
||||
const rendererDrawerOpen = ref(false);
|
||||
|
||||
// Ref pour le swipe edge detection
|
||||
const viewRef = ref<HTMLElement | null>(null)
|
||||
const viewRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// Swipe depuis le bord gauche pour ouvrir le drawer
|
||||
useSwipe(viewRef, {
|
||||
threshold: 50,
|
||||
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
|
||||
// Swipe right depuis le bord gauche → ouvrir drawer
|
||||
if (swipeDirection === 'right' && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0]
|
||||
if (swipeDirection === "right" && !drawerOpen.value) {
|
||||
const touch = _e.changedTouches[0];
|
||||
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
|
||||
if (touch && touch.clientX < 50) {
|
||||
drawerOpen.value = true
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Gestion de la lecture d'un item depuis le drawer
|
||||
async function handlePlayItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
// Récupérer l'ID du renderer actuellement sélectionné
|
||||
const currentRendererId = computed(() => {
|
||||
const currentTab = activeTab.value;
|
||||
if (currentTab?.type === "renderer" && currentTab.metadata?.rendererId) {
|
||||
return currentTab.metadata.rendererId;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
// Récupérer le renderer actif
|
||||
const activeRenderer = computed(() => {
|
||||
const rendererId = currentRendererId.value;
|
||||
if (!rendererId) return null;
|
||||
return allRenderers.value.find((r) => r.id === rendererId) || null;
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Play item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, true) // autoPlay = true
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de la lecture:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Gestion de l'ajout d'un item à la queue depuis le drawer
|
||||
async function handleQueueItem(item: ContainerEntry, serverId: string) {
|
||||
const currentTab = activeTab.value
|
||||
|
||||
if (!currentTab || currentTab.type !== 'renderer' || !currentTab.metadata?.rendererId) {
|
||||
console.error('[UnifiedControlView] Pas de renderer actif')
|
||||
return
|
||||
}
|
||||
|
||||
const rendererId = currentTab.metadata.rendererId
|
||||
|
||||
try {
|
||||
console.log('[UnifiedControlView] Queue item:', item.title, 'on renderer:', rendererId)
|
||||
|
||||
await api.attachPlaylist(rendererId, serverId, item.id, false) // autoPlay = false
|
||||
|
||||
// Fermer le drawer après succès
|
||||
drawerOpen.value = false
|
||||
} catch (error) {
|
||||
console.error('[UnifiedControlView] Erreur lors de l\'ajout à la queue:', error)
|
||||
}
|
||||
}
|
||||
// Récupérer l'état du renderer actif
|
||||
const activeRendererState = computed(() => {
|
||||
const rendererId = currentRendererId.value;
|
||||
if (!rendererId) return null;
|
||||
return getStateById(rendererId);
|
||||
});
|
||||
|
||||
// Nombre de servers online pour afficher dans le badge
|
||||
const onlineServersCount = computed(() => allServers.value.filter((s) => s.online).length)
|
||||
const onlineServersCount = computed(
|
||||
() => allServers.value.filter((s) => s.online).length,
|
||||
);
|
||||
|
||||
// Nombre de renderers online pour afficher dans le badge
|
||||
const onlineRenderersCount = computed(
|
||||
() => allRenderers.value.filter((r) => r.online).length,
|
||||
);
|
||||
|
||||
// Gestion de l'ouverture du drawer depuis le bouton
|
||||
function handleDrawerOpen() {
|
||||
drawerOpen.value = true
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
// Gestion de l'ouverture du renderer drawer depuis le bouton
|
||||
function handleRendererDrawerOpen() {
|
||||
rendererDrawerOpen.value = true;
|
||||
}
|
||||
|
||||
// Gestion de la sélection d'un renderer depuis le drawer
|
||||
function handleRendererSelect(rendererId: string) {
|
||||
// Chercher le tab correspondant à ce renderer ou le créer
|
||||
const existingTab = tabs.value.find(
|
||||
(t) => t.type === "renderer" && t.metadata?.rendererId === rendererId,
|
||||
);
|
||||
if (existingTab) {
|
||||
switchTab(existingTab.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync route query params avec l'état des tabs
|
||||
onMounted(async () => {
|
||||
// Fetch renderers et servers au montage
|
||||
await Promise.all([
|
||||
fetchRenderers(),
|
||||
fetchServers()
|
||||
])
|
||||
await Promise.all([fetchRenderers(), fetchServers()]);
|
||||
|
||||
// Sync initial des tabs avec les renderers
|
||||
syncWithRenderers(allRenderers.value)
|
||||
syncWithRenderers(allRenderers.value);
|
||||
|
||||
// Restaurer l'onglet actif depuis l'URL
|
||||
const urlTabId = route.query.tab as string
|
||||
const urlTabId = route.query.tab as string;
|
||||
if (urlTabId && tabs.value.find((t) => t.id === urlTabId)) {
|
||||
switchTab(urlTabId)
|
||||
switchTab(urlTabId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Watch renderers pour sync automatique des tabs
|
||||
watch(
|
||||
() => allRenderers.value,
|
||||
(newRenderers) => {
|
||||
syncWithRenderers(newRenderers)
|
||||
syncWithRenderers(newRenderers);
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
);
|
||||
|
||||
// Watch les changements d'URL pour changer d'onglet
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(newTabId) => {
|
||||
if (newTabId && typeof newTabId === 'string') {
|
||||
const tab = tabs.value.find((t) => t.id === newTabId)
|
||||
if (newTabId && typeof newTabId === "string") {
|
||||
const tab = tabs.value.find((t) => t.id === newTabId);
|
||||
if (tab && activeTabId.value !== newTabId) {
|
||||
switchTab(newTabId)
|
||||
switchTab(newTabId);
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Watch les changements d'onglet actif pour mettre à jour l'URL
|
||||
watch(
|
||||
() => activeTabId.value,
|
||||
(newActiveTabId) => {
|
||||
const currentTabId = route.query.tab as string
|
||||
const currentTabId = route.query.tab as string;
|
||||
if (currentTabId !== newActiveTabId) {
|
||||
router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
tab: newActiveTabId,
|
||||
tabs: tabs.value.map((t) => t.id).join(','),
|
||||
tabs: tabs.value.map((t) => t.id).join(","),
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
// Composant dynamique selon le type d'onglet
|
||||
const currentTabComponent = computed(() => {
|
||||
const tab = activeTab.value
|
||||
if (!tab) return null
|
||||
const tab = activeTab.value;
|
||||
if (!tab) return null;
|
||||
|
||||
switch (tab.type) {
|
||||
case 'renderer':
|
||||
return RendererTabContent
|
||||
case 'server':
|
||||
return ServerTabContent
|
||||
case "renderer":
|
||||
return RendererTabContent;
|
||||
case "server":
|
||||
return ServerTabContent;
|
||||
default:
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Props pour le composant actif
|
||||
const currentTabProps = computed(() => {
|
||||
const tab = activeTab.value
|
||||
if (!tab || !tab.metadata) return null
|
||||
const tab = activeTab.value;
|
||||
if (!tab || !tab.metadata) return null;
|
||||
|
||||
if (tab.type === 'renderer' && tab.metadata.rendererId) {
|
||||
return { rendererId: tab.metadata.rendererId }
|
||||
if (tab.type === "renderer" && tab.metadata.rendererId) {
|
||||
return { rendererId: tab.metadata.rendererId };
|
||||
}
|
||||
if (tab.type === 'server' && tab.metadata.serverId) {
|
||||
return { serverId: tab.metadata.serverId }
|
||||
if (tab.type === "server" && tab.metadata.serverId) {
|
||||
return { serverId: tab.metadata.serverId };
|
||||
}
|
||||
return null
|
||||
})
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -201,14 +199,27 @@ const currentTabProps = computed(() => {
|
||||
</keep-alive>
|
||||
</main>
|
||||
|
||||
<!-- Barre d'onglets en bas -->
|
||||
<BottomTabBar :online-servers-count="onlineServersCount" @open-drawer="handleDrawerOpen" />
|
||||
<!-- Barre d'infos en bas -->
|
||||
<BottomTabBar
|
||||
:online-servers-count="onlineServersCount"
|
||||
:online-renderers-count="onlineRenderersCount"
|
||||
:active-renderer="activeRenderer"
|
||||
:active-renderer-state="activeRendererState"
|
||||
@open-drawer="handleDrawerOpen"
|
||||
@open-renderer-drawer="handleRendererDrawerOpen"
|
||||
/>
|
||||
|
||||
<!-- Drawer servers (swipe depuis bord gauche) -->
|
||||
<ServerDrawer
|
||||
v-model="drawerOpen"
|
||||
@play-item="handlePlayItem"
|
||||
@queue-item="handleQueueItem"
|
||||
:selected-renderer-id="currentRendererId"
|
||||
/>
|
||||
|
||||
<!-- Drawer renderers (depuis bord droit) -->
|
||||
<RendererDrawer
|
||||
v-model="rendererDrawerOpen"
|
||||
:selected-renderer-id="currentRendererId"
|
||||
@select-renderer="handleRendererSelect"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -228,7 +239,6 @@ const currentTabProps = computed(() => {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0;
|
||||
padding-bottom: 80px; /* Espace pour la barre fixe en bas (64px + marge) */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -319,7 +329,9 @@ const currentTabProps = computed(() => {
|
||||
/* Animation de transition des onglets */
|
||||
.v-enter-active,
|
||||
.v-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.v-enter-from {
|
||||
|
||||
@@ -18,7 +18,7 @@ pmoplaylist = { path = "../pmoplaylist", optional = true }
|
||||
pmocache = { path = "../pmocache", optional = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { version = "0.7" }
|
||||
async-trait = "0.1"
|
||||
|
||||
@@ -28,8 +28,8 @@ rand = "0.8"
|
||||
|
||||
# HTTP streaming dependencies
|
||||
bytes = { version = "1.0", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
serde = { workspace = true, optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -8,9 +8,9 @@ default = []
|
||||
simd = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = "0.1"
|
||||
async-trait = { workspace = true }
|
||||
futures-util = "0.3"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
@@ -18,7 +18,7 @@ paste = "1"
|
||||
soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
tracing = { workspace = true }
|
||||
cpal = "0.15"
|
||||
once_cell = "1.20"
|
||||
|
||||
@@ -26,4 +26,4 @@ once_cell = "1.20"
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
29
pmoaudio/Cargo.toml.backup
Executable file
29
pmoaudio/Cargo.toml.backup
Executable file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "pmoaudio"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
simd = []
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1.42", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
paste = "1"
|
||||
soxr = "0.6.0"
|
||||
bytemuck = "1.24.0"
|
||||
reqwest = { version = "0.12", features = ["stream"] }
|
||||
tracing = "0.1"
|
||||
cpal = "0.15"
|
||||
once_cell = "1.20"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
tempfile = "3"
|
||||
wiremock = "0.6"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -16,7 +16,7 @@ pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
chrono = "0.4"
|
||||
chrono = { workspace = true }
|
||||
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
@@ -26,15 +26,15 @@ futures-util = "0.3"
|
||||
bytes = "1.0"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
quick-xml = { version = "0.37", features = ["serialize"] }
|
||||
anyhow = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
quick-xml = { workspace = true }
|
||||
paste = "1.0"
|
||||
async-trait = "0.1"
|
||||
async-trait = { workspace = true }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio = { workspace = true }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
@@ -45,7 +45,7 @@ pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
|
||||
57
pmoaudiocache/Cargo.toml.backup
Normal file
57
pmoaudiocache/Cargo.toml.backup
Normal file
@@ -0,0 +1,57 @@
|
||||
[package]
|
||||
name = "pmoaudiocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Cache générique
|
||||
pmocache = { path = "../pmocache" }
|
||||
|
||||
# DIDL-Lite pour UPnP
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
|
||||
# Streaming FLAC asynchrone
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
pmometadata = { path = "../pmometadata" }
|
||||
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
chrono = "0.4"
|
||||
|
||||
# Métadonnées audio
|
||||
lofty = "0.22"
|
||||
|
||||
# Outils de streaming
|
||||
futures-util = "0.3"
|
||||
bytes = "1.0"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
quick-xml = { version = "0.38", features = ["serialize"] }
|
||||
paste = "1.0"
|
||||
async-trait = "0.1"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
|
||||
# Singleton
|
||||
once_cell = "1.20"
|
||||
|
||||
# Serveur HTTP (optionnel pour l'extension)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
tracing = "0.1.41"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = ["pmoserver"]
|
||||
pmoconfig = ["dep:pmoconfig", "pmocache/pmoconfig"]
|
||||
pmoserver = ["pmoconfig", "dep:pmoserver", "dep:axum", "dep:utoipa", "pmocache/pmoserver", "pmocache/openapi"]
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pmometadata::{MetadataError, MetadataResult, TrackMetadata};
|
||||
use serde_json::{Number, Value};
|
||||
|
||||
@@ -17,27 +17,29 @@ sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
anyhow = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
bytes = "1.6"
|
||||
paste = "1.0"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio = { workspace = true }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing = { workspace = true }
|
||||
|
||||
# Feature pour OpenAPI
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
tower = { version = "0.5", optional = true }
|
||||
tower-http = { version = "0.6", features = ["fs"], optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
@@ -49,5 +51,5 @@ tempfile = "3"
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoserver = ["dep:axum", "dep:tower", "dep:tower-http"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
|
||||
53
pmocache/Cargo.toml.backup
Normal file
53
pmocache/Cargo.toml.backup
Normal file
@@ -0,0 +1,53 @@
|
||||
[package]
|
||||
name = "pmocache"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
# Base de données
|
||||
rusqlite = { version = "0.37.0", features = ["bundled"] }
|
||||
|
||||
# HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Cryptographie
|
||||
sha1 = "0.10"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# Utilitaires
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
chrono = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
bytes = "1.6"
|
||||
paste = "1.0"
|
||||
pmoflac = { path = "../pmoflac" }
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tokio-util = { version = "0.7", features = ["io"] }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
|
||||
# Feature pour OpenAPI
|
||||
utoipa = { version = "5.3", optional = true }
|
||||
|
||||
# Feature pour pmoserver (extension HTTP)
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
# Feature pour pmoconfig (extension de configuration)
|
||||
pmoconfig = { path = "../pmoconfig", optional = true }
|
||||
serde_yaml = { version = "0.9", optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
openapi = ["dep:utoipa"]
|
||||
pmoserver = ["dep:axum"]
|
||||
pmoconfig = ["dep:pmoconfig", "dep:serde_yaml"]
|
||||
@@ -112,6 +112,41 @@ pub struct ErrorResponse {
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Requête pour définir un TTL
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||
pub struct SetTtlRequest {
|
||||
/// Date/heure d'expiration au format RFC3339
|
||||
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
/// Réponse pour une opération de pinning/TTL
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||
pub struct PinResponse {
|
||||
/// Clé primaire de l'item
|
||||
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
|
||||
pub pk: String,
|
||||
/// Message de succès
|
||||
#[cfg_attr(feature = "openapi", schema(example = "Item pinned successfully"))]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// Statut de pinning d'un item
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "openapi", derive(ToSchema))]
|
||||
pub struct PinStatus {
|
||||
/// Clé primaire de l'item
|
||||
#[cfg_attr(feature = "openapi", schema(example = "1a2b3c4d5e6f7a8b"))]
|
||||
pub pk: String,
|
||||
/// Indique si l'item est épinglé
|
||||
pub pinned: bool,
|
||||
/// Date/heure d'expiration du TTL (si défini)
|
||||
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
|
||||
pub ttl_expires_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Liste tous les items en cache avec leurs statistiques
|
||||
///
|
||||
/// Retourne la liste complète des entrées du cache triées par nombre d'accès décroissant.
|
||||
@@ -438,3 +473,229 @@ pub async fn consolidate_cache<C: CacheConfig + 'static>(
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Récupère le statut de pinning d'un item
|
||||
///
|
||||
/// Retourne si l'item est épinglé et sa date d'expiration TTL (si défini).
|
||||
pub async fn get_pin_status<C: CacheConfig + 'static>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
// Vérifier que l'item existe et récupérer ses infos
|
||||
match cache.db.get(&pk, false) {
|
||||
Ok(entry) => (
|
||||
StatusCode::OK,
|
||||
Json(PinStatus {
|
||||
pk,
|
||||
pinned: entry.pinned,
|
||||
ttl_expires_at: entry.ttl_expires_at,
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(_) => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Item with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Épingle un item pour le protéger de l'éviction LRU
|
||||
///
|
||||
/// Un item épinglé ne peut pas être supprimé automatiquement et ne compte pas
|
||||
/// dans la limite du cache. Échoue si l'item a un TTL défini.
|
||||
pub async fn pin_item<C: CacheConfig + 'static>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match cache.pin(&pk).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(PinResponse {
|
||||
pk: pk.clone(),
|
||||
message: format!("Item '{}' pinned successfully", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("TTL") {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "CONFLICT".to_string(),
|
||||
message: "Cannot pin an item with TTL set. Clear TTL first.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else if error_msg.contains("no rows") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Item with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "PIN_ERROR".to_string(),
|
||||
message: format!("Cannot pin item: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Désépingle un item
|
||||
///
|
||||
/// Rend l'item à nouveau éligible à l'éviction LRU et le compte dans la limite du cache.
|
||||
pub async fn unpin_item<C: CacheConfig + 'static>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match cache.unpin(&pk).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(PinResponse {
|
||||
pk: pk.clone(),
|
||||
message: format!("Item '{}' unpinned successfully", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("no rows") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Item with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "UNPIN_ERROR".to_string(),
|
||||
message: format!("Cannot unpin item: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Définit le TTL (Time To Live) d'un item
|
||||
///
|
||||
/// L'item sera automatiquement supprimé à la date d'expiration.
|
||||
/// Échoue si l'item est épinglé.
|
||||
pub async fn set_item_ttl<C: CacheConfig + 'static>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
Json(req): Json<SetTtlRequest>,
|
||||
) -> impl IntoResponse {
|
||||
// Valider le format de la date
|
||||
if chrono::DateTime::parse_from_rfc3339(&req.expires_at).is_err() {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "INVALID_DATE".to_string(),
|
||||
message: "Invalid RFC3339 date format".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match cache.set_ttl(&pk, &req.expires_at).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(PinResponse {
|
||||
pk: pk.clone(),
|
||||
message: format!("TTL set successfully for item '{}'", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("pinned") {
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "CONFLICT".to_string(),
|
||||
message: "Cannot set TTL on a pinned item. Unpin first.".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else if error_msg.contains("no rows") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Item with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "TTL_ERROR".to_string(),
|
||||
message: format!("Cannot set TTL: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Supprime le TTL d'un item
|
||||
///
|
||||
/// L'item ne sera plus supprimé automatiquement.
|
||||
pub async fn clear_item_ttl<C: CacheConfig + 'static>(
|
||||
State(cache): State<Arc<Cache<C>>>,
|
||||
Path(pk): Path<String>,
|
||||
) -> impl IntoResponse {
|
||||
match cache.clear_ttl(&pk).await {
|
||||
Ok(_) => (
|
||||
StatusCode::OK,
|
||||
Json(PinResponse {
|
||||
pk: pk.clone(),
|
||||
message: format!("TTL cleared successfully for item '{}'", pk),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
Err(e) => {
|
||||
let error_msg = e.to_string();
|
||||
if error_msg.contains("no rows") {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "NOT_FOUND".to_string(),
|
||||
message: format!("Item with pk '{}' not found in cache", pk),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
} else {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "TTL_ERROR".to_string(),
|
||||
message: format!("Cannot clear TTL: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1057,6 +1057,67 @@ impl<C: CacheConfig + 'static> Cache<C> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Épingle un item pour le protéger de l'éviction LRU
|
||||
///
|
||||
/// Un item épinglé ne peut pas être supprimé automatiquement par la politique LRU
|
||||
/// et ne compte pas dans la limite du cache.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item à épingler
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si l'item a un TTL défini (incompatibilité métier)
|
||||
pub async fn pin(&self, pk: &str) -> Result<()> {
|
||||
self.db.pin(pk).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
/// Désépingle un item pour le rendre à nouveau éligible à l'éviction LRU
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item à désépingler
|
||||
pub async fn unpin(&self, pk: &str) -> Result<()> {
|
||||
self.db.unpin(pk).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
/// Vérifie si un item est épinglé
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` si l'item est épinglé, `false` sinon
|
||||
pub async fn is_pinned(&self, pk: &str) -> Result<bool> {
|
||||
self.db.is_pinned(pk).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
/// Définit le TTL (Time To Live) d'un item
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
/// * `expires_at` - Date/heure d'expiration au format RFC3339
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si l'item est épinglé (incompatibilité métier)
|
||||
pub async fn set_ttl(&self, pk: &str, expires_at: &str) -> Result<()> {
|
||||
self.db.set_ttl(pk, expires_at).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
/// Supprime le TTL d'un item
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
pub async fn clear_ttl(&self, pk: &str) -> Result<()> {
|
||||
self.db.clear_ttl(pk).map_err(|e| anyhow!(e))
|
||||
}
|
||||
|
||||
/// Récupère tous les fichiers d'une collection
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -1386,28 +1447,59 @@ impl<C: CacheConfig + 'static> Cache<C> {
|
||||
|
||||
/// Applique la politique d'éviction LRU (Least Recently Used)
|
||||
///
|
||||
/// Si le nombre d'entrées dépasse la limite configurée, supprime
|
||||
/// Si le nombre d'entrées non épinglées dépasse la limite configurée, supprime
|
||||
/// les entrées les plus anciennes (moins récemment utilisées).
|
||||
///
|
||||
/// Les items épinglés sont exclus du comptage et ne peuvent pas être supprimés.
|
||||
///
|
||||
/// Cette méthode :
|
||||
/// 1. Compte le nombre total d'entrées
|
||||
/// 2. Si > limit, récupère les N entrées les plus anciennes
|
||||
/// 1. Compte le nombre d'entrées non épinglées
|
||||
/// 2. Si > limit, récupère les N entrées les plus anciennes (non épinglées)
|
||||
/// 3. Supprime ces entrées de la DB et leurs fichiers du disque
|
||||
/// 4. Supprime également les items expirés (TTL dépassé)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre d'entrées supprimées
|
||||
pub async fn enforce_limit(&self) -> Result<usize> {
|
||||
let count = self.db.count()?;
|
||||
let mut total_removed = 0;
|
||||
|
||||
if count <= self.limit {
|
||||
return Ok(0);
|
||||
// 1. Supprimer d'abord les items expirés (TTL dépassé)
|
||||
let expired_entries = self.db.get_expired()?;
|
||||
for entry in expired_entries {
|
||||
if let Ok(paths) = self.get_file_paths(&entry.pk) {
|
||||
for path in paths {
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.db.delete(&entry.pk) {
|
||||
tracing::warn!("Error deleting expired entry {} from DB: {}", entry.pk, e);
|
||||
} else {
|
||||
total_removed += 1;
|
||||
tracing::debug!(
|
||||
"Removed expired item {} (TTL: {:?})",
|
||||
entry.pk,
|
||||
entry.ttl_expires_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Compter seulement les items non épinglés
|
||||
let count = self.db.count_unpinned()?;
|
||||
|
||||
if count <= self.limit {
|
||||
if total_removed > 0 {
|
||||
tracing::info!("Cache cleanup: removed {} expired entries", total_removed);
|
||||
}
|
||||
return Ok(total_removed);
|
||||
}
|
||||
|
||||
// 3. Supprimer les plus vieux items non épinglés si nécessaire
|
||||
let to_remove = count - self.limit;
|
||||
let old_entries = self.db.get_oldest(to_remove)?;
|
||||
|
||||
let mut removed = 0;
|
||||
let mut lru_removed = 0;
|
||||
for entry in old_entries {
|
||||
// Utiliser get_file_paths() pour obtenir tous les fichiers de cette entrée
|
||||
if let Ok(paths) = self.get_file_paths(&entry.pk) {
|
||||
@@ -1420,20 +1512,22 @@ impl<C: CacheConfig + 'static> Cache<C> {
|
||||
if let Err(e) = self.db.delete(&entry.pk) {
|
||||
tracing::warn!("Error deleting entry {} from DB: {}", entry.pk, e);
|
||||
} else {
|
||||
removed += 1;
|
||||
lru_removed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
total_removed += lru_removed;
|
||||
|
||||
if total_removed > 0 {
|
||||
tracing::info!(
|
||||
"LRU eviction: removed {} old entries (cache size: {} -> {})",
|
||||
removed,
|
||||
"LRU eviction: removed {} old entries (unpinned cache size: {} -> {})",
|
||||
lru_removed,
|
||||
count,
|
||||
count - removed
|
||||
count - lru_removed
|
||||
);
|
||||
}
|
||||
|
||||
Ok(removed)
|
||||
Ok(total_removed)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -39,6 +39,12 @@ pub struct CacheEntry {
|
||||
/// Date/heure du dernier accès (RFC3339)
|
||||
#[cfg_attr(feature = "openapi", schema(example = "2025-01-15T10:30:00Z"))]
|
||||
pub last_used: Option<String>,
|
||||
/// Indique si l'élément est épinglé (ne peut pas être supprimé par LRU)
|
||||
#[cfg_attr(feature = "openapi", schema(example = false))]
|
||||
pub pinned: bool,
|
||||
/// Date/heure d'expiration du TTL (RFC3339), incompatible avec pinned=true
|
||||
#[cfg_attr(feature = "openapi", schema(example = "2025-01-20T10:30:00Z"))]
|
||||
pub ttl_expires_at: Option<String>,
|
||||
/// Métadonnées JSON optionnelles (ex: métadonnées audio, EXIF images, etc.)
|
||||
#[cfg_attr(
|
||||
feature = "openapi",
|
||||
@@ -123,7 +129,9 @@ impl DB {
|
||||
id TEXT,
|
||||
hits INTEGER DEFAULT 0,
|
||||
last_used TEXT,
|
||||
lazy_pk TEXT
|
||||
lazy_pk TEXT,
|
||||
pinned INTEGER DEFAULT 0 CHECK (pinned IN (0, 1)),
|
||||
ttl_expires_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
@@ -489,7 +497,7 @@ impl DB {
|
||||
let mut entry = {
|
||||
let conn = self.lock_conn("get");
|
||||
conn.query_row(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used \
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at \
|
||||
FROM asset \
|
||||
WHERE pk = ?1",
|
||||
[pk],
|
||||
@@ -501,6 +509,8 @@ impl DB {
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
},
|
||||
@@ -530,7 +540,7 @@ impl DB {
|
||||
let mut entry = {
|
||||
let conn = self.lock_conn("get_from_id");
|
||||
conn.query_row(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used \
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at \
|
||||
FROM asset \
|
||||
WHERE collection = ?1 AND id = ?2",
|
||||
params![collection, id],
|
||||
@@ -542,6 +552,8 @@ impl DB {
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
},
|
||||
@@ -632,7 +644,7 @@ impl DB {
|
||||
let conn = self.lock_conn("get_all");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
|
||||
FROM asset
|
||||
ORDER BY hits DESC",
|
||||
)?;
|
||||
@@ -645,6 +657,8 @@ impl DB {
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?;
|
||||
@@ -676,7 +690,7 @@ impl DB {
|
||||
let conn = self.lock_conn("get_by_collection");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
|
||||
FROM asset
|
||||
WHERE collection = ?1 ORDER BY hits DESC",
|
||||
)?;
|
||||
@@ -688,6 +702,8 @@ impl DB {
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?;
|
||||
@@ -732,10 +748,190 @@ impl DB {
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
/// Compte le nombre d'entrées non épinglées dans le cache
|
||||
///
|
||||
/// Les items épinglés ne comptent pas dans la limite du cache.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Le nombre d'entrées non épinglées
|
||||
pub fn count_unpinned(&self) -> rusqlite::Result<usize> {
|
||||
let conn = self.lock_conn("count_unpinned");
|
||||
let count: i64 =
|
||||
conn.query_row("SELECT COUNT(*) FROM asset WHERE pinned = 0", [], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
Ok(count as usize)
|
||||
}
|
||||
|
||||
/// Épingle un item pour le protéger de l'éviction LRU
|
||||
///
|
||||
/// Un item épinglé ne peut pas être supprimé automatiquement par la politique LRU
|
||||
/// et ne compte pas dans la limite du cache.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item à épingler
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si l'item a un TTL défini (incompatibilité métier)
|
||||
pub fn pin(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.lock_conn("pin");
|
||||
|
||||
// Vérifier que l'item n'a pas de TTL
|
||||
let has_ttl: bool = conn
|
||||
.query_row(
|
||||
"SELECT ttl_expires_at IS NOT NULL FROM asset WHERE pk = ?1",
|
||||
[pk],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_ttl {
|
||||
return Err(Error::InvalidParameterName(
|
||||
"Cannot pin an item with TTL set".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = conn.execute("UPDATE asset SET pinned = 1 WHERE pk = ?1", [pk])?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Désépingle un item pour le rendre à nouveau éligible à l'éviction LRU
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item à désépingler
|
||||
pub fn unpin(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.lock_conn("unpin");
|
||||
let updated = conn.execute("UPDATE asset SET pinned = 0 WHERE pk = ?1", [pk])?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Vérifie si un item est épinglé
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `true` si l'item est épinglé, `false` sinon
|
||||
pub fn is_pinned(&self, pk: &str) -> rusqlite::Result<bool> {
|
||||
let conn = self.lock_conn("is_pinned");
|
||||
let pinned: i32 =
|
||||
conn.query_row("SELECT pinned FROM asset WHERE pk = ?1", [pk], |row| {
|
||||
row.get(0)
|
||||
})?;
|
||||
Ok(pinned != 0)
|
||||
}
|
||||
|
||||
/// Définit le TTL (Time To Live) d'un item
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
/// * `expires_at` - Date/heure d'expiration au format RFC3339
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Retourne une erreur si l'item est épinglé (incompatibilité métier)
|
||||
pub fn set_ttl(&self, pk: &str, expires_at: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.lock_conn("set_ttl");
|
||||
|
||||
// Vérifier que l'item n'est pas épinglé
|
||||
let is_pinned: bool = conn
|
||||
.query_row("SELECT pinned != 0 FROM asset WHERE pk = ?1", [pk], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.optional()?
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_pinned {
|
||||
return Err(Error::InvalidParameterName(
|
||||
"Cannot set TTL on a pinned item".to_owned(),
|
||||
));
|
||||
}
|
||||
|
||||
let updated = conn.execute(
|
||||
"UPDATE asset SET ttl_expires_at = ?2 WHERE pk = ?1",
|
||||
params![pk, expires_at],
|
||||
)?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Supprime le TTL d'un item
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `pk` - Clé primaire de l'item
|
||||
pub fn clear_ttl(&self, pk: &str) -> rusqlite::Result<()> {
|
||||
let conn = self.lock_conn("clear_ttl");
|
||||
let updated = conn.execute("UPDATE asset SET ttl_expires_at = NULL WHERE pk = ?1", [pk])?;
|
||||
|
||||
if updated == 0 {
|
||||
return Err(Error::QueryReturnedNoRows);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Récupère les items expirés (TTL dépassé)
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Liste des entrées dont le TTL est dépassé
|
||||
pub fn get_expired(&self) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.lock_conn("get_expired");
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
|
||||
FROM asset
|
||||
WHERE ttl_expires_at IS NOT NULL AND ttl_expires_at < ?1",
|
||||
)?;
|
||||
|
||||
let entries = stmt
|
||||
.query_map([now], |row| {
|
||||
Ok(CacheEntry {
|
||||
pk: row.get(0)?,
|
||||
lazy_pk: row.get::<_, Option<String>>(1)?,
|
||||
id: row.get::<_, Option<String>>(2)?,
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// Récupère les N entrées les plus anciennes (LRU - Least Recently Used)
|
||||
///
|
||||
/// Trie par last_used (les plus anciens en premier), puis par hits (les moins utilisés).
|
||||
/// Utile pour implémenter une politique d'éviction LRU.
|
||||
/// Les items épinglés sont EXCLUS de cette liste (ils ne peuvent pas être évincés).
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
@@ -743,13 +939,14 @@ impl DB {
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Liste des entrées les plus anciennes, triées par last_used ASC
|
||||
/// Liste des entrées les plus anciennes (non épinglées), triées par last_used ASC
|
||||
pub fn get_oldest(&self, limit: usize) -> rusqlite::Result<Vec<CacheEntry>> {
|
||||
let conn = self.lock_conn("get_oldest");
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used
|
||||
"SELECT pk, lazy_pk, id, collection, hits, last_used, pinned, ttl_expires_at
|
||||
FROM asset
|
||||
WHERE pinned = 0
|
||||
ORDER BY last_used ASC, hits ASC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
@@ -763,6 +960,8 @@ impl DB {
|
||||
collection: row.get(3)?,
|
||||
hits: row.get(4)?,
|
||||
last_used: row.get(5)?,
|
||||
pinned: row.get::<_, i32>(6)? != 0,
|
||||
ttl_expires_at: row.get::<_, Option<String>>(7)?,
|
||||
metadata: None,
|
||||
})
|
||||
})?
|
||||
|
||||
@@ -155,7 +155,10 @@ pub use lazy::{lazy_prefix_from_pk, LazyEntryRemoteData, LazyProvider};
|
||||
pub use pmoserver_ext::{create_api_router, create_file_router, GenericCacheExt};
|
||||
|
||||
#[cfg(all(feature = "pmoserver", feature = "openapi"))]
|
||||
pub use api::{AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse};
|
||||
pub use api::{
|
||||
AddItemRequest, AddItemResponse, DeleteItemResponse, DownloadStatus, ErrorResponse,
|
||||
PinResponse, PinStatus, SetTtlRequest,
|
||||
};
|
||||
|
||||
#[cfg(feature = "pmoconfig")]
|
||||
pub use config_ext::CacheConfigExt;
|
||||
|
||||
@@ -31,6 +31,11 @@ macro_rules! create_cache_openapi {
|
||||
$crate::api::delete_item::<Self>,
|
||||
$crate::api::purge_cache::<Self>,
|
||||
$crate::api::consolidate_cache::<Self>,
|
||||
$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(
|
||||
@@ -40,6 +45,9 @@ macro_rules! create_cache_openapi {
|
||||
$crate::api::AddItemResponse,
|
||||
$crate::api::DeleteItemResponse,
|
||||
$crate::api::ErrorResponse,
|
||||
$crate::api::PinStatus,
|
||||
$crate::api::PinResponse,
|
||||
$crate::api::SetTtlRequest,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
|
||||
@@ -90,10 +90,11 @@ async fn get_file<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path(pk): Path<String>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// Utiliser le param par défaut
|
||||
let param = C::default_param();
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, param, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
/// Handler générique pour GET /{cache_name}/{cache_type}/{pk}/{param}
|
||||
@@ -106,8 +107,9 @@ async fn get_file_with_param<C: CacheConfig + 'static>(
|
||||
Option<ParamGenerator<C>>,
|
||||
)>,
|
||||
Path((pk, param)): Path<(String, String)>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator).await
|
||||
serve_file_with_streaming(&cache, &pk, ¶m, content_type, param_generator, request).await
|
||||
}
|
||||
|
||||
#[cfg(feature = "pmoserver")]
|
||||
@@ -116,6 +118,7 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
let qualifier = param.to_string();
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -129,7 +132,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
// la lecture pendant le téléchargement tout en préservant la durée/position
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
if !download.finished().await {
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -140,9 +144,8 @@ async fn serve_finalized_pk<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
// ROUTE 2 : Fichier complètement téléchargé
|
||||
// Utilise l'ancien système éprouvé qui garantit un passage correct
|
||||
// de toutes les informations (Content-Length automatique, etc.)
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Utilise ServeFile avec support des Range requests pour le seek
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -166,6 +169,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk: &str,
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
tracing::info!("Lazy download triggered for pk: {}", lazy_pk);
|
||||
|
||||
@@ -176,7 +180,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
lazy_pk,
|
||||
real_pk
|
||||
);
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type).await;
|
||||
return serve_finalized_pk(cache, &real_pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
// 2. Télécharger en résolvant l'URL via la DB ou un provider
|
||||
@@ -196,7 +200,7 @@ async fn serve_lazy_audio_file<C: CacheConfig + 'static>(
|
||||
cache.broadcast_lazy_downloaded(lazy_pk, &real_pk).await;
|
||||
|
||||
// 5. Servir directement le fichier téléchargé
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type).await
|
||||
serve_finalized_pk(cache, &real_pk, param, content_type, request).await
|
||||
}
|
||||
|
||||
/// Fonction utilitaire pour servir un fichier avec streaming progressif
|
||||
@@ -211,10 +215,11 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
param: &str,
|
||||
content_type: &'static str,
|
||||
param_generator: Option<ParamGenerator<C>>,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
// LAZY PK SUPPORT: Détecter si c'est un lazy PK
|
||||
if crate::cache::is_lazy_pk(pk) {
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type).await;
|
||||
return serve_lazy_audio_file(cache, pk, param, content_type, request).await;
|
||||
}
|
||||
|
||||
let file_path = cache.get_file_path_with_qualifier(pk, param);
|
||||
@@ -246,8 +251,9 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
if let Some(download) = cache.get_download(pk).await {
|
||||
// Le fichier est en cours de téléchargement
|
||||
if !download.finished().await {
|
||||
// Streaming progressif
|
||||
let response = stream_file_progressive(file_path, download, content_type).await;
|
||||
// Streaming progressif avec support Range
|
||||
let response =
|
||||
stream_file_progressive(file_path, download, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -257,8 +263,8 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
}
|
||||
|
||||
// Fichier terminé ou pas de download en cours, servir normalement
|
||||
let response = serve_complete_file(file_path, content_type).await;
|
||||
// Fichier terminé ou pas de download en cours, servir normalement avec support Range
|
||||
let response = serve_complete_file(file_path, content_type, request).await;
|
||||
|
||||
if response.status().is_success() {
|
||||
cache.notify_broadcast(pk, &qualifier).await;
|
||||
@@ -268,13 +274,17 @@ async fn serve_file_with_streaming<C: CacheConfig + 'static>(
|
||||
}
|
||||
|
||||
/// Stream un fichier en cours de téléchargement de manière progressive
|
||||
///
|
||||
/// Supporte les Range requests pour permettre le seek même pendant le téléchargement
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn stream_file_progressive(
|
||||
file_path: std::path::PathBuf,
|
||||
download: Arc<crate::download::Download>,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||
|
||||
// Attendre qu'au moins 64 KB soient disponibles avant de commencer
|
||||
const MIN_SIZE_TO_START: u64 = 64 * 1024;
|
||||
@@ -291,8 +301,14 @@ async fn stream_file_progressive(
|
||||
return (StatusCode::NOT_FOUND, "File not available").into_response();
|
||||
}
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
|
||||
// Vérifier s'il y a un header Range
|
||||
let range_header = request.headers().get(header::RANGE);
|
||||
|
||||
// Ouvrir le fichier en lecture
|
||||
let file = match tokio::fs::File::open(&file_path).await {
|
||||
let mut file = match tokio::fs::File::open(&file_path).await {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
warn!("Error opening file {:?}: {}", file_path, e);
|
||||
@@ -300,24 +316,65 @@ async fn stream_file_progressive(
|
||||
}
|
||||
};
|
||||
|
||||
// Créer un stream à partir du fichier
|
||||
// Parser le Range header si présent
|
||||
if let Some(range_value) = range_header {
|
||||
if let Ok(range_str) = range_value.to_str() {
|
||||
// Format: "bytes=start-end" ou "bytes=start-"
|
||||
if let Some(range_spec) = range_str.strip_prefix("bytes=") {
|
||||
if let Some((start_str, _)) = range_spec.split_once('-') {
|
||||
if let Ok(start) = start_str.parse::<u64>() {
|
||||
// Seek vers la position demandée
|
||||
if let Err(e) = file.seek(std::io::SeekFrom::Start(start)).await {
|
||||
warn!("Error seeking to position {}: {}", start, e);
|
||||
return (StatusCode::RANGE_NOT_SATISFIABLE, "Invalid range")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Créer un stream à partir de la position
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Récupérer la taille attendue du fichier si disponible
|
||||
let expected_size = download.expected_size().await;
|
||||
// Calculer la range
|
||||
let end = expected_size.map(|s| s - 1).unwrap_or(start + 1_000_000);
|
||||
let content_length = expected_size.map(|s| s - start);
|
||||
|
||||
// Retourner une réponse 206 Partial Content
|
||||
let mut response_builder = axum::http::Response::builder()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(total_size) = expected_size {
|
||||
response_builder = response_builder.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, end, total_size),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(length) = content_length {
|
||||
response_builder =
|
||||
response_builder.header(header::CONTENT_LENGTH, length);
|
||||
}
|
||||
|
||||
return response_builder.body(body).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pas de Range header : retourner le fichier complet
|
||||
let stream = ReaderStream::new(file);
|
||||
let body = Body::from_stream(stream);
|
||||
|
||||
// Construire la réponse avec Content-Length si connu
|
||||
let mut response = axum::http::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, content_type);
|
||||
.header(header::CONTENT_TYPE, content_type)
|
||||
.header(header::ACCEPT_RANGES, "bytes");
|
||||
|
||||
if let Some(size) = expected_size {
|
||||
// Si on connaît la taille finale, l'envoyer au renderer
|
||||
// pour qu'il puisse calculer la durée et afficher la position
|
||||
response = response.header(header::CONTENT_LENGTH, size);
|
||||
} else {
|
||||
// Sinon, utiliser chunked encoding
|
||||
response = response.header(header::TRANSFER_ENCODING, "chunked");
|
||||
}
|
||||
|
||||
@@ -325,21 +382,45 @@ async fn stream_file_progressive(
|
||||
}
|
||||
|
||||
/// Sert un fichier complet déjà téléchargé
|
||||
///
|
||||
/// Utilise `tower_http::services::ServeFile` pour bénéficier automatiquement de :
|
||||
/// - Support des HTTP Range requests (seek)
|
||||
/// - Header `Accept-Ranges: bytes`
|
||||
/// - Réponses `206 Partial Content`
|
||||
/// - Streaming efficace sans chargement complet en RAM
|
||||
#[cfg(feature = "pmoserver")]
|
||||
async fn serve_complete_file(
|
||||
file_path: std::path::PathBuf,
|
||||
content_type: &'static str,
|
||||
request: axum::http::Request<Body>,
|
||||
) -> Response {
|
||||
use axum::http::header;
|
||||
use tower::ServiceExt; // Pour oneshot()
|
||||
use tower_http::services::ServeFile;
|
||||
|
||||
if !file_path.exists() {
|
||||
warn!("File not found: {:?}", file_path);
|
||||
return (StatusCode::NOT_FOUND, "File not found").into_response();
|
||||
}
|
||||
|
||||
match tokio::fs::read(&file_path).await {
|
||||
Ok(data) => (StatusCode::OK, [("content-type", content_type)], data).into_response(),
|
||||
// Utiliser ServeFile avec support automatique des Range requests
|
||||
// ServeFile traite automatiquement les headers Range: dans la requête
|
||||
let serve_file = ServeFile::new(file_path);
|
||||
|
||||
match serve_file.oneshot(request).await {
|
||||
Ok(response) => {
|
||||
// Convertir la réponse ServeFile en Response<Body> avec le bon Content-Type
|
||||
let (mut parts, body) = response.into_parts();
|
||||
parts
|
||||
.headers
|
||||
.insert(header::CONTENT_TYPE, content_type.parse().unwrap());
|
||||
|
||||
// Convertir ServeFileSystemResponseBody en Body
|
||||
axum::http::Response::from_parts(parts, Body::new(body))
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Error reading file {:?}: {}", file_path, e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error reading file").into_response()
|
||||
warn!("Error serving file: {}", e);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Error serving file").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -455,6 +536,11 @@ pub fn create_file_router_with_generator<C: CacheConfig + 'static>(
|
||||
/// - `GET /{pk}/status` - Status du download
|
||||
/// - `DELETE /{pk}` - Supprimer un item
|
||||
/// - `POST /consolidate` - Consolider le cache
|
||||
/// - `GET /{pk}/pin` - Statut de pinning
|
||||
/// - `POST /{pk}/pin` - Épingler un item
|
||||
/// - `DELETE /{pk}/pin` - Désépingler un item
|
||||
/// - `POST /{pk}/ttl` - Définir le TTL
|
||||
/// - `DELETE /{pk}/ttl` - Supprimer le TTL
|
||||
#[cfg(feature = "pmoserver")]
|
||||
pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Router {
|
||||
use crate::api;
|
||||
@@ -471,6 +557,16 @@ pub fn create_api_router<C: CacheConfig + 'static>(cache: Arc<Cache<C>>) -> Rout
|
||||
get(api::get_item_info::<C>).delete(api::delete_item::<C>),
|
||||
)
|
||||
.route("/{pk}/status", get(api::get_download_status::<C>))
|
||||
.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>),
|
||||
)
|
||||
.route("/consolidate", post(api::consolidate_cache::<C>))
|
||||
.with_state(cache)
|
||||
}
|
||||
|
||||
257
pmocache/tests/test_pinnable.rs
Normal file
257
pmocache/tests/test_pinnable.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
use chrono::{Duration, Utc};
|
||||
use pmocache::{Cache, CacheConfig};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// Configuration de test simple
|
||||
struct TestConfig;
|
||||
|
||||
impl CacheConfig for TestConfig {
|
||||
fn file_extension() -> &'static str {
|
||||
"dat"
|
||||
}
|
||||
|
||||
fn cache_type() -> &'static str {
|
||||
"test"
|
||||
}
|
||||
|
||||
fn cache_name() -> &'static str {
|
||||
"testcache"
|
||||
}
|
||||
}
|
||||
|
||||
type TestCache = Cache<TestConfig>;
|
||||
|
||||
fn create_test_cache(limit: usize) -> (TempDir, TestCache) {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let cache = TestCache::new(temp_dir.path().to_str().unwrap(), limit).unwrap();
|
||||
(temp_dir, cache)
|
||||
}
|
||||
|
||||
async fn add_test_file(cache: &TestCache, content: &str) -> String {
|
||||
let test_file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(test_file.path(), content.as_bytes()).unwrap();
|
||||
cache
|
||||
.add_from_file(test_file.path().to_str().unwrap(), None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pin_unpin() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
let pk = add_test_file(&cache, "Test data").await;
|
||||
|
||||
// Vérifier que l'item n'est pas épinglé par défaut
|
||||
assert!(!cache.is_pinned(&pk).await.unwrap());
|
||||
|
||||
// Épingler l'item
|
||||
cache.pin(&pk).await.unwrap();
|
||||
assert!(cache.is_pinned(&pk).await.unwrap());
|
||||
|
||||
// Désépingler l'item
|
||||
cache.unpin(&pk).await.unwrap();
|
||||
assert!(!cache.is_pinned(&pk).await.unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pinned_excluded_from_lru() {
|
||||
// Créer un cache avec une limite de 3 éléments non épinglés
|
||||
let (_temp_dir, cache) = create_test_cache(3);
|
||||
|
||||
let mut pks = Vec::new();
|
||||
|
||||
// Ajouter 3 fichiers normaux (atteint la limite)
|
||||
for i in 0..3 {
|
||||
let data = format!("File {} data", i);
|
||||
let pk = add_test_file(&cache, &data).await;
|
||||
pks.push(pk);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// Vérifier qu'on a 3 fichiers
|
||||
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
|
||||
|
||||
// Épingler le 2ème fichier (index 1)
|
||||
// Cela libère une place dans le comptage des non-épinglés
|
||||
let pinned_pk = pks[1].clone();
|
||||
cache.pin(&pinned_pk).await.unwrap();
|
||||
|
||||
// Maintenant on a 2 fichiers non épinglés et 1 épinglé
|
||||
assert_eq!(cache.db.count_unpinned().unwrap(), 2);
|
||||
assert_eq!(cache.db.count().unwrap(), 3);
|
||||
|
||||
// Ajouter 2 fichiers supplémentaires
|
||||
// Cela devrait déclencher l'éviction du plus vieux fichier non épinglé (index 0)
|
||||
for i in 3..5 {
|
||||
let data = format!("File {} data", i);
|
||||
let pk = add_test_file(&cache, &data).await;
|
||||
pks.push(pk);
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
// Le cache devrait contenir :
|
||||
// - 3 fichiers non épinglés (la limite)
|
||||
// - 1 fichier épinglé
|
||||
// Total = 4 fichiers
|
||||
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
|
||||
assert_eq!(cache.db.count().unwrap(), 4);
|
||||
|
||||
// Vérifier que le fichier épinglé est toujours là
|
||||
assert!(cache.get(&pinned_pk).await.is_ok());
|
||||
assert!(cache.is_pinned(&pinned_pk).await.unwrap());
|
||||
|
||||
// Le premier fichier (index 0, le plus vieux non épinglé) devrait avoir été évincé
|
||||
assert!(cache.get(&pks[0]).await.is_err());
|
||||
|
||||
// Le 3ème fichier (index 2) devrait être présent (non épinglé mais pas le plus vieux)
|
||||
assert!(cache.get(&pks[2]).await.is_ok());
|
||||
|
||||
// Les 2 derniers fichiers devraient être présents
|
||||
assert!(cache.get(&pks[3]).await.is_ok());
|
||||
assert!(cache.get(&pks[4]).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_pinned_count_separately() {
|
||||
let (_temp_dir, cache) = create_test_cache(5);
|
||||
|
||||
// Ajouter 3 fichiers normaux
|
||||
for i in 0..3 {
|
||||
let data = format!("File {}", i);
|
||||
add_test_file(&cache, &data).await;
|
||||
}
|
||||
|
||||
// Ajouter 2 fichiers épinglés
|
||||
for i in 3..5 {
|
||||
let data = format!("Pinned file {}", i);
|
||||
let pk = add_test_file(&cache, &data).await;
|
||||
cache.pin(&pk).await.unwrap();
|
||||
}
|
||||
|
||||
// Le comptage total devrait être 5
|
||||
assert_eq!(cache.db.count().unwrap(), 5);
|
||||
|
||||
// Le comptage non épinglé devrait être 3
|
||||
assert_eq!(cache.db.count_unpinned().unwrap(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cannot_pin_with_ttl() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
let pk = add_test_file(&cache, "Test data").await;
|
||||
|
||||
// Définir un TTL
|
||||
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||
cache.set_ttl(&pk, &expires_at).await.unwrap();
|
||||
|
||||
// Essayer d'épingler devrait échouer
|
||||
assert!(cache.pin(&pk).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cannot_set_ttl_when_pinned() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
let pk = add_test_file(&cache, "Test data").await;
|
||||
|
||||
// Épingler l'item
|
||||
cache.pin(&pk).await.unwrap();
|
||||
|
||||
// Essayer de définir un TTL devrait échouer
|
||||
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||
assert!(cache.set_ttl(&pk, &expires_at).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_ttl_expiration() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
// Ajouter un fichier avec un TTL expiré
|
||||
let pk = add_test_file(&cache, "Expiring data").await;
|
||||
let expires_at = (Utc::now() - Duration::seconds(1)).to_rfc3339(); // Déjà expiré
|
||||
cache.set_ttl(&pk, &expires_at).await.unwrap();
|
||||
|
||||
// Vérifier que le fichier existe avant l'enforcement
|
||||
assert!(cache.get(&pk).await.is_ok());
|
||||
|
||||
// Déclencher le nettoyage
|
||||
cache.enforce_limit().await.unwrap();
|
||||
|
||||
// Le fichier devrait avoir été supprimé
|
||||
assert!(cache.get(&pk).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_clear_ttl() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
let pk = add_test_file(&cache, "Test data").await;
|
||||
|
||||
// Définir un TTL
|
||||
let expires_at = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||
cache.set_ttl(&pk, &expires_at).await.unwrap();
|
||||
|
||||
// Vérifier que le TTL est défini
|
||||
let entry = cache.db.get(&pk, false).unwrap();
|
||||
assert!(entry.ttl_expires_at.is_some());
|
||||
|
||||
// Supprimer le TTL
|
||||
cache.clear_ttl(&pk).await.unwrap();
|
||||
|
||||
// Vérifier que le TTL a été supprimé
|
||||
let entry = cache.db.get(&pk, false).unwrap();
|
||||
assert!(entry.ttl_expires_at.is_none());
|
||||
|
||||
// Maintenant on devrait pouvoir épingler
|
||||
assert!(cache.pin(&pk).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_expired() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
// Ajouter un fichier non expiré
|
||||
let pk1 = add_test_file(&cache, "Non-expired data").await;
|
||||
let expires_at1 = (Utc::now() + Duration::hours(1)).to_rfc3339();
|
||||
cache.set_ttl(&pk1, &expires_at1).await.unwrap();
|
||||
|
||||
// Ajouter un fichier expiré
|
||||
let pk2 = add_test_file(&cache, "Expired data").await;
|
||||
let expires_at2 = (Utc::now() - Duration::seconds(1)).to_rfc3339();
|
||||
cache.set_ttl(&pk2, &expires_at2).await.unwrap();
|
||||
|
||||
// Récupérer les items expirés
|
||||
let expired = cache.db.get_expired().unwrap();
|
||||
|
||||
// Seulement le deuxième fichier devrait être dans la liste
|
||||
assert_eq!(expired.len(), 1);
|
||||
assert_eq!(expired[0].pk, pk2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cache_entry_fields() {
|
||||
let (_temp_dir, cache) = create_test_cache(10);
|
||||
|
||||
let pk = add_test_file(&cache, "Test data").await;
|
||||
|
||||
// Vérifier les valeurs par défaut
|
||||
let entry = cache.db.get(&pk, false).unwrap();
|
||||
assert!(!entry.pinned);
|
||||
assert!(entry.ttl_expires_at.is_none());
|
||||
|
||||
// Épingler et vérifier
|
||||
cache.pin(&pk).await.unwrap();
|
||||
let entry = cache.db.get(&pk, false).unwrap();
|
||||
assert!(entry.pinned);
|
||||
|
||||
// Désépingler et définir un TTL
|
||||
cache.unpin(&pk).await.unwrap();
|
||||
let expires_at = (Utc::now() + Duration::hours(2)).to_rfc3339();
|
||||
cache.set_ttl(&pk, &expires_at).await.unwrap();
|
||||
|
||||
let entry = cache.db.get(&pk, false).unwrap();
|
||||
assert!(!entry.pinned);
|
||||
assert!(entry.ttl_expires_at.is_some());
|
||||
}
|
||||
@@ -7,21 +7,21 @@ edition = "2021"
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
serde_json = "1.0"
|
||||
serde = { workspace = true }
|
||||
serde_yaml = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
uuid = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
tokio = { workspace = true, optional = true }
|
||||
|
||||
# Serveur HTTP (pour l'API REST)
|
||||
axum = { version = "0.8", optional = true }
|
||||
|
||||
32
pmoconfig/Cargo.toml.backup
Normal file
32
pmoconfig/Cargo.toml.backup
Normal file
@@ -0,0 +1,32 @@
|
||||
|
||||
[package]
|
||||
name = "pmoconfig"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
pmoutils ={ path = "../pmoutils" }
|
||||
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9.33"
|
||||
serde_json = "1.0"
|
||||
lazy_static = "1.4.0"
|
||||
dirs = "6.0.0"
|
||||
log = "0.4.20"
|
||||
anyhow = "1.0.75"
|
||||
uuid = { version = "1.18.1", features = ["v4"] }
|
||||
tracing = "0.1.41"
|
||||
aes-gcm = "0.10"
|
||||
sha2 = "0.10"
|
||||
base64 = "0.22"
|
||||
|
||||
# Async
|
||||
tokio = { version = "1.0", features = ["full"], optional = true }
|
||||
|
||||
# Serveur HTTP (pour l'API REST)
|
||||
axum = { version = "0.8", optional = true }
|
||||
utoipa = { version = "5.3", features = ["axum_extras"], optional = true }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
api = ["dep:tokio", "dep:axum", "dep:utoipa"]
|
||||
@@ -1,18 +1,18 @@
|
||||
[package]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
version = "0.3.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
quick-xml = "0.38.4"
|
||||
thiserror = "2.0.17"
|
||||
quick-xml = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
ureq = "3.1.4"
|
||||
tracing = "0.1.41"
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-log = "0.1"
|
||||
anyhow = "1.0"
|
||||
anyhow = { workspace = true }
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||
@@ -23,14 +23,15 @@ mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
smol = "2.0"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||
tokio = { workspace = true, features = ["sync", "rt"], optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
@@ -38,11 +39,9 @@ async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
|
||||
48
pmocontrol/Cargo.toml.backup
Normal file
48
pmocontrol/Cargo.toml.backup
Normal file
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "pmocontrol"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
pmoupnp = { path = "../pmoupnp" }
|
||||
pmodidl = { path = "../pmodidl" }
|
||||
quick-xml = "0.38.4"
|
||||
thiserror = "2.0.17"
|
||||
ureq = "3.1.4"
|
||||
tracing = "0.1.41"
|
||||
tracing-subscriber = "0.3"
|
||||
tracing-log = "0.1"
|
||||
anyhow = "1.0"
|
||||
xmltree = "0.11.0"
|
||||
crossbeam-channel = "0.5"
|
||||
ratatui = { version = "0.26", default-features = false, features = ["crossterm"] }
|
||||
crossterm = "0.27"
|
||||
rust_cast = "0.19"
|
||||
rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
mdns = "3.0"
|
||||
async-std = "1.12"
|
||||
futures-util = "0.3"
|
||||
smol = "2.0"
|
||||
|
||||
# pmoserver extension support (optional)
|
||||
pmoserver = { path = "../pmoserver", optional = true }
|
||||
utoipa = { version = "5.4.0", optional = true }
|
||||
axum = { version = "0.8.4", optional = true }
|
||||
serde = { version = "1.0", features = ["derive"], optional = true }
|
||||
serde_json = { version = "1.0", optional = true }
|
||||
tokio = { version = "1", features = ["sync", "rt"], optional = true }
|
||||
tokio-util = { version = "0.7", optional = true }
|
||||
async-trait = { version = "0.1", optional = true }
|
||||
tokio-stream = { version = "0.1", features = ["sync"], optional = true }
|
||||
async-stream = { version = "0.3", optional = true }
|
||||
chrono = { version = "0.4", features = ["serde"], optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
percent-encoding = "2.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Active l'API REST pmoserver
|
||||
pmoserver = ["dep:pmoserver", "dep:utoipa", "dep:axum", "dep:serde", "dep:serde_json", "dep:tokio", "dep:tokio-util", "dep:async-trait", "dep:tokio-stream", "dep:async-stream", "dep:chrono"]
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
||||
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||
PlaybackState, PlaybackStatus, TransportControl, VolumeControl,
|
||||
};
|
||||
|
||||
@@ -38,10 +38,10 @@ fn main() -> io::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let arylic_renderers: Vec<MusicRenderer> = renderers
|
||||
let arylic_renderers: Vec<MusicRendererBackend> = renderers
|
||||
.iter()
|
||||
.filter(|info| info.capabilities.has_arylic_tcp)
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
||||
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||
.collect();
|
||||
|
||||
if arylic_renderers.is_empty() {
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::RendererProtocol;
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, MediaServerInfo, RendererInfo};
|
||||
use pmocontrol::{ControlPoint, DeviceRegistryRead, UpnpMediaServer, RendererInfo};
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
// Un tout petit logging optionnel
|
||||
@@ -21,7 +21,7 @@ fn main() -> std::io::Result<()> {
|
||||
let reg = reg.read().expect("registry poisoned");
|
||||
|
||||
let renderers: Vec<RendererInfo> = reg.list_renderers();
|
||||
let servers: Vec<MediaServerInfo> = reg.list_servers();
|
||||
let servers: Vec<UpnpMediaServer> = reg.list_servers();
|
||||
|
||||
println!("=====================");
|
||||
println!("Renderers detected : {}", renderers.len());
|
||||
@@ -29,7 +29,7 @@ fn main() -> std::io::Result<()> {
|
||||
let proto = match r.protocol {
|
||||
RendererProtocol::UpnpAvOnly => "UPnP AV",
|
||||
RendererProtocol::OpenHomeOnly => "OpenHome",
|
||||
RendererProtocol::Hybrid => "Hybrid",
|
||||
RendererProtocol::OpenHomeHybrid => "Hybrid",
|
||||
};
|
||||
|
||||
println!(
|
||||
|
||||
@@ -19,8 +19,8 @@ use crossterm::terminal::{
|
||||
};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
|
||||
RendererEvent, RendererInfo, TransportControl, VolumeControl,
|
||||
};
|
||||
use ratatui::Terminal;
|
||||
@@ -102,7 +102,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let servers = {
|
||||
let reg = registry.read().expect("registry poisoned");
|
||||
let list: Vec<MediaServerInfo> = reg
|
||||
let list: Vec<UpnpMediaServer> = reg
|
||||
.list_servers()
|
||||
.into_iter()
|
||||
.filter(|s| s.has_content_directory && s.content_directory_control_url.is_some())
|
||||
@@ -129,10 +129,10 @@ struct App {
|
||||
renderers: Vec<RendererInfo>,
|
||||
renderer_index: usize,
|
||||
renderer_info: Option<RendererInfo>,
|
||||
servers: Vec<MediaServerInfo>,
|
||||
servers: Vec<UpnpMediaServer>,
|
||||
server_index: usize,
|
||||
server_info: Option<MediaServerInfo>,
|
||||
music_server: Option<MusicServer>,
|
||||
server_info: Option<UpnpMediaServer>,
|
||||
music_server: Option<UpnpMediaServer>,
|
||||
browser: Option<BrowserState>,
|
||||
mode: Mode,
|
||||
ui_state: UiState,
|
||||
@@ -168,7 +168,7 @@ impl App {
|
||||
fn new(
|
||||
control_point: Arc<ControlPoint>,
|
||||
renderers: Vec<RendererInfo>,
|
||||
servers: Vec<MediaServerInfo>,
|
||||
servers: Vec<UpnpMediaServer>,
|
||||
) -> Self {
|
||||
Self {
|
||||
control_point,
|
||||
@@ -192,7 +192,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn renderer_id(&self) -> Option<pmocontrol::model::RendererId> {
|
||||
fn renderer_id(&self) -> Option<pmocontrol::model::ServiceId> {
|
||||
self.renderer_info.as_ref().map(|info| info.id.clone())
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ impl App {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let info = self.servers[self.server_index].clone();
|
||||
match MusicServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
match UpnpMediaServer::from_info(&info, Duration::from_secs(DEFAULT_TIMEOUT_SECS)) {
|
||||
Ok(server) => {
|
||||
let entries = server.browse_root()?;
|
||||
let browser = BrowserState::new(entries);
|
||||
@@ -947,7 +947,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::RendererId) {
|
||||
fn refresh_queue_snapshot(&mut self, renderer_id: &pmocontrol::model::ServiceId) {
|
||||
if let Ok((queue, current_index)) = self.control_point.get_full_queue_snapshot(renderer_id)
|
||||
{
|
||||
self.queue_snapshot = queue;
|
||||
@@ -967,7 +967,7 @@ impl App {
|
||||
|
||||
fn peek_next_queue_item(
|
||||
&self,
|
||||
renderer_id: &pmocontrol::model::RendererId,
|
||||
renderer_id: &pmocontrol::model::ServiceId,
|
||||
) -> Option<PlaybackItem> {
|
||||
self.control_point
|
||||
.get_queue_snapshot(renderer_id)
|
||||
@@ -975,7 +975,7 @@ impl App {
|
||||
.and_then(|queue| queue.into_iter().next())
|
||||
}
|
||||
|
||||
fn get_renderer(&self) -> Result<pmocontrol::MusicRenderer> {
|
||||
fn get_renderer(&self) -> Result<pmocontrol::MusicRendererBackend> {
|
||||
let renderer_id = self
|
||||
.renderer_id()
|
||||
.ok_or_else(|| anyhow!("Renderer not selected"))?;
|
||||
@@ -1230,7 +1230,7 @@ impl NavigationState {
|
||||
|
||||
/// Collect playable items from MediaEntry list (including nested containers).
|
||||
fn collect_playable_items(
|
||||
server: &MusicServer,
|
||||
server: &UpnpMediaServer,
|
||||
entries: &[MediaEntry],
|
||||
) -> Result<Vec<PlaybackItem>> {
|
||||
let mut items = Vec::new();
|
||||
@@ -1255,7 +1255,7 @@ fn collect_playable_items(
|
||||
}
|
||||
|
||||
/// Convert MediaEntry to PlaybackItem.
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
|
||||
@@ -3,7 +3,7 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MusicRenderer, PlaybackPosition, PlaybackPositionInfo,
|
||||
ControlPoint, DeviceRegistryRead, MusicRendererBackend, PlaybackPosition, PlaybackPositionInfo,
|
||||
PlaybackState, PlaybackStatus, VolumeControl,
|
||||
};
|
||||
|
||||
@@ -34,10 +34,10 @@ fn main() -> io::Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let linkplay_renderers: Vec<MusicRenderer> = renderers
|
||||
let linkplay_renderers: Vec<MusicRendererBackend> = renderers
|
||||
.iter()
|
||||
.filter(|info| info.capabilities.has_linkplay_http)
|
||||
.filter_map(|info| MusicRenderer::from_registry_info(info.clone(), ®istry))
|
||||
.filter_map(|info| MusicRendererBackend::from_renderer_info(info.clone(), ®istry))
|
||||
.collect();
|
||||
|
||||
if linkplay_renderers.is_empty() {
|
||||
|
||||
@@ -10,8 +10,8 @@ use std::time::Duration;
|
||||
use anyhow::{Context, Result};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, UpnpMediaServer,
|
||||
MusicRendererBackend, UpnpMediaServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
@@ -69,7 +69,7 @@ fn main() -> Result<()> {
|
||||
server_info.friendly_name, server_info.location, server_info.id.0
|
||||
);
|
||||
|
||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
||||
let renderer_instance = MusicRendererBackend::from_renderer_info(renderer.clone(), ®istry)
|
||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||
let supports_set_next = renderer_instance
|
||||
.as_upnp()
|
||||
@@ -82,7 +82,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
let server =
|
||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
UpnpMediaServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
|
||||
println!("Searching for a Live Playlist container in ContentDirectory...");
|
||||
let live_playlist_container = find_live_playlist_container(&server)
|
||||
@@ -355,8 +355,8 @@ fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
||||
let mut candidates: Vec<MediaServerInfo> = servers
|
||||
fn pick_pmomusic_server(servers: Vec<UpnpMediaServer>) -> Option<UpnpMediaServer> {
|
||||
let mut candidates: Vec<UpnpMediaServer> = servers
|
||||
.into_iter()
|
||||
.filter(|info| info.has_content_directory)
|
||||
.filter(|info| info.content_directory_control_url.is_some())
|
||||
@@ -381,7 +381,7 @@ fn pick_pmomusic_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo
|
||||
Some(candidates.remove(0))
|
||||
}
|
||||
|
||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
||||
fn is_pmomusic_server(info: &UpnpMediaServer) -> bool {
|
||||
let name = info.friendly_name.to_ascii_lowercase();
|
||||
let model = info.model_name.to_ascii_lowercase();
|
||||
let manufacturer = info.manufacturer.to_ascii_lowercase();
|
||||
@@ -395,7 +395,7 @@ fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
||||
}
|
||||
|
||||
/// Search for a container whose title contains "Live Playlist" using BFS.
|
||||
fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntry>> {
|
||||
fn find_live_playlist_container(server: &UpnpMediaServer) -> Result<Option<MediaEntry>> {
|
||||
let root_entries = server
|
||||
.browse_root()
|
||||
.context("Failed to browse ContentDirectory root")?;
|
||||
@@ -477,7 +477,7 @@ fn find_live_playlist_container(server: &MusicServer) -> Result<Option<MediaEntr
|
||||
/// Collect playable items from a specific container.
|
||||
/// Retries with fewer items if the initial browse times out.
|
||||
fn collect_playable_items_from_container(
|
||||
server: &MusicServer,
|
||||
server: &UpnpMediaServer,
|
||||
container_id: &str,
|
||||
max_tracks: usize,
|
||||
) -> Result<Vec<PlaybackItem>> {
|
||||
@@ -532,7 +532,7 @@ fn collect_playable_items_from_container(
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
fn playback_item_from_entry(server: &UpnpMediaServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
// Skip live streams (we're looking for regular tracks in a live playlist)
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::Result;
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, MediaServerInfo, ServerId};
|
||||
use pmocontrol::{ControlPoint, MediaServerEvent, UpnpMediaServer, ServerId};
|
||||
|
||||
const DISCOVERY_WAIT_SECS: u64 = 5;
|
||||
const MONITOR_DURATION_SECS: u64 = 90;
|
||||
@@ -33,7 +33,7 @@ fn main() -> Result<()> {
|
||||
);
|
||||
}
|
||||
|
||||
let mut cache: HashMap<ServerId, MediaServerInfo> = servers
|
||||
let mut cache: HashMap<ServerId, UpnpMediaServer> = servers
|
||||
.into_iter()
|
||||
.map(|info| (info.id.clone(), info))
|
||||
.collect();
|
||||
@@ -64,7 +64,7 @@ fn main() -> Result<()> {
|
||||
|
||||
fn print_event(
|
||||
cp: &ControlPoint,
|
||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||
event: &MediaServerEvent,
|
||||
) {
|
||||
match event {
|
||||
@@ -101,7 +101,7 @@ fn print_event(
|
||||
|
||||
fn describe_server(
|
||||
cp: &ControlPoint,
|
||||
cache: &mut HashMap<ServerId, MediaServerInfo>,
|
||||
cache: &mut HashMap<ServerId, UpnpMediaServer>,
|
||||
id: &ServerId,
|
||||
) -> String {
|
||||
if let Some(info) = cache.get(id) {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use pmocontrol::{
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRenderer,
|
||||
DeviceDescriptionProvider, DiscoveredEndpoint, HttpXmlDescriptionProvider, MusicRendererBackend,
|
||||
RendererInfo,
|
||||
control_point::ControlPoint,
|
||||
openhome_client::{
|
||||
@@ -86,7 +86,7 @@ fn auto_discover_renderer() -> Result<RendererInfo> {
|
||||
let mut openhome_infos = Vec::new();
|
||||
|
||||
for renderer in renderers {
|
||||
if let MusicRenderer::OpenHome(oh) = renderer {
|
||||
if let MusicRendererBackend::OpenHome(oh) = renderer {
|
||||
if seen.insert(oh.id().0.clone()) {
|
||||
openhome_infos.push(oh.info.clone());
|
||||
}
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
//! End-to-end queue demo that prefers the PMOMusic media server and exercises
|
||||
//! the ControlPoint playback queue API.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::env;
|
||||
use std::process;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use pmocontrol::model::TrackMetadata;
|
||||
use pmocontrol::{
|
||||
ControlPoint, DeviceRegistryRead, MediaBrowser, MediaEntry, MediaServerEvent, MediaServerInfo,
|
||||
MusicRenderer, MusicServer, PlaybackItem, PlaybackPosition, PlaybackPositionInfo, RendererInfo,
|
||||
};
|
||||
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 5;
|
||||
const DEFAULT_DISCOVERY_SECS: u64 = 5;
|
||||
const DEFAULT_MAX_TRACKS: usize = 3;
|
||||
const MONITOR_DURATION_SECS: u64 = 600;
|
||||
const MONITOR_POLL_SECS: u64 = 5;
|
||||
const MAX_BROWSE_DEPTH: usize = 2;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let _ = tracing_subscriber::fmt::try_init();
|
||||
let config = CliConfig::parse_from_env().unwrap_or_else(|err| {
|
||||
eprintln!("Error parsing arguments: {err}");
|
||||
print_usage_and_exit();
|
||||
});
|
||||
|
||||
if config.max_tracks == 0 {
|
||||
eprintln!("--max-tracks must be >= 1");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Starting queue_pmomusic_demo with timeout={}s discovery={}s max_tracks={}",
|
||||
config.timeout_secs, config.discovery_secs, config.max_tracks
|
||||
);
|
||||
|
||||
// ControlPoint::spawn starts the HttpXmlDescriptionProvider + DiscoveryManager combo.
|
||||
let control_point =
|
||||
ControlPoint::spawn(config.timeout_secs).context("Failed to start control point")?;
|
||||
|
||||
println!(
|
||||
"Discovery running for {} seconds before selecting devices...",
|
||||
config.discovery_secs
|
||||
);
|
||||
thread::sleep(Duration::from_secs(config.discovery_secs));
|
||||
|
||||
let registry = control_point.registry();
|
||||
let (renderer, server_info) = {
|
||||
let reg = registry.read().expect("registry poisoned");
|
||||
let renderer_candidates: Vec<RendererInfo> = reg
|
||||
.list_renderers()
|
||||
.into_iter()
|
||||
.filter(|info| !is_pmomusic_renderer(info))
|
||||
.collect();
|
||||
let renderer = pick_renderer(renderer_candidates)
|
||||
.unwrap_or_else(|| no_renderer_and_exit("No suitable renderer found after discovery."));
|
||||
let server = pick_media_server(reg.list_servers())
|
||||
.unwrap_or_else(|| no_server_and_exit("No media server with ContentDirectory."));
|
||||
(renderer, server)
|
||||
};
|
||||
|
||||
println!(
|
||||
"Selected renderer \"{}\" (protocol={:?}, id={})",
|
||||
renderer.friendly_name, renderer.protocol, renderer.id.0
|
||||
);
|
||||
println!(
|
||||
"Selected media server \"{}\" at {} (id={})",
|
||||
server_info.friendly_name, server_info.location, server_info.id.0
|
||||
);
|
||||
|
||||
let renderer_instance = MusicRenderer::from_registry_info(renderer.clone(), ®istry)
|
||||
.expect("Selected renderer is not usable by MusicRenderer façade");
|
||||
let supports_set_next = renderer_instance
|
||||
.as_upnp()
|
||||
.map(|upnp| upnp.supports_set_next())
|
||||
.unwrap_or(false);
|
||||
println!(
|
||||
"Renderer \"{}\": AVTransport present = {}, SetNextAVTransportURI supported = {}",
|
||||
renderer.friendly_name, renderer.capabilities.has_avtransport, supports_set_next
|
||||
);
|
||||
|
||||
let timeout = Duration::from_secs(config.timeout_secs);
|
||||
let server =
|
||||
MusicServer::from_info(&server_info, timeout).context("Failed to init MusicServer")?;
|
||||
|
||||
let root_entries = server
|
||||
.browse_root()
|
||||
.context("Failed to browse ContentDirectory root")?;
|
||||
println!("Root returned {} entries", root_entries.len());
|
||||
|
||||
// Try to find a playlist container first
|
||||
let (playback_items, bound_container_id) =
|
||||
collect_playable_items_with_binding(&server, &root_entries, config.max_tracks)
|
||||
.context("Failed to derive playable items from ContentDirectory root/children")?;
|
||||
|
||||
if playback_items.is_empty() {
|
||||
println!("No playable tracks were found on the selected server.");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
println!(
|
||||
"Discovered {} playable items; enqueuing…",
|
||||
playback_items.len()
|
||||
);
|
||||
|
||||
if let Some(ref container_id) = bound_container_id {
|
||||
println!(
|
||||
"Found playlist container '{}' to bind queue to",
|
||||
container_id
|
||||
);
|
||||
} else {
|
||||
println!("No playlist container found; queue will not be bound to server");
|
||||
}
|
||||
|
||||
let mut planned_queue: VecDeque<PlaybackItem>;
|
||||
let renderer_id = renderer.id.clone();
|
||||
control_point
|
||||
.clear_queue(&renderer_id)
|
||||
.context("Failed to clear playback queue")?;
|
||||
control_point
|
||||
.enqueue_items(&renderer_id, playback_items)
|
||||
.context("Failed to enqueue playback items")?;
|
||||
|
||||
// Attach queue to playlist container if we found one
|
||||
if let Some(container_id) = bound_container_id {
|
||||
control_point
|
||||
.attach_queue_to_playlist(&renderer_id, server_info.id.clone(), container_id.clone())
|
||||
.context("Failed to attach queue to playlist container")?;
|
||||
println!(
|
||||
"✓ Queue attached to playlist container {} on server {}",
|
||||
container_id, server_info.friendly_name
|
||||
);
|
||||
}
|
||||
|
||||
let snapshot = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Failed to snapshot queue after enqueue")?;
|
||||
print_queue_snapshot(&snapshot);
|
||||
planned_queue = snapshot.clone().into();
|
||||
|
||||
control_point
|
||||
.play_next_from_queue(&renderer_id)
|
||||
.context("Failed to start playback from queue")?;
|
||||
let mut current_track = planned_queue.pop_front();
|
||||
let remaining = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Failed to snapshot queue after play_next_from_queue")?;
|
||||
planned_queue = remaining.clone().into();
|
||||
println!(
|
||||
"Playback started on \"{}\"; {} tracks remaining in queue.",
|
||||
renderer.friendly_name,
|
||||
remaining.len()
|
||||
);
|
||||
|
||||
println!(
|
||||
"Monitoring queue auto-advance for {} seconds (poll every {}s)…",
|
||||
MONITOR_DURATION_SECS, MONITOR_POLL_SECS
|
||||
);
|
||||
|
||||
// Subscribe to media server events to observe playlist updates
|
||||
let media_event_rx = control_point.subscribe_media_server_events();
|
||||
|
||||
let poll_count = MONITOR_DURATION_SECS / MONITOR_POLL_SECS;
|
||||
for tick in 0..poll_count {
|
||||
thread::sleep(Duration::from_secs(MONITOR_POLL_SECS));
|
||||
|
||||
// Drain any MediaServerEvent that arrived since last poll
|
||||
loop {
|
||||
match media_event_rx.try_recv() {
|
||||
Ok(MediaServerEvent::GlobalUpdated {
|
||||
server_id,
|
||||
system_update_id,
|
||||
}) => {
|
||||
println!(
|
||||
" 📢 MediaServer {} global update (SystemUpdateID={:?})",
|
||||
server_id.0, system_update_id
|
||||
);
|
||||
}
|
||||
Ok(MediaServerEvent::ContainersUpdated {
|
||||
server_id,
|
||||
container_ids,
|
||||
}) => {
|
||||
println!(
|
||||
" 📢 MediaServer {} containers updated: {:?}",
|
||||
server_id.0, container_ids
|
||||
);
|
||||
|
||||
// Check if our bound container was updated
|
||||
if let Some((bound_server, bound_container, _)) =
|
||||
control_point.current_queue_playlist_binding(&renderer_id)
|
||||
{
|
||||
if bound_server == server_id && container_ids.contains(&bound_container) {
|
||||
println!(
|
||||
" 🔄 Bound playlist container '{}' was updated, queue will refresh automatically",
|
||||
bound_container
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => break, // No more events, continue with normal monitoring
|
||||
}
|
||||
}
|
||||
|
||||
let snapshot = control_point
|
||||
.get_queue_snapshot(&renderer_id)
|
||||
.context("Queue snapshot failed during monitoring loop")?;
|
||||
let new_plan: VecDeque<PlaybackItem> = snapshot.clone().into();
|
||||
if planned_queue.len() > new_plan.len() {
|
||||
let removed = planned_queue.len() - new_plan.len();
|
||||
for _ in 0..removed {
|
||||
current_track = planned_queue.pop_front();
|
||||
}
|
||||
}
|
||||
planned_queue = new_plan;
|
||||
|
||||
let playback_info = control_point
|
||||
.music_renderer_by_id(&renderer_id)
|
||||
.and_then(|renderer| renderer.playback_position().ok());
|
||||
|
||||
let title = current_track_title(current_track.as_ref());
|
||||
if let Some(info) = playback_info {
|
||||
println!(
|
||||
"[tick {tick}] Queue length = {} | now playing: {} [{}]",
|
||||
snapshot.len(),
|
||||
title,
|
||||
format_playback_position(&info)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"[tick {tick}] Queue length = {} | now playing: {} [position unavailable]",
|
||||
snapshot.len(),
|
||||
title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Monitoring finished, exiting.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CliConfig {
|
||||
timeout_secs: u64,
|
||||
discovery_secs: u64,
|
||||
max_tracks: usize,
|
||||
}
|
||||
|
||||
impl CliConfig {
|
||||
fn parse_from_env() -> Result<Self, String> {
|
||||
let mut timeout_secs = DEFAULT_TIMEOUT_SECS;
|
||||
let mut discovery_secs = DEFAULT_DISCOVERY_SECS;
|
||||
let mut max_tracks = DEFAULT_MAX_TRACKS;
|
||||
|
||||
let mut args = env::args().skip(1);
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"--timeout-secs" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--timeout-secs requires a value".to_string())?;
|
||||
timeout_secs = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --timeout-secs ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--discovery-secs" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--discovery-secs requires a value".to_string())?;
|
||||
discovery_secs = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --discovery-secs ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--max-tracks" => {
|
||||
let value = args
|
||||
.next()
|
||||
.ok_or_else(|| "--max-tracks requires a value".to_string())?;
|
||||
max_tracks = value.parse().map_err(|err| {
|
||||
format!("Invalid value for --max-tracks ({value}): {err}")
|
||||
})?;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage_and_exit();
|
||||
}
|
||||
unknown => {
|
||||
return Err(format!("Unknown argument: {unknown}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
timeout_secs,
|
||||
discovery_secs,
|
||||
max_tracks,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_renderer(renderers: Vec<RendererInfo>) -> Option<RendererInfo> {
|
||||
let mut candidates: Vec<RendererInfo> = renderers;
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
println!("Renderer candidates:");
|
||||
for (idx, info) in candidates.iter().enumerate() {
|
||||
println!(
|
||||
" [{}] {} | model={} | location={} | online={}",
|
||||
idx, info.friendly_name, info.model_name, info.location, info.online
|
||||
);
|
||||
}
|
||||
|
||||
let selected = candidates.remove(0);
|
||||
println!(
|
||||
"Automatically selecting renderer index 0: {}",
|
||||
selected.friendly_name
|
||||
);
|
||||
Some(selected)
|
||||
}
|
||||
|
||||
fn pick_media_server(servers: Vec<MediaServerInfo>) -> Option<MediaServerInfo> {
|
||||
let mut candidates: Vec<MediaServerInfo> = servers
|
||||
.into_iter()
|
||||
.filter(|info| info.has_content_directory)
|
||||
.filter(|info| info.content_directory_control_url.is_some())
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(idx) = candidates.iter().position(is_pmomusic_server) {
|
||||
let server = candidates.remove(idx);
|
||||
println!(
|
||||
"Preferring PMOMusic server \"{}\" (server header: {}).",
|
||||
server.friendly_name, server.server_header
|
||||
);
|
||||
Some(server)
|
||||
} else {
|
||||
println!("No PMOMusic server discovered; falling back to first ContentDirectory server.");
|
||||
Some(candidates.remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pmomusic_server(info: &MediaServerInfo) -> bool {
|
||||
let name = info.friendly_name.to_ascii_lowercase();
|
||||
let header = info.server_header.to_ascii_lowercase();
|
||||
name.contains("pmomusic") || header.contains("pmomusic")
|
||||
}
|
||||
|
||||
fn is_pmomusic_renderer(info: &RendererInfo) -> bool {
|
||||
info.friendly_name
|
||||
.to_ascii_lowercase()
|
||||
.contains("pmomusic audio renderer")
|
||||
}
|
||||
|
||||
fn collect_playable_items_with_binding(
|
||||
server: &MusicServer,
|
||||
entries: &[MediaEntry],
|
||||
max_tracks: usize,
|
||||
) -> Result<(Vec<PlaybackItem>, Option<String>)> {
|
||||
// First, try to find a playlist container
|
||||
let playlist_container = entries.iter().find(|entry| {
|
||||
entry.is_container
|
||||
&& entry
|
||||
.class
|
||||
.to_ascii_lowercase()
|
||||
.contains("object.container.playlistcontainer")
|
||||
});
|
||||
|
||||
if let Some(playlist) = playlist_container {
|
||||
println!(
|
||||
"Found playlist container: '{}' (id: {}, class: {})",
|
||||
playlist.title, playlist.id, playlist.class
|
||||
);
|
||||
|
||||
// Browse the playlist container
|
||||
match server.browse_children(&playlist.id, 0, max_tracks as u32) {
|
||||
Ok(children) => {
|
||||
let mut items = Vec::new();
|
||||
for entry in &children {
|
||||
if let Some(item) = playback_item_from_entry(server, entry) {
|
||||
items.push(item);
|
||||
if items.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !items.is_empty() {
|
||||
return Ok((items, Some(playlist.id.clone())));
|
||||
}
|
||||
|
||||
println!(
|
||||
"Playlist container '{}' is empty, falling back to general browse",
|
||||
playlist.title
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
println!(
|
||||
"Failed to browse playlist container '{}': {}, falling back",
|
||||
playlist.title, err
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("No playlist container found in root entries, using fallback");
|
||||
}
|
||||
|
||||
// Fallback: collect from any container/item
|
||||
let mut items = Vec::new();
|
||||
for entry in entries {
|
||||
gather_items_from_entry(server, entry, max_tracks, 0, &mut items)?;
|
||||
if items.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok((items, None))
|
||||
}
|
||||
|
||||
fn gather_items_from_entry(
|
||||
server: &MusicServer,
|
||||
entry: &MediaEntry,
|
||||
max_tracks: usize,
|
||||
depth: usize,
|
||||
out: &mut Vec<PlaybackItem>,
|
||||
) -> Result<()> {
|
||||
if out.len() >= max_tracks {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if entry.is_container {
|
||||
if depth >= MAX_BROWSE_DEPTH {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match server.browse_children(&entry.id, 0, 50) {
|
||||
Ok(children) => {
|
||||
for child in children {
|
||||
gather_items_from_entry(server, &child, max_tracks, depth + 1, out)?;
|
||||
if out.len() >= max_tracks {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
container_id = entry.id.as_str(),
|
||||
error = %err,
|
||||
"Failed to browse child container"
|
||||
);
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(item) = playback_item_from_entry(server, entry) {
|
||||
out.push(item);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<PlaybackItem> {
|
||||
if entry.title.to_ascii_lowercase().contains("live stream") {
|
||||
return None;
|
||||
}
|
||||
let resource = entry.resources.iter().find(|res| res.is_audio())?;
|
||||
let metadata = TrackMetadata {
|
||||
title: Some(entry.title.clone()),
|
||||
artist: entry.artist.clone(),
|
||||
album: entry.album.clone(),
|
||||
genre: entry.genre.clone(),
|
||||
album_art_uri: entry.album_art_uri.clone(),
|
||||
date: entry.date.clone(),
|
||||
track_number: entry.track_number.clone(),
|
||||
creator: entry.creator.clone(),
|
||||
};
|
||||
Some(PlaybackItem {
|
||||
media_server_id: server.id().clone(),
|
||||
didl_id: entry.id.clone(),
|
||||
uri: resource.uri.clone(),
|
||||
protocol_info: resource.protocol_info.clone(),
|
||||
metadata: Some(metadata),
|
||||
})
|
||||
}
|
||||
|
||||
fn print_queue_snapshot(items: &[PlaybackItem]) {
|
||||
println!("Current queue snapshot ({} items):", items.len());
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
let label = item
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.title.as_deref())
|
||||
.unwrap_or_else(|| item.uri.as_str());
|
||||
println!(" [{}] {} -> {}", idx, label, item.uri);
|
||||
}
|
||||
if items.is_empty() {
|
||||
println!(" <queue is empty>");
|
||||
}
|
||||
}
|
||||
|
||||
fn current_track_title(item: Option<&PlaybackItem>) -> String {
|
||||
match item {
|
||||
Some(track) => track
|
||||
.metadata
|
||||
.as_ref()
|
||||
.and_then(|meta| meta.title.as_deref())
|
||||
.unwrap_or_else(|| track.uri.as_str())
|
||||
.to_string(),
|
||||
None => "<unknown>".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_playback_position(info: &PlaybackPositionInfo) -> String {
|
||||
let rel = info.rel_time.as_deref().unwrap_or("-");
|
||||
let dur = info.track_duration.as_deref().unwrap_or("-");
|
||||
format!("{rel} / {dur}")
|
||||
}
|
||||
|
||||
fn no_renderer_and_exit(message: &str) -> ! {
|
||||
println!("{message}");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
fn no_server_and_exit(message: &str) -> ! {
|
||||
println!("{message}");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
fn print_usage_and_exit() -> ! {
|
||||
println!(
|
||||
"Usage: cargo run -p pmocontrol --example queue_pmomusic_demo -- [--timeout-secs N] [--discovery-secs N] [--max-tracks N]"
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ use pmocontrol::PlaybackPosition;
|
||||
use pmocontrol::model::RendererInfo;
|
||||
use pmocontrol::openhome_client::{OhInfoClient, OhPlaylistClient, OhTimeClient};
|
||||
use pmocontrol::{
|
||||
ControlPoint, MusicRenderer, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||
ControlPoint, MusicRendererBackend, PlaybackState, PlaybackStatus, RendererCapabilities,
|
||||
RendererProtocol, TransportControl, VolumeControl,
|
||||
};
|
||||
use std::env;
|
||||
@@ -37,7 +37,7 @@ fn main() -> Result<()> {
|
||||
thread::sleep(Duration::from_secs(5));
|
||||
|
||||
// 2. Snapshot of logical music renderers
|
||||
let mut renderers: Vec<MusicRenderer> = cp.list_music_renderers();
|
||||
let mut renderers: Vec<MusicRendererBackend> = cp.list_music_renderers();
|
||||
|
||||
// Filter out the in-dev PMOMusic renderer (if present)
|
||||
renderers.retain(|r| {
|
||||
@@ -276,20 +276,20 @@ fn print_openhome_details(prefix: &str, info: &RendererInfo) {
|
||||
}
|
||||
}
|
||||
|
||||
fn print_backend(prefix: &str, renderer: &MusicRenderer) {
|
||||
fn print_backend(prefix: &str, renderer: &MusicRendererBackend) {
|
||||
let backend = match renderer {
|
||||
MusicRenderer::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||
MusicRenderer::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||
MusicRenderer::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
||||
MusicRenderer::HybridUpnpArylic { .. } => {
|
||||
MusicRendererBackend::Upnp(_) => "UpnpRenderer (UPnP AV / DLNA)",
|
||||
MusicRendererBackend::LinkPlay(_) => "LinkPlayRenderer (LinkPlay HTTP)",
|
||||
MusicRendererBackend::ArylicTcp(_) => "ArylicTcpRenderer (ARylic TCP Protocol)",
|
||||
MusicRendererBackend::HybridUpnpArylic { .. } => {
|
||||
"Hybrid UpnpArylicRenderer (UPnP AV / DLNA + ARylic TCP Protocol)"
|
||||
}
|
||||
MusicRenderer::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||
MusicRendererBackend::OpenHome(_) => "OpenHomeRenderer (native OpenHome stack)",
|
||||
};
|
||||
println!("{prefix}Backend : {backend}");
|
||||
}
|
||||
|
||||
fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
||||
fn dump_renderer_state(renderer: &MusicRendererBackend, label: &str) -> Result<()> {
|
||||
println!("\n[{label}]");
|
||||
|
||||
if let Ok(state) = renderer.playback_state() {
|
||||
@@ -344,7 +344,7 @@ fn dump_renderer_state(renderer: &MusicRenderer, label: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, interval_secs: u64) {
|
||||
fn progress_monitor(renderer: &MusicRendererBackend, label: &str, iterations: usize, interval_secs: u64) {
|
||||
println!(
|
||||
"\n[{label}] polling playback state/position {} times (every {} s)...",
|
||||
iterations, interval_secs
|
||||
@@ -370,7 +370,7 @@ fn progress_monitor(renderer: &MusicRenderer, label: &str, iterations: usize, in
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_demo(renderer: &MusicRenderer) -> Result<()> {
|
||||
fn volume_demo(renderer: &MusicRendererBackend) -> Result<()> {
|
||||
// Try to get current volume
|
||||
let original = renderer.volume()?;
|
||||
println!(" Current music volume : {}", original);
|
||||
|
||||
296
pmocontrol/src/arylic_client/mod.rs
Normal file
296
pmocontrol/src/arylic_client/mod.rs
Normal file
@@ -0,0 +1,296 @@
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::{Shutdown, TcpStream, ToSocketAddrs},
|
||||
sync::{Mutex, OnceLock},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::errors::ControlPointError;
|
||||
|
||||
pub const ARYLIC_TCP_PORT: u16 = 8899;
|
||||
pub const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
||||
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
||||
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
||||
|
||||
// Garde global pour respecter le délai de 200ms entre commandes
|
||||
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
||||
|
||||
fn last_command_time() -> &'static Mutex<Instant> {
|
||||
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||
}
|
||||
|
||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||
enum ResponseMode<'a> {
|
||||
/// On n’attend aucune réponse (fire-and-forget).
|
||||
None,
|
||||
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
||||
Optional(&'a [&'a str]),
|
||||
/// On attend une réponse, et l’absence de réponse est une erreur.
|
||||
Required(&'a [&'a str]),
|
||||
}
|
||||
|
||||
fn send_command_with_mode(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
mode: ResponseMode<'_>,
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
let mut stream = connect(host, port, timeout)?;
|
||||
let packet = encode_packet(payload);
|
||||
|
||||
stream.write_all(&packet).map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to write Arylic TCP packet for {}: {}",
|
||||
host, payload
|
||||
))
|
||||
})?;
|
||||
|
||||
stream.flush().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||
host, payload
|
||||
))
|
||||
})?;
|
||||
|
||||
match mode {
|
||||
ResponseMode::None => {
|
||||
debug!(
|
||||
"Arylic TCP fire-and-forget command sent to {}: {}",
|
||||
host, payload
|
||||
);
|
||||
let _ = stream.shutdown(Shutdown::Write);
|
||||
Ok(None)
|
||||
}
|
||||
ResponseMode::Required(expected) => {
|
||||
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
||||
}
|
||||
ResponseMode::Optional(expected) => {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
match read_packet(&mut stream) {
|
||||
Ok(response) => {
|
||||
if expected.iter().any(|p| response.starts_with(p)) {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
||||
payload, host, err
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No expected response for optional command {} on {}",
|
||||
payload, host
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_expected_response(
|
||||
stream: &mut TcpStream,
|
||||
host: &str,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String, ControlPointError> {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
let response = match read_packet(stream) {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||
host, payload, err
|
||||
)));
|
||||
}
|
||||
};
|
||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
|
||||
Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No expected response for command {} on {}",
|
||||
payload, host
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn send_command_required(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String, ControlPointError> {
|
||||
match send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Required(expected),
|
||||
)? {
|
||||
Some(s) => Ok(s),
|
||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Arylic TCP: no response payload for required command {}",
|
||||
payload
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_command_optional(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<Option<String>, ControlPointError> {
|
||||
send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Optional(expected),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn send_command_no_response(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
) -> Result<(), ControlPointError> {
|
||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||
}
|
||||
|
||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream, ControlPointError> {
|
||||
if let Ok(mut last_time) = last_command_time().lock() {
|
||||
let elapsed = last_time.elapsed();
|
||||
if elapsed < Duration::from_millis(200) {
|
||||
let wait = Duration::from_millis(200) - elapsed;
|
||||
debug!(
|
||||
"Waiting {:?} before sending command to respect 200ms interval",
|
||||
wait
|
||||
);
|
||||
thread::sleep(wait);
|
||||
}
|
||||
*last_time = Instant::now();
|
||||
}
|
||||
|
||||
let address = if host.contains(':') {
|
||||
format!("[{}]:{}", host, port)
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in address.to_socket_addrs().map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!("Failed to resolve {}:{}", host, port))
|
||||
})? {
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => {
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||
.map_err(|_| {
|
||||
ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to set socket timeouts for {}",
|
||||
address
|
||||
))
|
||||
})?;
|
||||
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some((addr, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some((addr, err)) => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Failed to connect to {} via {}: {}",
|
||||
host, addr, err
|
||||
))),
|
||||
None => Err(ControlPointError::ArilycTcpError(format!(
|
||||
"No socket addresses resolved for {}",
|
||||
address
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_packet(payload: &str) -> Vec<u8> {
|
||||
let bytes = payload.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
|
||||
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
||||
out.extend_from_slice(&PACKET_HEADER);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&checksum.to_le_bytes());
|
||||
out.extend_from_slice(&RESERVED_BYTES);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn read_packet(stream: &mut TcpStream) -> Result<String, ControlPointError> {
|
||||
let mut header = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut header)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
if header != PACKET_HEADER {
|
||||
return Err(ControlPointError::ArilycTcpError(format!(
|
||||
"Invalid Arylic packet header: {:x?}",
|
||||
header
|
||||
)));
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut len_buf)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut checksum_buf = [0u8; 4];
|
||||
stream
|
||||
.read_exact(&mut checksum_buf)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||
|
||||
let mut reserved = [0u8; 8];
|
||||
stream
|
||||
.read_exact(&mut reserved)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream
|
||||
.read_exact(&mut payload)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?;
|
||||
|
||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
if actual_checksum != expected_checksum {
|
||||
warn!(
|
||||
"Arylic payload checksum mismatch: expected={} actual={}",
|
||||
expected_checksum, actual_checksum
|
||||
);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(payload)
|
||||
.map_err(|e| ControlPointError::ArilycTcpError(format!("{}", e)))?)
|
||||
}
|
||||
@@ -1,659 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Shutdown, TcpStream, ToSocketAddrs};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::capabilities::{
|
||||
PlaybackPosition, PlaybackPositionInfo, PlaybackState, PlaybackStatus, TransportControl,
|
||||
VolumeControl,
|
||||
};
|
||||
use crate::linkplay::{extract_linkplay_host, parse_flat_json};
|
||||
use crate::model::{RendererId, RendererInfo};
|
||||
use std::time::Instant;
|
||||
|
||||
// Garde global pour respecter le délai de 200ms entre commandes
|
||||
static LAST_COMMAND_TIME: OnceLock<Mutex<Instant>> = OnceLock::new();
|
||||
|
||||
fn last_command_time() -> &'static Mutex<Instant> {
|
||||
LAST_COMMAND_TIME.get_or_init(|| Mutex::new(Instant::now()))
|
||||
}
|
||||
|
||||
const ARYLIC_TCP_PORT: u16 = 8899;
|
||||
const PACKET_HEADER: [u8; 4] = [0x18, 0x96, 0x18, 0x20];
|
||||
const RESERVED_BYTES: [u8; 8] = [0; 8];
|
||||
const MAX_RESPONSE_ATTEMPTS: usize = 8;
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 3;
|
||||
|
||||
static DETECTION_CACHE: OnceLock<Mutex<HashMap<String, bool>>> = OnceLock::new();
|
||||
|
||||
/// Mode d’attente de réponse pour une commande TCP Arylic.
|
||||
enum ResponseMode<'a> {
|
||||
/// On n’attend aucune réponse (fire-and-forget).
|
||||
None,
|
||||
/// On attend une réponse, mais si la lecture échoue immédiatement, on traite comme succès.
|
||||
Optional(&'a [&'a str]),
|
||||
/// On attend une réponse, et l’absence de réponse est une erreur.
|
||||
Required(&'a [&'a str]),
|
||||
}
|
||||
|
||||
fn detection_cache() -> &'static Mutex<HashMap<String, bool>> {
|
||||
DETECTION_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
/// Probe whether the renderer at the given location exposes the Arylic TCP API.
|
||||
pub(crate) fn detect_arylic_tcp(location: &str, timeout: Duration) -> bool {
|
||||
let Some(host) = extract_linkplay_host(location) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if let Ok(cache) = detection_cache().lock() {
|
||||
if let Some(result) = cache.get(&host) {
|
||||
return *result;
|
||||
}
|
||||
}
|
||||
|
||||
let detected = match try_detect_tcp(&host, timeout) {
|
||||
Ok(_) => true,
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Arylic TCP detection failed for {} (host={}): {}",
|
||||
location, host, err
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if let Ok(mut cache) = detection_cache().lock() {
|
||||
cache.insert(host, detected);
|
||||
}
|
||||
|
||||
detected
|
||||
}
|
||||
|
||||
fn try_detect_tcp(host: &str, timeout: Duration) -> Result<()> {
|
||||
let payload = send_command_required(
|
||||
host,
|
||||
ARYLIC_TCP_PORT,
|
||||
timeout,
|
||||
"MCU+INF+GET",
|
||||
&["AXX+INF+", "AXX+DEV+"],
|
||||
)?;
|
||||
|
||||
if payload.starts_with("AXX+INF+") || payload.starts_with("AXX+DEV+") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"Unexpected INF response from {}: {}",
|
||||
host,
|
||||
payload
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Backend speaking the Arylic TCP control protocol (port 8899).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArylicTcpRenderer {
|
||||
pub info: RendererInfo,
|
||||
host: String,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl ArylicTcpRenderer {
|
||||
pub fn from_renderer_info(info: RendererInfo) -> Result<Self> {
|
||||
let host = extract_linkplay_host(&info.location)
|
||||
.ok_or_else(|| anyhow!("Renderer {} has no valid LOCATION host", info.udn))?;
|
||||
|
||||
Ok(Self {
|
||||
info,
|
||||
host,
|
||||
port: ARYLIC_TCP_PORT,
|
||||
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn id(&self) -> &RendererId {
|
||||
&self.info.id
|
||||
}
|
||||
|
||||
pub fn friendly_name(&self) -> &str {
|
||||
&self.info.friendly_name
|
||||
}
|
||||
|
||||
fn send_required(&self, cmd: &str, expected: &[&str]) -> Result<String> {
|
||||
send_command_required(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_optional(&self, cmd: &str, expected: &[&str]) -> Result<Option<String>> {
|
||||
send_command_optional(&self.host, self.port, self.timeout, cmd, expected)
|
||||
}
|
||||
|
||||
fn send_no_response(&self, cmd: &str) -> Result<()> {
|
||||
send_command_no_response(&self.host, self.port, self.timeout, cmd)
|
||||
}
|
||||
|
||||
fn fetch_playback_info(&self) -> Result<ArylicPlaybackInfo> {
|
||||
let payload = self.send_required("MCU+PINFGET", &["AXX+PLY+INF"])?;
|
||||
match parse_playback_info(&payload) {
|
||||
Ok(info) => Ok(info),
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"Failed to parse Arylic playback info for {}: {}",
|
||||
self.host, err
|
||||
);
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_volume_command(value: u16) -> String {
|
||||
format!("MCU+VOL+{:03}", value.min(100))
|
||||
}
|
||||
|
||||
fn parse_volume_payload(payload: &str) -> Result<u16> {
|
||||
let data = payload
|
||||
.strip_prefix("AXX+VOL+")
|
||||
.ok_or_else(|| anyhow!("Unexpected volume response: {}", payload))?;
|
||||
let value: u16 = data
|
||||
.trim()
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid volume value: {}", data))?;
|
||||
Ok(value.min(100))
|
||||
}
|
||||
|
||||
fn parse_mute_payload(payload: &str) -> Result<bool> {
|
||||
let data = payload
|
||||
.strip_prefix("AXX+MUT+")
|
||||
.ok_or_else(|| anyhow!("Unexpected mute response: {}", payload))?;
|
||||
match data.trim() {
|
||||
"000" | "0" => Ok(false),
|
||||
"001" | "1" => Ok(true),
|
||||
other => Err(anyhow!("Invalid mute value: {}", other)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TransportControl for ArylicTcpRenderer {
|
||||
fn play_uri(&self, _uri: &str, _meta: &str) -> Result<()> {
|
||||
Err(anyhow!(
|
||||
"Arylic TCP backend does not support direct URL loading. Use UPnP AVTransport SetAVTransportURI instead."
|
||||
))
|
||||
}
|
||||
|
||||
fn play(&self) -> Result<()> {
|
||||
self.send_no_response("MCU+PLY-PLA")
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
let _ = self.send_optional("MCU+PLY-PUS", &["AXX+PLY+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stop(&self) -> Result<()> {
|
||||
self.send_no_response("MCU+PLY-STP")
|
||||
}
|
||||
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()> {
|
||||
let _ = parse_hhmmss(hhmmss)?;
|
||||
Err(anyhow!(
|
||||
"Arylic TCP seek_rel_time is not implemented yet for this device."
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl VolumeControl for ArylicTcpRenderer {
|
||||
fn volume(&self) -> Result<u16> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(vol) = info.volume {
|
||||
return Ok(vol);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing volume, falling back to VOL GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+VOL+GET", &["AXX+VOL+"])?;
|
||||
Self::parse_volume_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_volume(&self, v: u16) -> Result<()> {
|
||||
let command = Self::format_volume_command(v);
|
||||
let _ = self.send_optional(&command, &["AXX+VOL+"])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mute(&self) -> Result<bool> {
|
||||
if let Ok(info) = self.fetch_playback_info() {
|
||||
if let Some(mute) = info.mute {
|
||||
return Ok(mute);
|
||||
}
|
||||
debug!(
|
||||
"Arylic playback info for {} missing mute, falling back to MUT GET",
|
||||
self.host
|
||||
);
|
||||
}
|
||||
let payload = self.send_required("MCU+MUT+GET", &["AXX+MUT+"])?;
|
||||
Self::parse_mute_payload(&payload)
|
||||
}
|
||||
|
||||
fn set_mute(&self, m: bool) -> Result<()> {
|
||||
let command = if m { "MCU+MUT+001" } else { "MCU+MUT+000" };
|
||||
let payload = self.send_required(command, &["AXX+MUT+"])?;
|
||||
let _ = Self::parse_mute_payload(&payload)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackStatus for ArylicTcpRenderer {
|
||||
fn playback_state(&self) -> Result<PlaybackState> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.playback_state())
|
||||
}
|
||||
}
|
||||
|
||||
impl PlaybackPosition for ArylicTcpRenderer {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo> {
|
||||
let info = self.fetch_playback_info()?;
|
||||
Ok(info.position_info())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ArylicPlaybackInfo {
|
||||
status_raw: String,
|
||||
curpos_ms: u64,
|
||||
totlen_ms: u64,
|
||||
volume: Option<u16>,
|
||||
mute: Option<bool>,
|
||||
playlist_size: Option<u32>,
|
||||
track_index: Option<u32>,
|
||||
}
|
||||
|
||||
impl ArylicPlaybackInfo {
|
||||
fn playback_state(&self) -> PlaybackState {
|
||||
match self.status_raw.as_str() {
|
||||
"play" => PlaybackState::Playing,
|
||||
"pause" => PlaybackState::Paused,
|
||||
"stop" => PlaybackState::Stopped,
|
||||
other => PlaybackState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn position_info(&self) -> PlaybackPositionInfo {
|
||||
let track = match (self.track_index, self.playlist_size) {
|
||||
(Some(idx), Some(count)) if count > 0 => Some(idx.min(count)),
|
||||
(Some(idx), _) => Some(idx),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
PlaybackPositionInfo {
|
||||
track,
|
||||
rel_time: Some(format_hms(self.curpos_ms / 1000)),
|
||||
abs_time: None,
|
||||
track_duration: if self.totlen_ms > 0 {
|
||||
Some(format_hms(self.totlen_ms / 1000))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
track_metadata: None,
|
||||
track_uri: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_playback_info(payload: &str) -> Result<ArylicPlaybackInfo> {
|
||||
let json_blob = payload
|
||||
.strip_prefix("AXX+PLY+INF")
|
||||
.ok_or_else(|| anyhow!("Unexpected playback info prefix: {}", payload))?;
|
||||
|
||||
let json_blob = json_blob.trim_end_matches('&').trim();
|
||||
let map = parse_flat_json(json_blob)?;
|
||||
|
||||
let status_raw = map
|
||||
.get("status")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow!("Playback info missing `status` field"))?;
|
||||
|
||||
let curpos_ms = parse_u64_field(&map, "curpos")?;
|
||||
let totlen_ms = parse_u64_field(&map, "totlen")?;
|
||||
|
||||
let volume = match map.get("vol") {
|
||||
Some(raw) => match raw.parse::<u16>() {
|
||||
Ok(value) => Some(value.min(100)),
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `vol` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
|
||||
let mute = match map.get("mute") {
|
||||
Some(value) if value == "1" => Some(true),
|
||||
Some(value) if value == "0" => Some(false),
|
||||
Some(other) => {
|
||||
debug!("Invalid Arylic `mute` value {}", other);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
|
||||
let playlist_size = map
|
||||
.get("plicount")
|
||||
.and_then(|raw| match raw.parse::<u32>() {
|
||||
Ok(count) if count > 0 => Some(count),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicount` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
let track_index = map.get("plicurr").and_then(|raw| match raw.parse::<u32>() {
|
||||
Ok(idx) if idx > 0 => Some(idx),
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
debug!("Invalid Arylic `plicurr` value {}: {}", raw, err);
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(ArylicPlaybackInfo {
|
||||
status_raw,
|
||||
curpos_ms,
|
||||
totlen_ms,
|
||||
volume,
|
||||
mute,
|
||||
playlist_size,
|
||||
track_index,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_u64_field(map: &HashMap<String, String>, key: &str) -> Result<u64> {
|
||||
let raw = map
|
||||
.get(key)
|
||||
.ok_or_else(|| anyhow!("Playback info missing `{}` field", key))?;
|
||||
raw.parse::<u64>()
|
||||
.with_context(|| format!("Invalid `{}` value: {}", key, raw))
|
||||
}
|
||||
|
||||
fn connect(host: &str, port: u16, timeout: Duration) -> Result<TcpStream> {
|
||||
if let Ok(mut last_time) = last_command_time().lock() {
|
||||
let elapsed = last_time.elapsed();
|
||||
if elapsed < Duration::from_millis(200) {
|
||||
let wait = Duration::from_millis(200) - elapsed;
|
||||
debug!(
|
||||
"Waiting {:?} before sending command to respect 200ms interval",
|
||||
wait
|
||||
);
|
||||
thread::sleep(wait);
|
||||
}
|
||||
*last_time = Instant::now();
|
||||
}
|
||||
|
||||
let address = if host.contains(':') {
|
||||
format!("[{}]:{}", host, port)
|
||||
} else {
|
||||
format!("{host}:{port}")
|
||||
};
|
||||
|
||||
let mut last_err = None;
|
||||
for addr in address
|
||||
.to_socket_addrs()
|
||||
.with_context(|| format!("Failed to resolve {}:{}", host, port))?
|
||||
{
|
||||
match TcpStream::connect_timeout(&addr, timeout) {
|
||||
Ok(stream) => {
|
||||
stream
|
||||
.set_read_timeout(Some(timeout))
|
||||
.and_then(|_| stream.set_write_timeout(Some(timeout)))
|
||||
.with_context(|| format!("Failed to set socket timeouts for {}", address))?;
|
||||
return Ok(stream);
|
||||
}
|
||||
Err(err) => {
|
||||
last_err = Some((addr, err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match last_err {
|
||||
Some((addr, err)) => Err(anyhow!(
|
||||
"Failed to connect to {} via {}: {}",
|
||||
host,
|
||||
addr,
|
||||
err
|
||||
)),
|
||||
None => Err(anyhow!("No socket addresses resolved for {}", address)),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_packet(payload: &str) -> Vec<u8> {
|
||||
let bytes = payload.as_bytes();
|
||||
let len = bytes.len() as u32;
|
||||
let checksum = bytes.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
|
||||
let mut out = Vec::with_capacity(4 + 4 + 4 + 8 + bytes.len());
|
||||
out.extend_from_slice(&PACKET_HEADER);
|
||||
out.extend_from_slice(&len.to_le_bytes());
|
||||
out.extend_from_slice(&checksum.to_le_bytes());
|
||||
out.extend_from_slice(&RESERVED_BYTES);
|
||||
out.extend_from_slice(bytes);
|
||||
out
|
||||
}
|
||||
|
||||
fn read_packet(stream: &mut TcpStream) -> Result<String> {
|
||||
let mut header = [0u8; 4];
|
||||
stream.read_exact(&mut header)?;
|
||||
if header != PACKET_HEADER {
|
||||
return Err(anyhow!("Invalid Arylic packet header: {:x?}", header));
|
||||
}
|
||||
|
||||
let mut len_buf = [0u8; 4];
|
||||
stream.read_exact(&mut len_buf)?;
|
||||
let len = u32::from_le_bytes(len_buf) as usize;
|
||||
|
||||
let mut checksum_buf = [0u8; 4];
|
||||
stream.read_exact(&mut checksum_buf)?;
|
||||
let expected_checksum = u32::from_le_bytes(checksum_buf);
|
||||
|
||||
let mut reserved = [0u8; 8];
|
||||
stream.read_exact(&mut reserved)?;
|
||||
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload)?;
|
||||
|
||||
let actual_checksum = payload.iter().fold(0u32, |acc, b| acc + (*b as u32));
|
||||
if actual_checksum != expected_checksum {
|
||||
warn!(
|
||||
"Arylic payload checksum mismatch: expected={} actual={}",
|
||||
expected_checksum, actual_checksum
|
||||
);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8(payload)?)
|
||||
}
|
||||
|
||||
fn format_hms(secs: u64) -> String {
|
||||
let h = secs / 3600;
|
||||
let m = (secs % 3600) / 60;
|
||||
let s = secs % 60;
|
||||
format!("{:02}:{:02}:{:02}", h, m, s)
|
||||
}
|
||||
|
||||
fn parse_hhmmss(value: &str) -> Result<u64> {
|
||||
let parts: Vec<_> = value.split(':').collect();
|
||||
if parts.len() != 3 {
|
||||
return Err(anyhow!(
|
||||
"Invalid time format `{}`. Expected HH:MM:SS.",
|
||||
value
|
||||
));
|
||||
}
|
||||
|
||||
let hours: u64 = parts[0]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid hour component in {}", value))?;
|
||||
let minutes: u64 = parts[1]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid minute component in {}", value))?;
|
||||
let seconds: u64 = parts[2]
|
||||
.parse()
|
||||
.with_context(|| format!("Invalid second component in {}", value))?;
|
||||
|
||||
if minutes > 59 || seconds > 59 {
|
||||
return Err(anyhow!(
|
||||
"Invalid HH:MM:SS value `{}`. Minutes and seconds must be < 60.",
|
||||
value
|
||||
));
|
||||
}
|
||||
|
||||
Ok(hours * 3600 + minutes * 60 + seconds)
|
||||
}
|
||||
|
||||
fn send_command_with_mode(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
mode: ResponseMode<'_>,
|
||||
) -> Result<Option<String>> {
|
||||
let mut stream = connect(host, port, timeout)?;
|
||||
let packet = encode_packet(payload);
|
||||
|
||||
stream.write_all(&packet).with_context(|| {
|
||||
format!(
|
||||
"Failed to write Arylic TCP packet for {}: {}",
|
||||
host, payload
|
||||
)
|
||||
})?;
|
||||
stream.flush().with_context(|| {
|
||||
format!(
|
||||
"Failed to flush Arylic TCP stream for {} (command {})",
|
||||
host, payload
|
||||
)
|
||||
})?;
|
||||
|
||||
match mode {
|
||||
ResponseMode::None => {
|
||||
debug!(
|
||||
"Arylic TCP fire-and-forget command sent to {}: {}",
|
||||
host, payload
|
||||
);
|
||||
let _ = stream.shutdown(Shutdown::Write);
|
||||
Ok(None)
|
||||
}
|
||||
ResponseMode::Required(expected) => {
|
||||
read_expected_response(&mut stream, host, payload, expected).map(Some)
|
||||
}
|
||||
ResponseMode::Optional(expected) => {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
match read_packet(&mut stream) {
|
||||
Ok(response) => {
|
||||
if expected.iter().any(|p| response.starts_with(p)) {
|
||||
return Ok(Some(response));
|
||||
}
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
"No full response for Arylic TCP command {} on {}: {}. Treating as success and relying on PINFGET.",
|
||||
payload, host, err
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No expected response for optional command {} on {}",
|
||||
payload,
|
||||
host
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_expected_response(
|
||||
stream: &mut TcpStream,
|
||||
host: &str,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String> {
|
||||
for _ in 0..MAX_RESPONSE_ATTEMPTS {
|
||||
let response = match read_packet(stream) {
|
||||
Ok(resp) => resp,
|
||||
Err(err) => {
|
||||
return Err(anyhow!(
|
||||
"Failed to read Arylic TCP response for {} (command {}): {}",
|
||||
host,
|
||||
payload,
|
||||
err
|
||||
));
|
||||
}
|
||||
};
|
||||
if expected.iter().any(|prefix| response.starts_with(prefix)) {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
debug!(
|
||||
"Ignoring unsolicited Arylic payload from {}: {}",
|
||||
host, response
|
||||
);
|
||||
}
|
||||
|
||||
Err(anyhow!(
|
||||
"No expected response for command {} on {}",
|
||||
payload,
|
||||
host
|
||||
))
|
||||
}
|
||||
|
||||
fn send_command_required(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<String> {
|
||||
match send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Required(expected),
|
||||
)? {
|
||||
Some(s) => Ok(s),
|
||||
None => Err(anyhow!(
|
||||
"Arylic TCP: no response payload for required command {}",
|
||||
payload
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_command_optional(
|
||||
host: &str,
|
||||
port: u16,
|
||||
timeout: Duration,
|
||||
payload: &str,
|
||||
expected: &[&str],
|
||||
) -> Result<Option<String>> {
|
||||
send_command_with_mode(
|
||||
host,
|
||||
port,
|
||||
timeout,
|
||||
payload,
|
||||
ResponseMode::Optional(expected),
|
||||
)
|
||||
}
|
||||
|
||||
fn send_command_no_response(host: &str, port: u16, timeout: Duration, payload: &str) -> Result<()> {
|
||||
send_command_with_mode(host, port, timeout, payload, ResponseMode::None).map(|_| ())
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// pmocontrol/src/capabilities.rs
|
||||
use anyhow::Result;
|
||||
|
||||
/// Logical playback position across backends.
|
||||
///
|
||||
/// Times peuvent être soit en secondes, soit en "HH:MM:SS" selon ce que
|
||||
/// tu préfères pour la façade; ici je reste en String pour garder la
|
||||
/// même granularité que UPnP sans parser.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlaybackPositionInfo {
|
||||
pub track: Option<u32>,
|
||||
pub rel_time: Option<String>, // position courante
|
||||
pub abs_time: Option<String>, // si pertinent
|
||||
pub track_duration: Option<String>, // durée totale
|
||||
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
|
||||
pub track_uri: Option<String>, // Current track URI
|
||||
}
|
||||
pub trait PlaybackPosition {
|
||||
fn playback_position(&self) -> Result<PlaybackPositionInfo>;
|
||||
}
|
||||
|
||||
/// High-level playback state across backends.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlaybackState {
|
||||
Stopped,
|
||||
Playing,
|
||||
Paused,
|
||||
Transitioning,
|
||||
NoMedia,
|
||||
/// Backend-specific or unknown state string.
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl PlaybackState {
|
||||
/// Map a raw UPnP AVTransport CurrentTransportState string
|
||||
/// to a logical PlaybackState.
|
||||
pub fn from_upnp_state(raw: &str) -> Self {
|
||||
let s = raw.trim().to_ascii_uppercase();
|
||||
match s.as_str() {
|
||||
"STOPPED" => PlaybackState::Stopped,
|
||||
"PLAYING" => PlaybackState::Playing,
|
||||
"PAUSED_PLAYBACK" => PlaybackState::Paused,
|
||||
// States from the AVTransport spec that we normalize:
|
||||
"PAUSED_RECORDING" => PlaybackState::Paused,
|
||||
"RECORDING" => PlaybackState::Playing,
|
||||
// Common vendor-specific states:
|
||||
"TRANSITIONING" => PlaybackState::Transitioning,
|
||||
"BUFFERING" | "PREPARING" => PlaybackState::Transitioning,
|
||||
"NO_MEDIA_PRESENT" => PlaybackState::NoMedia,
|
||||
_ => PlaybackState::Unknown(raw.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a human-readable label for the playback state.
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
PlaybackState::Stopped => "STOPPED",
|
||||
PlaybackState::Playing => "PLAYING",
|
||||
PlaybackState::Paused => "PAUSED",
|
||||
PlaybackState::Transitioning => "TRANSITIONING",
|
||||
PlaybackState::NoMedia => "NO_MEDIA",
|
||||
PlaybackState::Unknown(s) => s.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generic abstraction for playback status (transport state).
|
||||
///
|
||||
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
|
||||
/// For OpenHome, a future implementation will adapt from OH Info/Time.
|
||||
pub trait PlaybackStatus {
|
||||
fn playback_state(&self) -> Result<PlaybackState>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
|
||||
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
|
||||
pub trait TransportControl {
|
||||
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
|
||||
///
|
||||
/// Selon l'implémentation, cette méthode peut soit :
|
||||
/// - faire un "Set...URI" + "Play" (cas UPnP AV),
|
||||
/// - ou configurer la file de lecture (cas OpenHome, etc.).
|
||||
fn play_uri(&self, uri: &str, meta: &str) -> Result<()>;
|
||||
|
||||
/// Démarre ou reprend la lecture.
|
||||
fn play(&self) -> Result<()>;
|
||||
|
||||
/// Met la lecture en pause.
|
||||
fn pause(&self) -> Result<()>;
|
||||
|
||||
/// Arrête la lecture.
|
||||
fn stop(&self) -> Result<()>;
|
||||
|
||||
/// Seek à un temps relatif (HH:MM:SS) si supporté.
|
||||
fn seek_rel_time(&self, hhmmss: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Abstraction générique des capacités de contrôle de volume / mute.
|
||||
pub trait VolumeControl {
|
||||
/// Retourne le volume logique courant (échelle dépendante du renderer).
|
||||
fn volume(&self) -> Result<u16>;
|
||||
|
||||
/// Définit le volume logique (échelle dépendante du renderer).
|
||||
fn set_volume(&self, v: u16) -> Result<()>;
|
||||
|
||||
/// Indique si le renderer est muet (mute activé).
|
||||
fn mute(&self) -> Result<bool>;
|
||||
|
||||
/// Active ou désactive le mute.
|
||||
fn set_mute(&self, m: bool) -> Result<()>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user