16 Commits

Author SHA1 Message Date
7c10b22204 Merge pull request 'push-zusoquuoxstx' (#99) from push-zusoquuoxstx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m35s
Reviewed-on: https://gargoton.petite-maison-orange.fr/eric/pmomusic/pulls/99
2026-06-07 15:14:04 +02:00
46e269a290 fix: resume playback on non-empty queue regardless of transport state
Removes the strict `transport_state` condition and now triggers `api.resume(id)` solely based on a non-empty queue. This improves resilience against transient or unknown device states, such as post-sleep-timer or network errors, where transport state strings may mismatch expected values but playback should still proceed.
2026-06-07 15:13:03 +02:00
7bce0ffe19 Debounce transient snapshot fetch errors
Introduce a `consecutiveSnapshotErrors` counter with a threshold of 3 to debounce transient fetch failures. The last known snapshot is preserved and UI notifications are suppressed until the threshold is met, filtering out intermittent device glitches. The counter resets on success.
2026-06-07 14:57:51 +02:00
ba40d07f73 fix: prevent direct playback of single-item playlists
Update `isNavigable` to explicitly validate `item.is_container` and disable navigation for playlists with only one item. This forces users to enter the playlist container, preserving metadata update benefits.
2026-06-07 14:52:51 +02:00
8bc0670de4 fix: correct queue drawer positioning and safe-area layout
Replace malformed max-height values with precise calc() expressions and update open/closed transform states. Apply flexbox improvements to ensure reliable mobile drawer layout and proper safe-area padding above the BottomTabBar.
2026-06-07 14:41:37 +02:00
037435af20 style: optimize drawer layout for mobile portrait viewports
Update mobile portrait media queries in RendererDrawer and ServerDrawer to span full viewport width, reduce background opacity to 6%, and remove side box shadows. This flattens the visual style and improves layout for narrow screens.
2026-06-07 14:27:15 +02:00
2b3fae7778 feat: handle SSE cache invalidation and nightly rust toolchain config
Adds a Vue watch in `ServerDrawer.vue` to trigger a manual container directory refresh when SSE cache invalidation clears `browseData`. Includes guards to prevent redundant requests during active loads and properly manages the `isLoading` state. Additionally, introduces `rust-toolchain.toml` to enforce the nightly Rust compiler across all environments, ensuring consistent builds and access to recent language improvements.
2026-06-07 14:20:25 +02:00
06de4feb5e Merge pull request '🎨 Improve mobile safe-area support and refine UPnP/Chromecast rendering' (#98) from push-optznxlwwomy into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m25s
Reviewed-on: https://gargoton.petite-maison-orange.fr/eric/pmomusic/pulls/98
2026-04-13 04:14:51 +02:00
9b5a028f3f 🎨 Improve mobile safe-area support and refine UPnP/Chromecast rendering
- Add viewport-fit=cover to HTML meta for iOS safe-area support
- Extend bottom drawers and tab bar into system navigation area using env(safe_area_inset_bottom)
- Adjust queue drawer transforms to account for safe-area padding
+ Add enrich_position_from_queue() helper and integrate across UPnP, Arylic TCP,
  LinkPlay & Chromecast backends to ensure queue-authoritative metadata
+ Add transient error retry logic for UPnP control actions (2 retries, 300ms delay)
+ Separate timeouts: short poll timeout for GetTransportInfo/GetPosition (3s),
  longer actiontimeout SetAVTURI/SetNext... for slow devices
+ Remove duplicate continuous-stream detection from play_uri() methods (now handled centrally)
- Bump version to 0.3.49
2026-04-13 04:14:18 +02:00
9ad7d7454d Merge pull request 'push-yvskvtsqsmxs' (#97) from push-yvskvtsqsmxs into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m4s
Reviewed-on: https://gargoton.petite-maison-orange.fr/eric/pmomusic/pulls/97
2026-04-12 21:10:23 +02:00
39d7c56a99 Replace unwrap() with expect("mutex poisoned") across all mutex locks
- Replace `.lock().unwrap()` with explicit error messages for poisoned MutexGuard
- Improve robustness against mutex poisoning in renderer, queue and event subsystems
2026-04-12 21:09:38 +02:00
82197c8103 ♻️ refactor stream detection and queue sync logic
- Replace `is_continuous_stream_url` with new canonical check using metadata + fallback
- Extract stream duration comparison logic to `queue::stream_duration_*` helpers  
- Improve queue sync concurrency: add worker loop, pending/cancel flags
- Make `stream_duration_*` functions public(crate) for reuse
2026-04-12 21:09:38 +02:00
8ddee7d94f ♻️ refactor queue control methods into trait default impls
Remove duplicate `play_from_queue`, ` play_next `,  
`play_previous , and
``
pay _from_index implementations across all renderers (ArylicTcp, Chromecast,
LinkPlay , OpenHome UPnP) and instead provide default implementations in the
`QueueTransportControl `trait. Also introduce dispatch macros to simplify backend delegation for Transport/Volume control, and expose parse_didl_duration as public(crate).
2026-04-12 21:09:38 +02:00
4b793cec59 refactor: replace unwrap() with expect for mutex locks
Replace all `lock().unwrap()` calls on Mutex guards with explicit error messages using `.expect("... mutex poisoned")`. This improves robustness by providing clear diagnostics when a thread panics while holding the lock, preventing silent failures. Affected modules: events.rs (Renderer/MediaServer), all renderer backends, queue backend/implementation files. Also adds documentation to marker traits (HasQueue、 HasContinuousStream) and clarifies error handling in watcher loop with panic catching.
2026-04-12 21:09:38 +02:00
9e447023a8 🚀 refactor(pmocontrol): eliminate QueueBackend boilerplate with HasQueue blanket impl
- Add `Hasqueue` trait and implement it for all renderers (Upnp, OpenHome, LinkPlay, ArylicTcp, Chromecast)
- Replace manual `QueueBackend` implementations with blanket impl for types implementing Hasqueue
  (removes ~30+ duplicated methods across renderers)
- Fix BUG: `sync_queue` in UpnpRenderer now correctly propagates cancel_token instead of ignoring it
- Update version to 0.3.48 in Cargo.toml, lockfile and root file
- Add refactoring plan document (`refactoring_pmocontrol.md`) detailing remaining P1-P3 tasks
2026-04-12 19:03:39 +02:00
6285c8ccc8 Merge pull request 'push-zpoknxvwspkr' (#96) from push-zpoknxvwspkr into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m3s
Reviewed-on: https://gargoton.petite-maison-orange.fr/eric/pmomusic/pulls/96
2026-04-10 00:06:54 +02:00
35 changed files with 1265 additions and 1783 deletions

1
.gitignore vendored
View File

@@ -50,3 +50,4 @@ RF_old.json
.claude/
.claude.old
Kilo-session.md
pmomusic_logs.txt

View File

@@ -0,0 +1,323 @@
# Refactoring Plan - Crate `pmocontrol`
## Contexte
La crate `pmocontrol` implémente un control point UPnP multiprotocole pour contrôler des renderers audio (UPnP/DLNA, OpenHome, LinkPlay, Arylic TCP, Chromecast). Une refactorisation récente avait pour objectif de monter la logique vers les couches abstraites, mais des duplications et des problèmes de conception subsistent.
---
## Structure analysée
- `music_renderer/` : Implémentations concrètes + façade `MusicRenderer`
- `queue/` : Gestion abstraite et concrète des files de lecture
- `discovery/` : Découverte SSDP et gestion des appareils
- `upnp_clients/` : Clients SOAP pour services UPnP
- `control_point.rs` : Point de contrôle principal
Backends : `UpnpRenderer`, `OpenHomeRenderer`, `LinkPlayRenderer`, `ArylicTcpRenderer`, `ChromecastRenderer`, `HybridUpnpArylicRenderer`
---
## CATÉGORIE P0 : BUGS LOGIQUES (à corriger immédiatement)
### BUG-1 : `sync_queue` dans UpnpRenderer ignore le cancel_token
**Fichier :** `src/music_renderer/upnp_renderer.rs` (lignes ~354-364)
**Description :** Le paramètre `cancel_token` est reçu comme `_cancel_token` (ignoré) et remplacé par un `Arc::new(AtomicBool::new(false))` fraîchement créé. Les demandes d'annulation de synchronisation de queue sont silencieusement ignorées pour le backend UPnP.
**Correction :**
```rust
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>, // utiliser le param, pas _cancel_token
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, cancel_token, on_ready) // passer le vrai token
}
```
**Tâche :** Vérifier également les autres backends (OpenHome, LinkPlay, Arylic, Chromecast) s'ils propagent correctement le cancel_token.
---
## CATÉGORIE P1 : DUPLICATIONS MAJEURES (à traiter en priorité)
### DUP-1 : Implémentation de `QueueBackend` répétée dans les 5+ renderers
**Fichiers :**
- `src/music_renderer/upnp_renderer.rs` (~310-381)
- `src/music_renderer/arylic_tcp.rs` (~368-450)
- `src/music_renderer/linkplay_renderer.rs` (~246-330)
- `src/music_renderer/chromecast_renderer.rs` (~866+)
- `src/music_renderer/openhome_renderer.rs` (~629+)
**Description :** Chaque renderer implémente `QueueBackend` de manière identique : chaque méthode verrouille `self.queue` et délègue à la file sous-jacente. ~150+ lignes de boilerplate.
**Approche recommandée — Trait délégateur :**
```rust
// Dans queue/mod.rs ou music_renderer/mod.rs
pub trait HasQueue {
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
}
// Impl automatique pour QueueBackend si le type implémente HasQueue
impl<T: HasQueue> QueueBackend for T {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue().lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue().lock().unwrap().track_ids()
}
// ... toutes les méthodes déléguantes
}
// Dans chaque renderer : une seule ligne
impl HasQueue for UpnpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { &self.queue }
}
```
**Tâche :** Définir le trait `HasQueue`, implémenter `QueueBackend for T where T: HasQueue`, supprimer les implémentations manuelles dans chaque renderer.
---
### DUP-2 : Logique commune de `play_from_queue` dupliquée dans 4+ renderers
**Fichiers :**
- `src/music_renderer/upnp_renderer.rs` (~184-256)
- `src/music_renderer/linkplay_renderer.rs` (~189-212)
- `src/music_renderer/arylic_tcp.rs` (~311-334)
- `src/music_renderer/openhome_renderer.rs` (~partie similaire)
**Description :** Les 10-12 premières lignes de `play_from_queue` sont identiques dans tous les renderers : verrouillage de queue, gestion de l'index courant, fallback sur index 0 si non défini, récupération de l'item. Seule la partie terminale (play effectif sur le backend) diffère.
**Approche recommandée — Méthode par défaut dans un trait :**
```rust
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
// Primitive spécifique au backend
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
// Implémentation commune par défaut
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue().lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
drop(queue);
let is_stream = is_continuous_stream_url(&item.uri);
*self.continuous_stream().lock().unwrap() = is_stream;
self.play_item(&item)
}
}
```
**Tâche :** Créer `QueueTransportControl` avec une méthode par défaut, implémenter `play_item` dans chaque renderer, supprimer la logique commune dupliquée.
---
### DUP-3 : Initialisation redondante des champs partagés dans tous les renderers
**Fichiers :** Constructeurs dans tous les fichiers renderer
**Description :** Chaque renderer répète la même construction :
```rust
let queue = Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?));
// ...
continuous_stream: Arc::new(Mutex::new(false)),
```
**Approche recommandée :**
```rust
pub struct SharedRendererState {
pub queue: Arc<Mutex<MusicQueue>>,
pub continuous_stream: Arc<Mutex<bool>>,
}
impl SharedRendererState {
pub fn from_renderer_info(info: &RendererInfo) -> Result<Self, ControlPointError> {
Ok(Self {
queue: Arc::new(Mutex::new(MusicQueue::from_renderer_info(info)?)),
continuous_stream: Arc::new(Mutex::new(false)),
})
}
}
```
**Tâche :** Créer `SharedRendererState`, l'utiliser dans tous les constructeurs de renderers.
---
### DUP-4 : `parse_didl_duration` implémentée deux fois différemment
**Fichiers :**
- `src/music_renderer/upnp_renderer.rs` (~383-420) : parsing manuel par string search (fragile)
- `src/music_renderer/musicrenderer.rs` (~2089-2117) : via parser DIDL-Lite structuré (robuste)
**Description :** Deux implémentations divergentes. L'une risque de mal parser du DIDL là où l'autre réussit.
**Tâche :** Conserver uniquement la version via `DIDLLite::parse`, l'exporter depuis `music_renderer/mod.rs`, supprimer la version par string search dans `upnp_renderer.rs`.
---
## CATÉGORIE P2 : ALGORITHMES COMPLEXES ET ABSTRACTIONS MAL PLACÉES
### ALGO-1 : `schedule_sync` dans `music_queue.rs` — logique intriquée
**Fichier :** `src/queue/music_queue.rs` (~97-200)
**Description :** La méthode crée un thread worker avec :
- Des `AtomicBool` pour synchronisation (sync_in_progress, sync_pending, sync_cancel_token)
- Une boucle infinie interne qui re-tente si un nouveau job arrive
- Un Guard RAII basé sur `Drop` pour le cleanup
- Des closures capturées mêlant synchronisation et logique métier
Difficile à tester, à observer de l'extérieur, pas de timeout.
**Tâche :**
1. Extraire la logique du worker dans une fonction `sync_worker_loop` avec signature claire
2. Documenter le protocole de synchronisation avec les AtomicBool
3. Ajouter une stratégie de timeout ou de sortie en cas de blocage
---
### ALGO-2 : Enum dispatch sprawl dans `MusicRendererBackend`
**Fichier :** `src/music_renderer/musicrenderer.rs` (~2134-2495)
**Description :** L'enum a 6 variantes. Chaque trait implémenté pour l'enum (`TransportControl`, `PlaybackStatus`, `PlaybackPosition`, `RendererBackend`, `QueueBackend`, etc.) contient un `match` sur les 6 variantes. Estimation : 200+ lignes de boilerplate purement mécanique. Ajouter une 7e variante requiert des mises à jour dans 25+ endroits.
**Approche recommandée — Macro de dispatch :**
```rust
macro_rules! dispatch {
($self:expr, $method:ident($($arg:expr),*)) => {
match $self {
MusicRendererBackend::Upnp(b) => b.$method($($arg),*),
MusicRendererBackend::OpenHome(b) => b.$method($($arg),*),
MusicRendererBackend::LinkPlay(b) => b.$method($($arg),*),
MusicRendererBackend::ArylicTcp(b) => b.$method($($arg),*),
MusicRendererBackend::Chromecast(b) => b.$method($($arg),*),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
}
}
}
impl TransportControl for MusicRendererBackend {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
dispatch!(self, play_uri(uri, meta))
}
// ...
}
```
**Tâche :** Définir la macro `dispatch!`, remplacer les match statements redondants, valider les cas où HybridUpnpArylic a une logique spéciale.
---
### ALGO-3 : Logique de protection des durées de streams dupliquée dans 3 endroits
**Fichiers :**
- `src/queue/interne.rs` (~74-126) : `protect_stream_durations`
- `src/queue/openhome.rs` (~400+) : logique similaire pour playlists OpenHome
- `src/music_renderer/musicrenderer.rs` (~488-537) : dans `poll_and_emit_changes`
**Description :** La logique "refuser la diminution de durée pour un stream continu" est réimplémentée trois fois. Si la définition de "diminution acceptable" change, il faut modifier 3 fichiers.
**Tâche :** Créer `music_renderer/stream_utils.rs` (ou équivalent) avec une fonction `protect_stream_duration(old, new, is_stream) -> Option<String>` et l'utiliser dans les 3 endroits.
---
### ALGO-4 : Détection de flux continu fragmentée
**Fichiers :** `stream_detection.rs`, `musicrenderer.rs`, `queue/interne.rs`, `queue/openhome.rs`
**Description :** La détection "est-ce un stream continu?" passe par plusieurs chemins non unifiés :
1. `TrackMetadata::is_continuous_stream`
2. Appel `is_continuous_stream_url(uri)` (réseau)
3. Absence de durée dans les métadonnées
Un stream peut être marqué continu dans une couche mais pas l'autre.
**Tâche :** Créer une fonction canonique unique :
```rust
pub fn is_continuous_stream(metadata: Option<&TrackMetadata>, uri: &str) -> bool {
metadata.map(|m| m.is_continuous_stream).unwrap_or(false)
|| is_continuous_stream_url(uri)
}
```
Faire passer tous les codepaths par cette fonction.
---
## CATÉGORIE P3 : BONNES PRATIQUES (amélioration continue)
### BP-1 : `.unwrap()` sur mutex locks (>50 occurrences)
**Problème :** Si un mutex est empoisonné (panique dans une autre tâche), `.unwrap()` propage la panique. Aucun code ne gère ce cas.
**Tâche :** Remplacer `.unwrap()` par `.expect("message contextuel")` à court terme. À long terme, envisager `parking_lot::Mutex` (pas de concept de poison).
---
### BP-2 : Absence de gestion d'erreur dans les threads watcher et sync
**Fichiers :** `musicrenderer.rs` (watcher_loop), `music_queue.rs` (schedule_sync)
**Tâche :** Ajouter `error!` logs dans les threads et décider explicitement de la politique de redémarrage (continuer vs arrêter).
---
### BP-3 : Champs `pub` au lieu de `pub(crate)` dans `PlaylistBinding`
**Fichier :** `src/music_renderer/musicrenderer.rs` (struct `PlaylistBinding`)
**Tâche :** Rendre les champs `pub``pub(crate)` ou privés avec accesseurs.
---
### BP-4 : Documentation manquante sur les contrats des traits
**Fichiers :** `src/music_renderer/capabilities.rs`, `src/queue/backend.rs`
**Tâche :** Ajouter des doc-comments sur les traits clés (`TransportControl`, `PlaybackStatus`, `QueueBackend`) décrivant les invariants, les pré/post-conditions, et le comportement attendu.
---
## PLAN D'EXÉCUTION
### Phase 1 — Bugs (immédiat)
- [ ] **BUG-1** : Corriger le cancel_token ignoré dans `upnp_renderer.rs::sync_queue`
- [ ] Vérifier les autres renderers pour le même bug
### Phase 2 — Éliminer les duplications majeures (1-2 semaines)
- [ ] **DUP-1** : Trait `HasQueue` + impl automatique de `QueueBackend`
- [ ] **DUP-4** : Unifier `parse_didl_duration` sur la version DIDL-Lite
- [ ] **DUP-3** : Créer `SharedRendererState` pour l'init commune
- [ ] **DUP-2** : Trait `QueueTransportControl` avec `play_from_queue` par défaut
### Phase 3 — Simplifier les algorithmes (2-4 semaines)
- [ ] **ALGO-2** : Macro `dispatch!` pour `MusicRendererBackend`
- [ ] **ALGO-3** : Centraliser la protection des durées de stream
- [ ] **ALGO-4** : Unifier la détection de flux continu
- [ ] **ALGO-1** : Refactoriser `schedule_sync` (extraire `sync_worker_loop`)
### Phase 4 — Qualité continue
- [ ] **BP-1** : Remplacer les `.unwrap()` critiques
- [ ] **BP-2** : Gestion d'erreur dans les threads
- [ ] **BP-3** : Visibilité des champs `PlaylistBinding`
- [ ] **BP-4** : Documentation des traits

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.47"
version = "0.3.49"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.48"
version = "0.3.50"
edition = "2024"
[dependencies]

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>webapp</title>
</head>
<body>

View File

@@ -257,6 +257,9 @@
.drawer-footer {
padding: var(--spacing-md);
/* On mobile, extend into the safe-area so the footer doesn't sit under
the system navigation bar / home indicator. */
padding-bottom: max(var(--spacing-md), env(safe-area-inset-bottom, 0px));
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
}

View File

@@ -147,7 +147,10 @@ function handleRendererDrawerClick() {
align-items: center;
gap: var(--spacing-md);
height: 72px;
padding: 0 var(--spacing-md);
/* Extend background into the system navigation bar area (iOS home indicator,
Android gesture bar). Content stays in the 72px zone; only the visual
background bleeds into the safe-area below. */
padding: 0 var(--spacing-md) env(safe-area-inset-bottom, 0px);
background: rgba(22, 22, 32, 0.96);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
@@ -323,7 +326,7 @@ function handleRendererDrawerClick() {
@media (max-width: 768px) {
.bottom-bar {
height: 64px;
padding: 0 var(--spacing-sm);
padding: 0 var(--spacing-sm) env(safe-area-inset-bottom, 0px);
gap: var(--spacing-sm);
}

View File

@@ -860,14 +860,8 @@ async function handleTransferQueue(event: Event, targetRendererId: string) {
/* 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 */
width: 100vw;
box-shadow: none;
}
.drawer-header {

View File

@@ -231,7 +231,8 @@ async function handleQueueItemClick(item: QueueItem) {
/* Queue drawer visible sur mobile (géré par v-if maintenant) */
.queue-drawer {
position: fixed;
bottom: 0;
/* Ancré juste au-dessus de la BottomTabBar (64px) et de la safe-area système */
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
left: 0;
right: 0;
background: rgba(22, 22, 32, 0.96);
@@ -239,11 +240,11 @@ async function handleQueueItemClick(item: QueueItem) {
-webkit-backdrop-filter: blur(8px);
border-top: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 -4px 32px rgba(0, 0, 0, 0.4);
/* Fermé: caché sauf le toggle (56px) qui dépasse au-dessus de la BottomTabBar (64px) */
transform: translateY(calc(100% - 56px - 64px));
/* Fermé: seul le toggle (56px) visible */
transform: translateY(calc(100% - 56px));
transition: transform 0.3s ease;
z-index: 95; /* Au-dessus de la BottomTabBar (z-index: 100) */
max-height: 70vh;
z-index: 95; /* En dessous de la BottomTabBar (z-index: 100) */
max-height: calc(100vh - 64px - env(safe-area-inset-bottom, 0px));
display: flex;
flex-direction: column;
/* Fermé: ne bloque pas les clics en dehors du toggle */
@@ -251,9 +252,9 @@ async function handleQueueItemClick(item: QueueItem) {
}
.queue-drawer.open {
/* Ouvert: remonte mais s'arrête à 64px du bas pour laisser la BottomTabBar accessible */
transform: translateY(64px);
pointer-events: auto; /* Ouvert: capture les clics */
/* Ouvert: le drawer occupe toute la hauteur disponible au-dessus de la tab bar */
transform: translateY(0);
pointer-events: auto;
}
@media (prefers-color-scheme: light) {
@@ -266,6 +267,7 @@ async function handleQueueItemClick(item: QueueItem) {
.queue-drawer-toggle {
width: 100%;
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
@@ -281,7 +283,7 @@ async function handleQueueItemClick(item: QueueItem) {
}
.queue-drawer-content {
max-height: calc(70vh - 56px);
flex: 1;
overflow-y: auto;
padding: var(--spacing-md);
}
@@ -292,12 +294,12 @@ async function handleQueueItemClick(item: QueueItem) {
top: 0;
left: 0;
right: 0;
bottom: 64px; /* S'arrête au-dessus de la BottomTabBar */
bottom: calc(64px + env(safe-area-inset-bottom, 0px));
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 94; /* Entre la BottomTabBar et le drawer */
pointer-events: auto; /* Capture les clics pour fermer le drawer */
z-index: 94;
pointer-events: auto;
}
.controls-column {

View File

@@ -113,6 +113,20 @@ watch(drawerContentRef, (el) => {
if (el) setupObserver();
});
// Recharger quand le cache est invalidé par un événement SSE (containers_updated / global_updated)
watch(browseData, async (data, oldData) => {
if (!data && oldData && currentServer.value && currentContainerId.value && !isLoading.value) {
isLoading.value = true;
try {
await browseContainer(currentServer.value.id, currentContainerId.value, false);
} catch (error) {
console.error("[ServerDrawer] Erreur reload après invalidation cache:", error);
} finally {
isLoading.value = false;
}
}
});
// État du menu dropdown (pour chaque item, on stocke si son menu est ouvert)
const openMenuId = ref<string | null>(null);
@@ -275,8 +289,12 @@ function isPlayable(item: ContainerEntry): boolean {
// 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;
if (!item.is_container) return false;
// Une playlist avec un seul item : on garde le caractère jouable mais on retire
// la navigation pour éviter que l'utilisateur joue l'item directement et perde
// le bénéfice des mises à jour de métadonnées de la playlist.
if (isPlayable(item) && item.child_count === 1) return false;
return true;
}
function handleItemClick(item: ContainerEntry) {
@@ -1425,14 +1443,8 @@ function handleSettingsClick() {
/* Mobile responsive - portrait */
@media (max-width: 768px) and (orientation: portrait) {
.server-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 */
width: 100vw;
box-shadow: none;
}
.drawer-header {

View File

@@ -27,6 +27,12 @@ const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(null);
// Compteur d'erreurs consécutives par renderer — on ne notifie l'utilisateur
// qu'après SNAPSHOT_ERROR_THRESHOLD échecs d'affilée pour absorber les hoquets
// transitoires des devices (Arylic, etc.).
const consecutiveSnapshotErrors = new Map<string, number>();
const SNAPSHOT_ERROR_THRESHOLD = 3;
// Cache des renderers (summary)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
@@ -367,19 +373,25 @@ async function fetchRendererSnapshot(
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshots.set(rendererId, snapshot);
lastSnapshotAt.set(rendererId, Date.now());
consecutiveSnapshotErrors.set(rendererId, 0);
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
snapshots.delete(rendererId);
// Notifier l'utilisateur
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
const count = (consecutiveSnapshotErrors.get(rendererId) ?? 0) + 1;
consecutiveSnapshotErrors.set(rendererId, count);
console.error(`[useRenderers] Erreur snapshot ${rendererId} (${count}/${SNAPSHOT_ERROR_THRESHOLD}):`, err);
// Garder le dernier snapshot connu plutôt que de le supprimer — l'UI reste
// affichable même si le device est momentanément muet.
// Notifier seulement après N échecs consécutifs pour absorber les hoquets passagers
if (count >= SNAPSHOT_ERROR_THRESHOLD) {
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
// Remettre à zéro pour ne pas spammer si le device reste instable
consecutiveSnapshotErrors.set(rendererId, 0);
}
} finally {
// Toujours nettoyer le flag de chargement
loadingIds.delete(rendererId);
}
}
@@ -439,11 +451,11 @@ async function resumeOrPlayFromQueue(id: string) {
return play(id);
}
// Check if queue has content
if (
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
snapshot.queue.items.length > 0
) {
// If queue has items, always try to play from queue regardless of transport
// state. After a sleep timer stop, the device may report an unknown/transient
// state ("load", network error → "UNKNOWN", etc.) that doesn't match the
// expected "STOPPED"/"NO_MEDIA" strings, yet the queue is non-empty.
if (snapshot.queue.items.length > 0) {
return api.resume(id);
}

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
pipeline::{Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
};
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
use std::{

View File

@@ -58,6 +58,15 @@ impl ControlPointError {
ControlPointError::UpnpOperationNotSupported(operation.to_string(), service.to_string())
}
/// Returns true if the error is a transient transport-level failure that
/// may succeed on retry (TCP refused, connection reset, timeout).
///
/// Protocol-level errors (UPnP fault, HTTP 4xx/5xx with a valid body)
/// are **not** transient: the device understood the request and rejected it.
pub fn is_transient_soap_error(&self) -> bool {
matches!(self, ControlPointError::SoapAction(_))
}
pub fn upnp_missing_return_value(value: &str) -> Self {
ControlPointError::UpnpMissingReturnValue(value.to_string())
}

View File

@@ -19,7 +19,7 @@ impl RendererEventBus {
pub(crate) fn subscribe(&self) -> Receiver<RendererEvent> {
let (tx, rx) = unbounded::<RendererEvent>();
{
let mut subscribers = self.subscribers.lock().unwrap();
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
subscribers.push(tx);
}
rx
@@ -27,7 +27,7 @@ impl RendererEventBus {
#[allow(dead_code)]
pub(crate) fn broadcast(&self, event: RendererEvent) {
let mut subscribers = self.subscribers.lock().unwrap();
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
}
}
@@ -47,14 +47,14 @@ impl MediaServerEventBus {
pub fn subscribe(&self) -> Receiver<MediaServerEvent> {
let (tx, rx) = unbounded::<MediaServerEvent>();
{
let mut subscribers = self.subscribers.lock().unwrap();
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
subscribers.push(tx);
}
rx
}
pub(crate) fn broadcast(&self, event: MediaServerEvent) {
let mut subscribers = self.subscribers.lock().unwrap();
let mut subscribers = self.subscribers.lock().expect("event subscribers mutex poisoned");
subscribers.retain(|tx| tx.send(event.clone()).is_ok());
}
}

View File

@@ -373,6 +373,7 @@ pub struct MediaEntry {
pub is_container: bool,
pub class: String,
pub resources: Vec<MediaResource>,
pub child_count: Option<u32>,
pub artist: Option<String>,
pub album: Option<String>,
pub genre: Option<String>,
@@ -709,6 +710,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
is_container: true,
class: container.class,
resources: Vec::new(),
child_count: container.child_count.as_deref().and_then(|s| s.parse().ok()),
artist: container.artist,
album: None,
genre: None,
@@ -742,6 +744,7 @@ fn map_didl_entries(xml: &str) -> Result<Vec<MediaEntry>, ControlPointError> {
is_container: false,
class: item.class,
resources,
child_count: None,
artist: item.artist,
album: item.album,
genre: item.genre,

View File

@@ -12,14 +12,14 @@ use crate::errors::ControlPointError;
use crate::linkplay_client::extract_linkplay_host;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::DeviceIdentity;
/// Raw response from Arylic MCU+PINFGET command
@@ -137,7 +137,7 @@ impl RendererFromMediaRendererInfo for ArylicTcpRenderer {
impl ArylicTcpRenderer {
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
/// Create an ArylicTcpRenderer with a shared queue (for HybridUpnpArylic)
@@ -243,198 +243,44 @@ impl PlaybackStatus for ArylicTcpRenderer {
impl PlaybackPosition for ArylicTcpRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let info = match self.fetch_playback_info() {
Ok(info) => {
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
info
}
Err(e) => {
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
return Err(e);
}
};
let info = self.fetch_playback_info().map_err(|e| {
tracing::warn!("ArylicTcp fetch_playback_info failed: {}", e);
e
})?;
let mut position_info = info.position_info();
tracing::debug!("ArylicTcp fetch_playback_info returned: {:?}", info);
let mut position = info.position_info();
tracing::debug!(
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}, track_metadata={:?}, track_uri={:?}",
position_info.track_duration,
position_info.rel_time,
position_info
.track_metadata
.as_ref()
.map(|s| &s[..s.len().min(100)]),
position_info.track_uri
"ArylicTcp position_info: track_duration={:?}, rel_time={:?}",
position.track_duration,
position.rel_time,
);
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
let mut queue_guard = self.queue.lock().unwrap();
let queue_item = queue_guard.peek_current().ok().flatten();
// Replace device metadata with queue metadata (queue is authoritative).
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
if let Some((current_item, _)) = queue_item {
// Build DIDL metadata XML from cached/protected TrackMetadata
if let Some(ref metadata) = current_item.metadata {
tracing::debug!(
"ArylicTcp playback_position: using queue metadata - title={:?}, artist={:?}, duration={:?}, is_stream={}",
metadata.title,
metadata.artist,
metadata.duration,
metadata.is_continuous_stream
);
position_info.track_metadata = Some(
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
),
);
} else {
tracing::warn!("ArylicTcp playback_position: queue item has no metadata");
}
position_info.track_uri = Some(current_item.uri.clone());
} else {
tracing::warn!("ArylicTcp playback_position: no current queue item");
}
drop(queue_guard);
Ok(position_info)
Ok(position)
}
}
impl RendererBackend for ArylicTcpRenderer {
impl QueueTransportControl for ArylicTcpRenderer {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
self.play_uri(&item.uri, "")
}
}
impl HasQueue for ArylicTcpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for ArylicTcpRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for ArylicTcpRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
impl HasContinuousStream for ArylicTcpRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}

View File

@@ -1,36 +1,96 @@
// pmocontrol/src/capabilities.rs
use anyhow::Result;
use std::sync::{Arc, Mutex};
use crate::queue::MusicQueue;
use crate::{errors::ControlPointError, model::PlaybackState};
use crate::queue::{MusicQueue, QueueBackend};
use crate::{errors::ControlPointError, model::PlaybackState, PlaybackItem};
/// Backend-specific operations for renderers.
/// Marker trait for renderer backends that own a `MusicQueue`.
///
/// This trait provides access to backend-specific resources like the queue.
pub trait RendererBackend {
/// Returns a reference to the queue associated with this backend.
/// Implementing this trait automatically provides the full `QueueBackend`
/// blanket implementation (see `queue/backend.rs`). Backends only need to
/// return a reference to their `Arc<Mutex<MusicQueue>>` field.
pub trait HasQueue {
fn queue(&self) -> &Arc<Mutex<MusicQueue>>;
}
/// Marker trait for renderer backends that track stream continuity.
///
/// The flag is `true` while the renderer is playing a continuous stream
/// (e.g. an internet radio station) and `false` for bounded media files.
/// It is used by the watcher to decide whether auto-advance should be
/// suppressed when playback stops.
pub trait HasContinuousStream {
fn continuous_stream(&self) -> &Arc<Mutex<bool>>;
}
/// Queue-aware transport control operations.
///
/// These operations combine queue management with transport control,
/// allowing navigation (next/previous) and track selection from the queue.
#[allow(dead_code)]
pub trait QueueTransportControl {
/// Play the next track from the queue.
fn play_next(&self) -> Result<(), ControlPointError>;
/// Play the previous track from the queue.
#[allow(dead_code)]
fn play_previous(&self) -> Result<(), ControlPointError>;
pub trait QueueTransportControl: HasQueue + HasContinuousStream {
/// Play a specific item from the queue (backend-specific implementation).
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError>;
/// Play from the queue at the current index (or initialize to 0 if not set).
fn play_from_queue(&self) -> Result<(), ControlPointError>;
/// This is the default implementation that handles queue navigation.
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue().lock().expect("queue mutex poisoned");
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
drop(queue);
let is_stream = crate::music_renderer::is_continuous_stream(item.metadata.as_ref(), &item.uri);
*self.continuous_stream().lock().expect("continuous_stream mutex poisoned") = is_stream;
self.play_item(&item)
}
/// Play the next track from the queue.
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue().lock().expect("queue mutex poisoned");
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
/// Play the previous track from the queue.
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue().lock().expect("queue mutex poisoned");
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
/// Play from a specific index in the queue.
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError>;
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue().lock().expect("queue mutex poisoned");
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
/// Logical playback position across backends.
@@ -47,52 +107,97 @@ pub struct PlaybackPositionInfo {
pub track_metadata: Option<String>, // DIDL-Lite XML from GetPositionInfo
pub track_uri: Option<String>, // Current track URI
}
/// Provides the current playback position and track metadata.
///
/// All time fields use the format `"HH:MM:SS"` (or `None` when unavailable).
/// `track_metadata` carries a raw DIDL-Lite XML fragment returned by the device;
/// callers that only need structured metadata should use `extract_track_metadata`
/// from the watcher module instead.
pub trait PlaybackPosition {
/// Returns the current playback position information.
///
/// Returns `Err` if the renderer is unreachable or the query fails.
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError>;
}
/// Generic abstraction for playback status (transport state).
/// Generic abstraction for the current transport state.
///
/// For UPnP AV, this is backed by AVTransport::GetTransportInfo.
/// For OpenHome, a future implementation will adapt from OH Info/Time.
/// # Implementations
///
/// - **UPnP AV**: backed by `AVTransport::GetTransportInfo`.
/// - **OpenHome**: adapted from OH `Info` / `Time` services.
/// - **LinkPlay / Arylic**: mapped from the vendor status response.
///
/// # Postconditions
///
/// The returned `PlaybackState` must be one of the canonical values defined by
/// the `PlaybackState` enum. Backend-specific states that have no canonical
/// equivalent should be mapped to the closest approximation (e.g. "BUFFERING"
/// → `PlaybackState::Transitioning`).
pub trait PlaybackStatus {
/// Returns the current transport state of the renderer.
fn playback_state(&self) -> Result<PlaybackState, ControlPointError>;
}
/// Abstraction générique des capacités de transport (lecture / pause / stop / seek)
/// indépendamment du protocole sous-jacent (UPnP AV, OpenHome, ...).
/// Generic transport control abstraction (play / pause / stop / seek),
/// independent of the underlying protocol (UPnP AV, OpenHome, ).
///
/// # Invariants
///
/// - `play_uri` sets the active resource and begins playback atomically from the
/// caller's perspective. Implementations may split this into two protocol steps
/// (e.g. `SetAVTransportURI` + `Play` for UPnP AV) but the caller should not
/// need to know.
/// - `play` / `pause` / `stop` operate on whatever resource is currently loaded;
/// they do not change the queue pointer.
/// - `seek_rel_time` uses the format `"HH:MM:SS"`. Backends that do not support
/// seeking should return `ControlPointError::NotSupported`.
///
/// # Relation to `QueueTransportControl`
///
/// `TransportControl` knows nothing about the queue. `QueueTransportControl`
/// extends it with queue-aware navigation (`play_next`, `play_previous`, …).
pub trait TransportControl {
/// Set la ressource à lire (URI + métadonnées) et/ou commence la lecture.
/// Load a resource (URI + DIDL-Lite metadata) and begin playback.
///
/// 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.).
/// Depending on the backend this may execute as a single atomic operation or as
/// two sequential commands (set resource, then play).
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError>;
/// Démarre ou reprend la lecture.
/// Start or resume playback of the currently loaded resource.
fn play(&self) -> Result<(), ControlPointError>;
/// Met la lecture en pause.
/// Pause the current playback.
fn pause(&self) -> Result<(), ControlPointError>;
/// Arrête la lecture.
/// Stop the current playback and release the loaded resource.
fn stop(&self) -> Result<(), ControlPointError>;
/// Seek à un temps relatif (HH:MM:SS) si supporté.
/// Seek to a relative time position expressed as `"HH:MM:SS"`.
///
/// Returns `ControlPointError::NotSupported` when the backend does not
/// implement seeking.
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError>;
}
/// Abstraction générique des capacités de contrôle de volume / mute.
/// Generic volume and mute control abstraction.
///
/// # Volume scale
///
/// Volume values are expressed on the native scale of each renderer.
/// UPnP AV and OpenHome renderers typically use 0100. Callers should
/// not assume any particular scale; use the values returned by `volume()`
/// as the baseline for relative adjustments.
pub trait VolumeControl {
/// Retourne le volume logique courant (échelle dépendante du renderer).
/// Returns the current logical volume (renderer-specific scale).
fn volume(&self) -> Result<u16, ControlPointError>;
/// Définit le volume logique (échelle dépendante du renderer).
/// Sets the logical volume (renderer-specific scale).
fn set_volume(&self, v: u16) -> Result<(), ControlPointError>;
/// Indique si le renderer est muet (mute activé).
/// Returns `true` when the renderer is muted.
fn mute(&self) -> Result<bool, ControlPointError>;
/// Active ou désactive le mute.
/// Enables (`true`) or disables (`false`) mute.
fn set_mute(&self, m: bool) -> Result<(), ControlPointError>;
}

View File

@@ -23,11 +23,12 @@ use crate::discovery::chromecast_discovery::{
use crate::errors::ControlPointError;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss_f64, parse_hhmmss_strict};
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::DeviceIdentity;
@@ -35,7 +36,7 @@ use crate::DeviceIdentity;
use rust_cast::{
channels::{
heartbeat::HeartbeatResponse,
media::{Media, PlayerState as CastPlayerState, StreamType},
media::{Media, PlayerState as CastPlayerState, StatusEntry, StreamType},
receiver::CastDeviceApp,
},
CastDevice, ChannelMessage,
@@ -171,7 +172,51 @@ impl RendererFromMediaRendererInfo for ChromecastRenderer {
impl ChromecastRenderer {
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
/// Returns `(transport_id, media_entry)` for the currently active Cast session.
///
/// This encapsulates the repeated sequence:
/// connect device → get receiver status → get active app → connect to app → get media status
///
/// Used by all transport operations (play/pause/stop/seek) and by
/// playback_state/playback_position to avoid duplicating this boilerplate.
fn get_active_media_entry<'d>(
&self,
device: &'d CastDevice<'d>,
) -> Result<(String, StatusEntry), ControlPointError> {
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.into_iter()
.next()
.ok_or_else(|| ControlPointError::ChromecastError("No active app found".into()))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let entry = media_status
.entries
.into_iter()
.next()
.ok_or_else(|| ControlPointError::ChromecastError("No media session found".into()))?;
Ok((app.transport_id, entry))
}
/// Connect to the device with retry on connection failures.
@@ -202,15 +247,6 @@ impl TransportControl for ChromecastRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: play_uri({})", uri);
// Détecte si l'URL est un flux continu
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
*self.continuous_stream.lock().unwrap() = is_stream;
tracing::debug!(
"ChromecastRenderer play_uri: URI={}, continuous_stream={}",
uri,
is_stream
);
// Signal any existing play thread to stop
if let Ok(mut stop) = self.stop_signal.lock() {
*stop = true;
@@ -377,186 +413,53 @@ impl TransportControl for ChromecastRenderer {
fn play(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: play()");
let device = self.connect_with_retry()?;
// Get receiver status to find the active app
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
// Connect to the app
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
// Send play command
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.play(app.transport_id.as_str(), media_entry.media_session_id)
.play(transport_id.as_str(), entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to play: {}", e)))?;
Ok(())
}
fn pause(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: pause()");
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.pause(app.transport_id.as_str(), media_entry.media_session_id)
.pause(transport_id.as_str(), entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to pause: {}", e)))?;
Ok(())
}
fn stop(&self) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: stop()");
// Signal the play thread to stop
// Signal the play thread to stop first.
// The thread terminates on its own; play_uri() will wait for it if needed.
if let Ok(mut stop) = self.stop_signal.lock() {
*stop = true;
}
// Note: We don't wait for the thread here as stop() should be quick.
// The thread will terminate on its own when it checks stop_signal.
// If a new play_uri() is called, it will properly wait for this thread.
// Also send stop command to the device
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.stop(app.transport_id.as_str(), media_entry.media_session_id)
.stop(transport_id.as_str(), entry.media_session_id)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to stop: {}", e)))?;
Ok(())
}
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
debug!("ChromecastRenderer: seek_rel_time({})", hhmmss);
let total_seconds = parse_hhmmss_strict(hhmmss)? as f32;
let device = self.connect_with_retry()?;
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
let (transport_id, entry) = self.get_active_media_entry(&device)?;
device
.media
.seek(
app.transport_id.as_str(),
media_entry.media_session_id,
Some(total_seconds),
None,
)
.seek(transport_id.as_str(), entry.media_session_id, Some(total_seconds), None)
.map_err(|e| ControlPointError::ChromecastError(format!("Failed to seek: {}", e)))?;
Ok(())
}
}
@@ -565,125 +468,57 @@ impl PlaybackStatus for ChromecastRenderer {
fn playback_state(&self) -> Result<PlaybackState, ControlPointError> {
let device = self.connect_with_retry()?;
// Get receiver status to find the active app
// If no app is running there is no media — return NoMedia without error.
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
tracing::debug!("Chromecast playback_state: {} apps running", status.applications.len());
tracing::debug!(
"Chromecast playback_state: {} apps running",
status.applications.len()
);
if status.applications.is_empty() {
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
return Ok(PlaybackState::NoMedia);
}
// If no app is running, return NoMedia
let app = match status.applications.first() {
Some(app) => {
tracing::debug!("Chromecast playback_state: app={}", app.display_name);
app
}
None => {
tracing::debug!("Chromecast playback_state: no apps running, returning NoMedia");
return Ok(PlaybackState::NoMedia);
}
};
// Connect to the app
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
tracing::debug!(
"Chromecast playback_state: {} media entries",
media_status.entries.len()
);
// If no media entry, return NoMedia
let media_entry = match media_status.entries.first() {
Some(entry) => {
match self.get_active_media_entry(&device) {
Ok((_, entry)) => {
tracing::debug!(
"Chromecast playback_state: player_state={:?}, current_time={:?}",
entry.player_state,
entry.current_time
entry.player_state, entry.current_time
);
entry
Ok(map_player_state(&entry.player_state))
}
None => {
tracing::debug!("Chromecast playback_state: no media entries, returning NoMedia");
return Ok(PlaybackState::NoMedia);
// No media session → device is idle
Err(_) => {
tracing::debug!("Chromecast playback_state: no media session, returning NoMedia");
Ok(PlaybackState::NoMedia)
}
};
Ok(map_player_state(&media_entry.player_state))
}
}
}
impl PlaybackPosition for ChromecastRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let device = self.connect_with_retry()?;
let (_, entry) = self.get_active_media_entry(&device)?;
// Get receiver status to find the active app
let status = device.receiver.get_status().map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get receiver status: {}", e))
})?;
let rel_time = entry.current_time.map(|t| format_hhmmss_f64(t as f64));
let track_duration = entry.media.as_ref().and_then(|m| m.duration).map(|d| format_hhmmss_f64(d as f64));
let track_uri = entry.media.as_ref().map(|m| m.content_id.clone());
let app = status
.applications
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No active app found")))?;
// Connect to the app
device
.connection
.connect(app.transport_id.as_str())
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to connect to app: {}", e))
})?;
// Get media status
let media_status = device
.media
.get_status(app.transport_id.as_str(), None)
.map_err(|e| {
ControlPointError::ChromecastError(format!("Failed to get media status: {}", e))
})?;
let media_entry = media_status
.entries
.first()
.ok_or_else(|| ControlPointError::ChromecastError(format!("No media session found")))?;
// Extract position information
let rel_time = media_entry
.current_time
.map(|time| format_hhmmss_f64(time as f64));
let track_duration = media_entry
.media
.as_ref()
.and_then(|m| m.duration)
.map(|dur| format_hhmmss_f64(dur as f64));
let track_uri = media_entry.media.as_ref().map(|m| m.content_id.clone());
Ok(PlaybackPositionInfo {
let mut position = PlaybackPositionInfo {
track: Some(1),
rel_time,
abs_time: None,
track_duration,
track_metadata: None, // Chromecast doesn't use DIDL-Lite
track_metadata: None,
track_uri,
})
};
// Replace device metadata with queue metadata (queue is authoritative;
// Chromecast does not return DIDL-Lite natively so the queue is the only source).
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
Ok(position)
}
}
@@ -799,139 +634,22 @@ impl VolumeControl for ChromecastRenderer {
}
}
impl RendererBackend for ChromecastRenderer {
impl QueueTransportControl for ChromecastRenderer {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
self.play_uri(&item.uri, "")
}
}
impl HasQueue for ChromecastRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for ChromecastRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for ChromecastRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
impl HasContinuousStream for ChromecastRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}

View File

@@ -10,14 +10,14 @@ use crate::linkplay_client::{
};
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::parse_hhmmss_strict;
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend};
use crate::DeviceIdentity;
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
@@ -91,21 +91,12 @@ impl RendererFromMediaRendererInfo for LinkPlayRenderer {
impl LinkPlayRenderer {
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
}
impl TransportControl for LinkPlayRenderer {
fn play_uri(&self, uri: &str, _meta: &str) -> Result<(), ControlPointError> {
// Détecte si l'URL est un flux continu
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
*self.continuous_stream.lock().unwrap() = is_stream;
tracing::debug!(
"LinkPlayRenderer play_uri: URI={}, continuous_stream={}",
uri,
is_stream
);
let encoded = percent_encode(uri);
self.send_player_command(&format!("play:{}", encoded))
}
@@ -155,163 +146,29 @@ impl PlaybackStatus for LinkPlayRenderer {
impl PlaybackPosition for LinkPlayRenderer {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
let mut position_info = self.fetch_status()?.position_info();
// Use queue metadata instead of direct status metadata to benefit from duration protection
let mut queue_guard = self.queue.lock().unwrap();
let queue_item = queue_guard.peek_current().ok().flatten();
if let Some((current_item, _)) = queue_item {
if let Some(ref metadata) = current_item.metadata {
position_info.track_metadata = Some(
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
),
);
}
position_info.track_uri = Some(current_item.uri.clone());
}
drop(queue_guard);
Ok(position_info)
let mut position = self.fetch_status()?.position_info();
// Replace device metadata with queue metadata (queue is authoritative).
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
Ok(position)
}
}
impl RendererBackend for LinkPlayRenderer {
impl QueueTransportControl for LinkPlayRenderer {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
self.play_uri(&item.uri, "")
}
}
impl HasQueue for LinkPlayRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for LinkPlayRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
let uri = item.uri.clone();
drop(queue);
self.play_uri(&uri, "")
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
self.play_from_queue()
}
}
impl QueueBackend for LinkPlayRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
impl HasContinuousStream for LinkPlayRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}

View File

@@ -6,7 +6,7 @@ mod upnp_renderer;
mod openhome;
mod openhome_renderer;
mod capabilities;
pub mod capabilities;
mod chromecast_renderer;
mod musicrenderer;
@@ -18,13 +18,14 @@ pub mod watcher;
use std::sync::{Arc, Mutex};
pub use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
pub use crate::music_renderer::musicrenderer::{MusicRenderer, PlaylistBinding};
pub use crate::music_renderer::sleep_timer::SleepTimer;
pub use crate::music_renderer::stream_detection::is_continuous_stream_url;
pub use crate::music_renderer::stream_detection::{is_continuous_stream, is_continuous_stream_url};
use crate::{
RendererInfo, errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend,
errors::ControlPointError, music_renderer::musicrenderer::MusicRendererBackend, RendererInfo,
};
pub trait RendererFromMediaRendererInfo {

View File

@@ -19,10 +19,6 @@ use crate::events::RendererEventBus;
use crate::model::RendererEvent;
use crate::model::{PlaybackSource, PlaybackState, RendererInfo, RendererProtocol, TrackMetadata};
use crate::music_renderer::arylic_tcp::ArylicTcpRenderer;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::chromecast_renderer::ChromecastRenderer;
use crate::music_renderer::linkplay_renderer::LinkPlayRenderer;
use crate::music_renderer::openhome_renderer::OpenHomeRenderer;
@@ -33,6 +29,10 @@ use crate::music_renderer::watcher::{
WatchedState,
};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::{
HasContinuousStream, HasQueue, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::online::DeviceConnectionState;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::{DeviceId, DeviceIdentity, DeviceOnline};
@@ -46,9 +46,9 @@ use tracing::warn;
#[derive(Clone, Debug)]
pub struct PlaylistBinding {
/// MediaServer that owns the playlist container.
pub server_id: DeviceId,
pub(crate) server_id: DeviceId,
/// DIDL-Lite object id of the playlist container.
pub container_id: String,
pub(crate) container_id: String,
/// True once at least one ContainerUpdateIDs notification has been seen.
pub(crate) has_seen_update: bool,
/// Flag used internally to signal that the queue should be refreshed
@@ -58,6 +58,18 @@ pub struct PlaylistBinding {
pub(crate) auto_play_on_refresh: bool,
}
impl PlaylistBinding {
/// Returns the ID of the MediaServer that owns this playlist container.
pub fn server_id(&self) -> &DeviceId {
&self.server_id
}
/// Returns the DIDL-Lite object ID of the playlist container.
pub fn container_id(&self) -> &str {
&self.container_id
}
}
/// Backend-agnostic façade exposing transport, volume, and status contracts.
#[derive(Clone, Debug)]
pub enum MusicRendererBackend {
@@ -311,6 +323,14 @@ impl MusicRenderer {
}
/// Main loop for the watcher thread.
///
/// # Error policy
///
/// The watcher thread runs for the lifetime of the renderer and never restarts
/// automatically. Network/device errors during polling are logged at `debug` level
/// and ignored — they are transient and expected when a device is temporarily
/// unreachable. Panics inside `poll_and_emit_changes` are caught and logged at
/// `error` level so they do not kill the watcher thread.
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
let Some(base_interval) = strategy.polling_interval() else {
// Pure push strategy - no polling needed (future implementation)
@@ -341,7 +361,16 @@ impl MusicRenderer {
};
if self.is_online() {
self.poll_and_emit_changes(tick);
// Wrap in catch_unwind so a panic in poll logic does not terminate the watcher.
let poll_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.poll_and_emit_changes(tick);
}));
if let Err(_panic) = poll_result {
error!(
renderer = self.info.friendly_name(),
"poll_and_emit_changes panicked; watcher continues"
);
}
last_activity_time = SystemTime::now();
}
@@ -393,14 +422,19 @@ impl MusicRenderer {
// Step 2: Do all network calls WITHOUT holding any locks
// This prevents blocking other threads that need to read watched_state
let position = self.playback_position().ok();
let raw_state = self.playback_state().ok();
// Errors are logged at trace level — device temporarily unreachable is expected.
let position = self.playback_position()
.inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_position failed"))
.ok();
let raw_state = self.playback_state()
.inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "playback_state failed"))
.ok();
// Poll volume and mute every other tick (1 second at 500ms interval)
let (volume, mute, is_stream) = if tick % 2 == 0 {
(
self.volume().ok(),
self.mute().ok(),
self.volume().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "volume poll failed")).ok(),
self.mute().inspect_err(|e| tracing::trace!(renderer = self.info.friendly_name(), error = %e, "mute poll failed")).ok(),
Some(self.is_playing_a_stream()),
)
} else {
@@ -490,41 +524,21 @@ impl MusicRenderer {
if let Some(stream_flag) = is_stream {
if stream_flag {
if let Some(ref new_duration) = position.track_duration {
let mut state = self.state.lock().unwrap();
// Parse durations to compare (HH:MM:SS format)
let parse_duration = |dur_str: &str| -> Option<u32> {
let parts: Vec<&str> = dur_str.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
let mut state = self.state.lock().expect("RendererState mutex poisoned");
match &state.current_track_duration {
Some(stored_duration) => {
// Compare new duration with stored one
if let (Some(stored_secs), Some(new_secs)) = (
parse_duration(stored_duration),
parse_duration(new_duration),
) {
if new_secs > stored_secs {
// Duration increased: update stored value and use new one
tracing::debug!(
"MusicRenderer [{}]: Stream duration increased: {} -> {}",
self.info.friendly_name(),
stored_duration,
new_duration
);
state.current_track_duration = Some(new_duration.clone());
} else {
// Duration decreased or equal: keep stored value
position.track_duration = Some(stored_duration.clone());
}
if crate::queue::stream_duration_increased(stored_duration, new_duration) {
tracing::debug!(
"MusicRenderer [{}]: Stream duration increased: {} -> {}",
self.info.friendly_name(),
stored_duration,
new_duration
);
state.current_track_duration = Some(new_duration.clone());
} else if crate::queue::stream_duration_decreased(stored_duration, new_duration) {
// Duration decreased: keep stored value
position.track_duration = Some(stored_duration.clone());
}
}
None => {
@@ -667,7 +681,7 @@ impl MusicRenderer {
match state {
PlaybackState::Stopped => {
{
let s = self.state.lock().unwrap();
let s = self.state.lock().expect("RendererState mutex poisoned");
tracing::debug!(
renderer = self.info.friendly_name(),
has_played = s.has_played_since_track_start,
@@ -700,7 +714,7 @@ impl MusicRenderer {
// On autorise l'auto-avance si:
// 1. On a bien vu PLAYING OU
// 2. Le titre a été lancé depuis plus de 20 secondes
let track_start = self.state.lock().unwrap().track_start_time;
let track_start = self.state.lock().expect("RendererState mutex poisoned").track_start_time;
let elapsed = track_start
.and_then(|t| t.elapsed().ok())
.unwrap_or_default();
@@ -757,7 +771,7 @@ impl MusicRenderer {
PlaybackState::NoMedia => {
// Handle end of track (Chromecast returns NoMedia when track ends)
// This is equivalent to Stopped for auto-advance purposes
let s = self.state.lock().unwrap();
let s = self.state.lock().expect("RendererState mutex poisoned");
let playback_source = s.playback_source;
let has_played = s.has_played_since_track_start;
let user_stop = s.user_stop_requested;
@@ -824,7 +838,7 @@ impl MusicRenderer {
self.set_has_played_flag();
}
PlaybackState::Transitioning => {
let s = self.state.lock().unwrap();
let s = self.state.lock().expect("RendererState mutex poisoned");
tracing::trace!(
renderer = self.info.friendly_name(),
has_played = s.has_played_since_track_start,
@@ -998,7 +1012,7 @@ impl MusicRenderer {
/// Get a clone of the queue Arc (for async sync operations).
pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
let backend = self.lock_backend_for("queue");
crate::music_renderer::capabilities::RendererBackend::queue(&*backend).clone()
crate::music_renderer::capabilities::HasQueue::queue(&*backend).clone()
}
/// Get the current queue item without advancing.
@@ -1666,13 +1680,13 @@ impl MusicRenderer {
/// Gets the last known track metadata.
pub fn last_metadata(&self) -> Option<TrackMetadata> {
self.state.lock().unwrap().last_metadata.clone()
self.state.lock().expect("RendererState mutex poisoned").last_metadata.clone()
}
/// Sets the last known track metadata.
/// Updates track_start_time and resets current_track_duration only if the metadata actually changes.
pub fn set_last_metadata(&self, metadata: Option<TrackMetadata>) {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
let metadata_changed = state.last_metadata != metadata;
if metadata_changed {
// Pour les flux continus: utiliser dc:date comme track_start_time réel de diffusion.
@@ -1693,23 +1707,23 @@ impl MusicRenderer {
/// Gets the timestamp when the current track started playing.
pub fn track_start_time(&self) -> Option<SystemTime> {
self.state.lock().unwrap().track_start_time
self.state.lock().expect("RendererState mutex poisoned").track_start_time
}
/// Gets the current playback source.
pub fn playback_source(&self) -> PlaybackSource {
self.state.lock().unwrap().playback_source
self.state.lock().expect("RendererState mutex poisoned").playback_source
}
/// Sets the playback source.
pub fn set_playback_source(&self, source: PlaybackSource) {
self.state.lock().unwrap().playback_source = source;
self.state.lock().expect("RendererState mutex poisoned").playback_source = source;
}
/// Checks if currently playing from queue.
pub fn is_playing_from_queue(&self) -> bool {
matches!(
self.state.lock().unwrap().playback_source,
self.state.lock().expect("RendererState mutex poisoned").playback_source,
PlaybackSource::FromQueue
)
}
@@ -1720,7 +1734,7 @@ impl MusicRenderer {
/// Does NOT change None -> External because that would break queue playback
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
pub fn mark_external_if_idle(&self) {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
if matches!(state.playback_source, PlaybackSource::External) {
// Keep External if we were already playing externally
} else {
@@ -1731,12 +1745,12 @@ impl MusicRenderer {
/// Marks that the user requested a stop (to prevent auto-advance).
pub fn mark_user_stop_requested(&self) {
self.state.lock().unwrap().user_stop_requested = true;
self.state.lock().expect("RendererState mutex poisoned").user_stop_requested = true;
}
/// Checks and clears the user stop requested flag.
pub fn check_and_clear_user_stop_requested(&self) -> bool {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
let was_requested = state.user_stop_requested;
state.user_stop_requested = false;
was_requested
@@ -1747,21 +1761,21 @@ impl MusicRenderer {
/// Sets the has_played_since_track_start flag to true.
/// Called when PLAYING state is detected.
fn set_has_played_flag(&self) {
self.state.lock().unwrap().has_played_since_track_start = true;
self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = true;
}
/// Clears the has_played_since_track_start flag.
/// Called when stopping playback or starting a new track.
/// This is public so that ControlPoint can reset it when jumping to a new track.
pub fn clear_has_played_flag(&self) {
self.state.lock().unwrap().has_played_since_track_start = false;
self.state.lock().expect("RendererState mutex poisoned").has_played_since_track_start = false;
}
/// Checks and clears the has_played_since_track_start flag.
/// Returns true if PLAYING was seen since last track start, false otherwise.
/// Used to determine if auto-advance should be allowed.
fn check_and_clear_has_played_flag(&self) -> bool {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
let has_played = state.has_played_since_track_start;
state.has_played_since_track_start = false;
has_played
@@ -1777,7 +1791,7 @@ impl MusicRenderer {
/// # Errors
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
pub fn start_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
state
.sleep_timer
.start(duration_seconds)
@@ -1793,7 +1807,7 @@ impl MusicRenderer {
/// # Errors
/// Returns an error if the duration is invalid (0 or > 7200 seconds).
pub fn update_sleep_timer(&self, duration_seconds: u32) -> Result<u32, ControlPointError> {
let mut state = self.state.lock().unwrap();
let mut state = self.state.lock().expect("RendererState mutex poisoned");
state
.sleep_timer
.update(duration_seconds)
@@ -1804,32 +1818,32 @@ impl MusicRenderer {
/// Cancels the sleep timer.
pub fn cancel_sleep_timer(&self) {
self.state.lock().unwrap().sleep_timer.cancel();
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.cancel();
}
/// Returns the remaining seconds of the sleep timer, or None if no timer is active.
pub fn sleep_timer_remaining(&self) -> Option<u32> {
self.state.lock().unwrap().sleep_timer.remaining_seconds()
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.remaining_seconds()
}
/// Returns the configured duration of the sleep timer in seconds.
pub fn sleep_timer_duration(&self) -> u32 {
self.state.lock().unwrap().sleep_timer.duration_seconds()
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.duration_seconds()
}
/// Returns true if the sleep timer is active.
pub fn is_sleep_timer_active(&self) -> bool {
self.state.lock().unwrap().sleep_timer.is_active()
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_active()
}
/// Returns true if the sleep timer has expired.
pub fn is_sleep_timer_expired(&self) -> bool {
self.state.lock().unwrap().sleep_timer.is_expired()
self.state.lock().expect("RendererState mutex poisoned").sleep_timer.is_expired()
}
/// Gets the sleep timer state as a tuple (is_active, duration_seconds, remaining_seconds).
pub fn sleep_timer_state(&self) -> (bool, u32, Option<u32>) {
let state = self.state.lock().unwrap();
let state = self.state.lock().expect("RendererState mutex poisoned");
(
state.sleep_timer.is_active(),
state.sleep_timer.duration_seconds(),
@@ -1934,6 +1948,33 @@ pub(crate) fn build_didl_lite_metadata(
didl.to_xml()
}
/// Enriches a [`PlaybackPositionInfo`] with metadata from the backend's queue.
///
/// Overwrites `track_metadata` and `track_uri` with the values stored for the
/// current queue item. This is a no-op when the queue is empty or has no
/// current item.
///
/// All backends that hold a local queue (UPnP, LinkPlay, Arylic, Chromecast)
/// should call this after fetching the raw device position, so that callers
/// always receive consistent, queue-authoritative metadata rather than
/// potentially stale or absent metadata coming from the device itself.
pub(crate) fn enrich_position_from_queue<B: HasQueue>(
backend: &B,
position: &mut PlaybackPositionInfo,
) {
let mut queue = backend.queue().lock().expect("queue mutex poisoned");
if let Ok(Some((current_item, _))) = queue.peek_current() {
if let Some(ref metadata) = current_item.metadata {
position.track_metadata = Some(build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
));
}
position.track_uri = Some(current_item.uri.clone());
}
}
impl DeviceIdentity for MusicRenderer {
fn id(&self) -> DeviceId {
self.info.id()
@@ -2091,7 +2132,7 @@ impl RendererFromMediaRendererInfo for MusicRendererBackend {
/// Extracts the duration attribute from the <res> element in DIDL metadata.
/// This is used as a fallback when the renderer doesn't provide track_duration
/// in GetPositionInfo or similar calls.
fn parse_didl_duration(didl_xml: &str) -> Option<String> {
pub(crate) fn parse_didl_duration(didl_xml: &str) -> Option<String> {
// Parse DIDL-Lite XML properly using pmodidl
let didl = match DIDLLite::parse(didl_xml) {
Ok(d) => d,
@@ -2129,6 +2170,37 @@ fn parse_rfc3339_to_system_time(s: &str) -> Option<SystemTime> {
Some(std::time::UNIX_EPOCH + std::time::Duration::from_secs(secs as u64))
}
/// Dispatch a method call to the inner backend, using the UPnP field for
/// HybridUpnpArylic.
macro_rules! dispatch_upnp {
($self:expr, $method:ident($($arg:expr),*)) => {
match $self {
MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.$method($($arg),*),
}
};
}
/// Dispatch a method call to the inner backend, using the Arylic field for
/// HybridUpnpArylic.
macro_rules! dispatch_arylic {
($self:expr, $method:ident($($arg:expr),*)) => {
match $self {
MusicRendererBackend::Upnp(r) => r.$method($($arg),*),
MusicRendererBackend::OpenHome(r) => r.$method($($arg),*),
MusicRendererBackend::LinkPlay(r) => r.$method($($arg),*),
MusicRendererBackend::ArylicTcp(r) => r.$method($($arg),*),
MusicRendererBackend::Chromecast(cc) => cc.$method($($arg),*),
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.$method($($arg),*),
}
};
}
/// Transport control façade that dispatches to whichever backend can fulfill
/// the request, returning a standardized error if the backend lacks support.
impl TransportControl for MusicRendererBackend {
@@ -2145,38 +2217,9 @@ impl TransportControl for MusicRendererBackend {
}
}
fn play(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(upnp) => upnp.play(),
MusicRendererBackend::OpenHome(oh) => oh.play(),
MusicRendererBackend::LinkPlay(lp) => lp.play(),
MusicRendererBackend::ArylicTcp(ary) => ary.play(),
MusicRendererBackend::Chromecast(cc) => cc.play(),
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.play(),
}
}
fn pause(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(upnp) => upnp.pause(),
MusicRendererBackend::OpenHome(oh) => oh.pause(),
MusicRendererBackend::LinkPlay(lp) => lp.pause(),
MusicRendererBackend::ArylicTcp(ary) => ary.pause(),
MusicRendererBackend::Chromecast(cc) => cc.pause(),
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.pause(),
}
}
fn stop(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(upnp) => upnp.stop(),
MusicRendererBackend::OpenHome(oh) => oh.stop(),
MusicRendererBackend::LinkPlay(lp) => lp.stop(),
MusicRendererBackend::ArylicTcp(ary) => ary.stop(),
MusicRendererBackend::Chromecast(cc) => cc.stop(),
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.stop(),
}
}
fn play(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, play()) }
fn pause(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, pause()) }
fn stop(&self) -> Result<(), ControlPointError> { dispatch_arylic!(self, stop()) }
fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
match self {
@@ -2197,49 +2240,10 @@ impl TransportControl for MusicRendererBackend {
/// Hybrid backends may read via Arylic TCP and write via UPnP, but callers
/// always depend on a single [`VolumeControl`] entry point.
impl VolumeControl for MusicRendererBackend {
fn volume(&self) -> Result<u16, ControlPointError> {
match self {
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.volume(),
MusicRendererBackend::ArylicTcp(ary) => ary.volume(),
MusicRendererBackend::OpenHome(oh) => oh.volume(),
MusicRendererBackend::Upnp(upnp) => upnp.volume(),
MusicRendererBackend::LinkPlay(lp) => lp.volume(),
MusicRendererBackend::Chromecast(cc) => cc.volume(),
}
}
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_volume(vol),
MusicRendererBackend::ArylicTcp(ary) => ary.set_volume(vol),
MusicRendererBackend::OpenHome(oh) => oh.set_volume(vol),
MusicRendererBackend::Upnp(upnp) => upnp.set_volume(vol),
MusicRendererBackend::LinkPlay(lp) => lp.set_volume(vol),
MusicRendererBackend::Chromecast(cc) => cc.set_volume(vol),
}
}
fn mute(&self) -> Result<bool, ControlPointError> {
match self {
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.mute(),
MusicRendererBackend::OpenHome(r) => r.mute(),
MusicRendererBackend::Upnp(r) => r.mute(),
MusicRendererBackend::LinkPlay(r) => r.mute(),
MusicRendererBackend::ArylicTcp(r) => r.mute(),
MusicRendererBackend::Chromecast(cc) => cc.mute(),
}
}
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.set_mute(m),
MusicRendererBackend::OpenHome(r) => r.set_mute(m),
MusicRendererBackend::Upnp(r) => r.set_mute(m),
MusicRendererBackend::LinkPlay(r) => r.set_mute(m),
MusicRendererBackend::ArylicTcp(r) => r.set_mute(m),
MusicRendererBackend::Chromecast(cc) => cc.set_mute(m),
}
}
fn volume(&self) -> Result<u16, ControlPointError> { dispatch_arylic!(self, volume()) }
fn set_volume(&self, vol: u16) -> Result<(), ControlPointError> { dispatch_upnp!(self, set_volume(vol)) }
fn mute(&self) -> Result<bool, ControlPointError> { dispatch_arylic!(self, mute()) }
fn set_mute(&self, m: bool) -> Result<(), ControlPointError> { dispatch_arylic!(self, set_mute(m)) }
}
/// Playback-state queries sourced from the backend best suited for the job.
@@ -2263,234 +2267,28 @@ impl PlaybackStatus for MusicRendererBackend {
/// regardless of the backend providing the raw transport data.
impl PlaybackPosition for MusicRendererBackend {
fn playback_position(&self) -> Result<PlaybackPositionInfo, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.playback_position(),
MusicRendererBackend::OpenHome(r) => r.playback_position(),
MusicRendererBackend::LinkPlay(r) => r.playback_position(),
MusicRendererBackend::ArylicTcp(r) => r.playback_position(),
MusicRendererBackend::Chromecast(cc) => cc.playback_position(),
MusicRendererBackend::HybridUpnpArylic { arylic, .. } => arylic.playback_position(),
}
dispatch_arylic!(self, playback_position())
}
}
impl RendererBackend for MusicRendererBackend {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
match self {
MusicRendererBackend::Upnp(r) => r.queue(),
MusicRendererBackend::OpenHome(r) => r.queue(),
MusicRendererBackend::LinkPlay(r) => r.queue(),
MusicRendererBackend::ArylicTcp(r) => r.queue(),
MusicRendererBackend::Chromecast(cc) => cc.queue(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue(),
}
impl HasQueue for MusicRendererBackend {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> { dispatch_upnp!(self, queue()) }
}
impl HasContinuousStream for MusicRendererBackend {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
dispatch_arylic!(self, continuous_stream())
}
}
impl QueueTransportControl for MusicRendererBackend {
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
dispatch_upnp!(self, play_item(item))
}
fn play_from_queue(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_from_queue(),
MusicRendererBackend::OpenHome(r) => r.play_from_queue(),
MusicRendererBackend::LinkPlay(r) => r.play_from_queue(),
MusicRendererBackend::ArylicTcp(r) => r.play_from_queue(),
MusicRendererBackend::Chromecast(cc) => cc.play_from_queue(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_queue(),
}
dispatch_upnp!(self, play_from_queue())
}
fn play_next(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_next(),
MusicRendererBackend::OpenHome(r) => r.play_next(),
MusicRendererBackend::LinkPlay(r) => r.play_next(),
MusicRendererBackend::ArylicTcp(r) => r.play_next(),
MusicRendererBackend::Chromecast(cc) => cc.play_next(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_next(),
}
}
fn play_previous(&self) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_previous(),
MusicRendererBackend::OpenHome(r) => r.play_previous(),
MusicRendererBackend::LinkPlay(r) => r.play_previous(),
MusicRendererBackend::ArylicTcp(r) => r.play_previous(),
MusicRendererBackend::Chromecast(cc) => cc.play_previous(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_previous(),
}
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.play_from_index(index),
MusicRendererBackend::OpenHome(r) => r.play_from_index(index),
MusicRendererBackend::LinkPlay(r) => r.play_from_index(index),
MusicRendererBackend::ArylicTcp(r) => r.play_from_index(index),
MusicRendererBackend::Chromecast(cc) => cc.play_from_index(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.play_from_index(index),
}
}
}
impl QueueBackend for MusicRendererBackend {
fn len(&self) -> Result<usize, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.len(),
MusicRendererBackend::OpenHome(r) => r.len(),
MusicRendererBackend::LinkPlay(r) => r.len(),
MusicRendererBackend::ArylicTcp(r) => r.len(),
MusicRendererBackend::Chromecast(cc) => cc.len(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.len(),
}
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.track_ids(),
MusicRendererBackend::OpenHome(r) => r.track_ids(),
MusicRendererBackend::LinkPlay(r) => r.track_ids(),
MusicRendererBackend::ArylicTcp(r) => r.track_ids(),
MusicRendererBackend::Chromecast(cc) => cc.track_ids(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.track_ids(),
}
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.id_to_position(id),
MusicRendererBackend::OpenHome(r) => r.id_to_position(id),
MusicRendererBackend::LinkPlay(r) => r.id_to_position(id),
MusicRendererBackend::ArylicTcp(r) => r.id_to_position(id),
MusicRendererBackend::Chromecast(cc) => cc.id_to_position(id),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.id_to_position(id),
}
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.position_to_id(id),
MusicRendererBackend::OpenHome(r) => r.position_to_id(id),
MusicRendererBackend::LinkPlay(r) => r.position_to_id(id),
MusicRendererBackend::ArylicTcp(r) => r.position_to_id(id),
MusicRendererBackend::Chromecast(cc) => cc.position_to_id(id),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.position_to_id(id),
}
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.current_track(),
MusicRendererBackend::OpenHome(r) => r.current_track(),
MusicRendererBackend::LinkPlay(r) => r.current_track(),
MusicRendererBackend::ArylicTcp(r) => r.current_track(),
MusicRendererBackend::Chromecast(cc) => cc.current_track(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_track(),
}
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.current_index(),
MusicRendererBackend::OpenHome(r) => r.current_index(),
MusicRendererBackend::LinkPlay(r) => r.current_index(),
MusicRendererBackend::ArylicTcp(r) => r.current_index(),
MusicRendererBackend::Chromecast(cc) => cc.current_index(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.current_index(),
}
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.queue_snapshot(),
MusicRendererBackend::OpenHome(r) => r.queue_snapshot(),
MusicRendererBackend::LinkPlay(r) => r.queue_snapshot(),
MusicRendererBackend::ArylicTcp(r) => r.queue_snapshot(),
MusicRendererBackend::Chromecast(cc) => cc.queue_snapshot(),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.queue_snapshot(),
}
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.set_index(index),
MusicRendererBackend::OpenHome(r) => r.set_index(index),
MusicRendererBackend::LinkPlay(r) => r.set_index(index),
MusicRendererBackend::ArylicTcp(r) => r.set_index(index),
MusicRendererBackend::Chromecast(cc) => cc.set_index(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.set_index(index),
}
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.replace_queue(items, current_index),
MusicRendererBackend::OpenHome(r) => r.replace_queue(items, current_index),
MusicRendererBackend::LinkPlay(r) => r.replace_queue(items, current_index),
MusicRendererBackend::ArylicTcp(r) => r.replace_queue(items, current_index),
MusicRendererBackend::Chromecast(cc) => cc.replace_queue(items, current_index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
upnp.replace_queue(items, current_index)
}
}
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
upnp.sync_queue(items, cancel_token, on_ready)
}
}
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.get_item(index),
MusicRendererBackend::OpenHome(r) => r.get_item(index),
MusicRendererBackend::LinkPlay(r) => r.get_item(index),
MusicRendererBackend::ArylicTcp(r) => r.get_item(index),
MusicRendererBackend::Chromecast(cc) => cc.get_item(index),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.get_item(index),
}
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.replace_item(index, item),
MusicRendererBackend::OpenHome(r) => r.replace_item(index, item),
MusicRendererBackend::LinkPlay(r) => r.replace_item(index, item),
MusicRendererBackend::ArylicTcp(r) => r.replace_item(index, item),
MusicRendererBackend::Chromecast(cc) => cc.replace_item(index, item),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.replace_item(index, item),
}
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.enqueue_items(items, mode),
MusicRendererBackend::OpenHome(r) => r.enqueue_items(items, mode),
MusicRendererBackend::LinkPlay(r) => r.enqueue_items(items, mode),
MusicRendererBackend::ArylicTcp(r) => r.enqueue_items(items, mode),
MusicRendererBackend::Chromecast(cc) => cc.enqueue_items(items, mode),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.enqueue_items(items, mode),
}
dispatch_upnp!(self, play_from_index(index))
}
}

View File

@@ -2,8 +2,8 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::SystemTime;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::time_utils::{format_hhmmss_u32, parse_time_flexible};
use crate::DeviceIdentity;
@@ -15,6 +15,7 @@ use crate::music_renderer::openhome::{
build_info_client, build_playlist_client, build_product_client, build_radio_client,
build_time_client, build_volume_client,
};
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{
@@ -88,7 +89,7 @@ impl OpenHomeRenderer {
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
pub fn has_playlist(&self) -> bool {
@@ -175,14 +176,14 @@ impl OpenHomeRenderer {
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
pub(crate) fn openhome_playlist_len(&self) -> Result<usize, ControlPointError> {
// Use queue.len() which uses cached track_ids() internally
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock().expect("queue mutex poisoned");
queue.len()
}
/// Retourne les IDs des pistes de la playlist OpenHome.
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock().expect("queue mutex poisoned");
if let Some(oh_queue) = queue.as_openhome() {
oh_queue.track_ids()
} else {
@@ -208,7 +209,7 @@ impl OpenHomeRenderer {
let insert_after = match after_id {
Some(id) => id,
None => {
let queue = self.queue.lock().unwrap();
let queue = self.queue.lock().expect("queue mutex poisoned");
if let Some(oh_queue) = queue.as_openhome() {
oh_queue
.track_ids()?
@@ -266,11 +267,6 @@ impl RendererFromMediaRendererInfo for OpenHomeRenderer {
}
}
impl RendererBackend for OpenHomeRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl TransportControl for OpenHomeRenderer {
fn play_uri(&self, uri: &str, meta: &str) -> Result<(), ControlPointError> {
@@ -369,7 +365,7 @@ impl PlaybackPosition for OpenHomeRenderer {
// Check cache first
{
let mut cache = self.position_cache.lock().unwrap();
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
// Track calls for warning detection
cache.calls_in_last_second.push(now);
@@ -410,7 +406,7 @@ impl PlaybackPosition for OpenHomeRenderer {
let mut track_metadata_xml = None;
// Get track ID from queue (uses cached data)
let queue_guard_for_id = self.queue.lock().unwrap();
let queue_guard_for_id = self.queue.lock().expect("queue mutex poisoned");
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
match oh_queue.current_track() {
Ok(id_opt) => track_id = id_opt,
@@ -423,7 +419,7 @@ impl PlaybackPosition for OpenHomeRenderer {
drop(queue_guard_for_id);
// Use queue API to get current item with cached metadata
let mut queue_guard = self.queue.lock().unwrap();
let mut queue_guard = self.queue.lock().expect("queue mutex poisoned");
if let Ok(Some((current_item, _))) = queue_guard.peek_current() {
// Use metadata from queue cache (updated via OpenHome events)
track_uri = Some(current_item.uri.clone());
@@ -440,7 +436,7 @@ impl PlaybackPosition for OpenHomeRenderer {
}
// Check if the URI has changed to detect track changes
let mut cached_uri = self.current_track_uri.lock().unwrap();
let mut cached_uri = self.current_track_uri.lock().expect("current_track_uri mutex poisoned");
let uri_changed = cached_uri.as_ref() != Some(&current_item.uri);
if uri_changed {
@@ -452,7 +448,7 @@ impl PlaybackPosition for OpenHomeRenderer {
// Détecte si la nouvelle URL est un flux continu
let is_stream = crate::music_renderer::is_continuous_stream_url(&current_item.uri);
*self.continuous_stream.lock().unwrap() = is_stream;
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned") = is_stream;
tracing::debug!("OpenHome URI changed, continuous_stream={}", is_stream);
*cached_uri = Some(current_item.uri.clone());
@@ -488,7 +484,7 @@ impl PlaybackPosition for OpenHomeRenderer {
// Update cache with fresh data
{
let mut cache = self.position_cache.lock().unwrap();
let mut cache = self.position_cache.lock().expect("position_cache mutex poisoned");
cache.last_position = Some(position_info.clone());
cache.last_update = Some(now);
}
@@ -496,28 +492,6 @@ impl PlaybackPosition for OpenHomeRenderer {
Ok(position_info)
}
}
/// Parse duration from DIDL-Lite metadata XML (OpenHome version)
#[allow(dead_code)]
fn parse_didl_duration_openhome(didl: &str) -> Option<String> {
// Search for duration attribute in <res> element
let res_start = didl.find("<res ")?;
let after_res = &didl[res_start..];
let tag_close = after_res.find('>')?;
let tag_attrs = &after_res[..tag_close];
if let Some(duration_start) = tag_attrs.find("duration=\"") {
let duration_offset = duration_start + "duration=\"".len();
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
return Some(duration.to_string());
}
}
tracing::debug!("OpenHome: No duration found in DIDL metadata");
None
}
pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
match raw.trim().to_ascii_uppercase().as_str() {
"PLAYING" => PlaybackState::Playing,
@@ -529,6 +503,11 @@ pub(crate) fn map_openhome_state(raw: &str) -> PlaybackState {
}
impl QueueTransportControl for OpenHomeRenderer {
fn play_item(&self, _item: &PlaybackItem) -> Result<(), ControlPointError> {
let playlist = self.playlist_client_for("play_item")?;
playlist.play()
}
fn play_from_queue(&self) -> Result<(), ControlPointError> {
{
let queue = self
@@ -553,50 +532,6 @@ impl QueueTransportControl for OpenHomeRenderer {
playlist.play()
}
fn play_next(&self) -> Result<(), ControlPointError> {
{
let mut queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
let len = queue.len().unwrap_or(0);
let current = queue.current_index().ok().flatten();
let current_track_id = queue.current_track().ok().flatten();
let all_ids = queue.track_ids().ok().unwrap_or_default();
tracing::trace!(
queue_len = len,
current_index = ?current,
current_track_id = ?current_track_id,
all_track_ids = ?all_ids,
"OpenHome play_next: advancing queue"
);
if !queue.advance()? {
tracing::trace!(
queue_len = len,
current_index = ?current,
"OpenHome play_next: advance() returned false — no next track"
);
return Err(ControlPointError::QueueError("No next track".into()));
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self
.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?;
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// For OpenHome, we need to convert index to track_id
let track_id = {
@@ -626,90 +561,37 @@ impl QueueTransportControl for OpenHomeRenderer {
}
}
impl QueueBackend for OpenHomeRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.len()
impl HasQueue for OpenHomeRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.track_ids()
impl HasContinuousStream for OpenHomeRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.set_index(index)
}
fn replace_queue(
impl OpenHomeRenderer {
pub fn replace_queue_with_background(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
// ✅ CORRECTION BUG PRODUCTION: On ne charge PAS toutes les métadonnées
// dans le thread principal. OpenHome sur 1000 titres inondait la base SQLite
// et bloquait TOUS les autres threads (mutex >500ms).
//
// On fait juste l'insertion minimaliste maintenant. Le préchargement
// des métadonnées est délégué à un thread background.
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
.replace_queue(items, current_index)?;
// Background worker: charge les métadonnées petit à petit sans bloquer personne
let queue = self.queue.clone();
std::thread::spawn(move || {
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
if let Ok(mut queue) = queue.lock() {
// On ne fait que les 10 prochains titres maintenant, le reste on s'en fout
if let Ok(Some(idx)) = queue.current_index() {
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
for i in idx..end {
let _ = queue.get_item(i);
// Petit délai pour ne pas noyer la base de données
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
@@ -719,41 +601,4 @@ impl QueueBackend for OpenHomeRenderer {
Ok(())
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.sync_queue(items, cancel_token, on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.enqueue_items(items, mode)
}
}

View File

@@ -53,7 +53,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
// Check cache first
{
let cache = STREAM_CACHE.lock().unwrap();
let cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
if let Some(&cached_result) = cache.get(url) {
trace!("Cache hit for {}: is_stream={}", url, cached_result);
return cached_result;
@@ -62,7 +62,7 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
// Check if already being verified
{
let mut pending = PENDING_CHECKS.lock().unwrap();
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
if pending.contains(url) {
debug!(
"Stream detection already in progress for {}, returning false temporarily",
@@ -93,13 +93,13 @@ pub fn is_continuous_stream_url(url: &str) -> bool {
// Store in cache
{
let mut cache = STREAM_CACHE.lock().unwrap();
let mut cache = STREAM_CACHE.lock().expect("stream cache mutex poisoned");
cache.insert(url_owned.clone(), result);
}
// Remove from pending
{
let mut pending = PENDING_CHECKS.lock().unwrap();
let mut pending = PENDING_CHECKS.lock().expect("pending checks mutex poisoned");
pending.remove(&url_owned);
}
@@ -185,12 +185,27 @@ fn check_stream_headers(url: &str) -> Result<bool, String> {
trace!(
"Stream detection for {}: content-length={}, chunked={}, streaming_mime={}, is_stream={}",
url, has_content_length, is_chunked, is_streaming_mime, is_stream
url,
has_content_length,
is_chunked,
is_streaming_mime,
is_stream
);
Ok(is_stream)
}
/// Canonical check: returns `true` if this item should be treated as a continuous stream.
///
/// Checks `metadata.is_continuous_stream` first (already computed at ingest time),
/// then falls back to the URL-based HTTP detection.
///
/// Use this function everywhere transport-layer code needs to decide whether playback is
/// a continuous stream (radio) vs bounded media (file/album track).
pub fn is_continuous_stream(metadata: Option<&crate::model::TrackMetadata>, uri: &str) -> bool {
metadata.map(|m| m.is_continuous_stream).unwrap_or(false) || is_continuous_stream_url(uri)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -3,10 +3,13 @@ use std::sync::{atomic::AtomicBool, Arc, Mutex};
use crate::errors::ControlPointError;
use crate::model::PlaybackState;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
HasContinuousStream, PlaybackPosition, PlaybackPositionInfo, PlaybackStatus,
QueueTransportControl, TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
use crate::music_renderer::musicrenderer::{
build_didl_lite_metadata, parse_didl_duration, MusicRendererBackend,
};
use crate::music_renderer::HasQueue;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{
@@ -114,7 +117,7 @@ impl UpnpRenderer {
/// Returns true if currently playing a continuous stream (radio without duration)
pub fn is_continuous_stream(&self) -> bool {
*self.continuous_stream.lock().unwrap()
*self.continuous_stream.lock().expect("continuous_stream mutex poisoned")
}
}
@@ -174,80 +177,27 @@ impl RendererFromMediaRendererInfo for UpnpRenderer {
}
}
impl RendererBackend for UpnpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueTransportControl for UpnpRenderer {
fn play_from_queue(&self) -> Result<(), ControlPointError> {
let mut queue = self.queue.lock().unwrap();
// Get or initialize current index
let current_index = match queue.current_index()? {
Some(idx) => idx,
None => {
if queue.len()? > 0 {
queue.set_index(Some(0))?;
0
} else {
return Err(ControlPointError::QueueError("Queue is empty".into()));
}
}
};
// Get the item
let item = queue
.get_item(current_index)?
.ok_or_else(|| ControlPointError::QueueError("Current item not found".into()))?;
drop(queue);
// Build metadata - handle optional TrackMetadata
fn play_item(&self, item: &PlaybackItem) -> Result<(), ControlPointError> {
let metadata = if let Some(ref track_metadata) = item.metadata {
build_didl_lite_metadata(track_metadata, &item.uri, &item.protocol_info)
} else {
// Fallback to minimal DIDL-Lite if no metadata
format!(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><item id="0" parentID="-1" restricted="1"><res protocolInfo="{}">{}</res></item></DIDL-Lite>"#,
item.protocol_info, item.uri
)
};
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
*self.continuous_stream.lock().unwrap() = is_stream;
// Log current queue state for debugging
let queue_state = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
let uri = item.uri.clone();
let title = item.metadata.as_ref().and_then(|m| m.title.clone());
(idx, len, uri, title)
};
tracing::debug!(
"UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
queue_state.0,
queue_state.1,
queue_state.2,
queue_state.3,
is_stream
);
// Parse et cache la durée du DIDL (fallback pour certains amplis)
let duration = parse_didl_duration(&metadata);
if let Some(ref dur) = duration {
tracing::debug!("Caching duration from queue DIDL: {}", dur);
*self.cached_duration.lock().unwrap() = Some(dur.clone());
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
} else {
tracing::debug!("No duration to cache from queue DIDL");
*self.cached_duration.lock().unwrap() = None;
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
}
// UPNP: SetAVTransportURI + Play
let avt = self.avtransport()?;
avt.set_av_transport_uri(&item.uri, &metadata)?;
avt.play(0, "1")?;
@@ -255,160 +205,19 @@ impl QueueTransportControl for UpnpRenderer {
Ok(())
}
fn play_next(&self) -> Result<(), ControlPointError> {
let current_idx = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
tracing::debug!(
current_index = ?idx,
queue_len = len,
"play_next: attempting to advance"
);
idx
};
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
let new_idx = queue.current_index().unwrap_or(None);
tracing::debug!(
previous_index = ?current_idx,
new_index = ?new_idx,
"play_next: advanced"
);
}
}
self.play_from_queue()
}
fn play_previous(&self) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
if !queue.rewind()? {
return Err(ControlPointError::QueueError("No previous track".into()));
}
}
self.play_from_queue()
}
fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
{
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
// CORRECTIF: Quand on change l'index manuellement (shuffle, sélection d'un titre)
// on logue pour être sûr que c'est bien appelé
tracing::debug!(index = index, "✅ SHUFFLE / SEEK: play_from_index appelé");
self.play_from_queue()
impl HasQueue for UpnpRenderer {
fn queue(&self) -> &Arc<Mutex<MusicQueue>> {
&self.queue
}
}
impl QueueBackend for UpnpRenderer {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue.lock().unwrap().track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue.lock().unwrap().id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue.lock().unwrap().position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue.lock().unwrap().current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue.lock().unwrap().current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue.lock().unwrap().queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue.lock().unwrap().get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().enqueue_items(items, mode)
impl HasContinuousStream for UpnpRenderer {
fn continuous_stream(&self) -> &Arc<Mutex<bool>> {
&self.continuous_stream
}
}
/// Parse le DIDL-Lite pour extraire la durée du premier élément <res>
fn parse_didl_duration(didl: &str) -> Option<String> {
// Recherche de l'élément <res> (avec ou sans espace après)
let res_start = didl
.find("<res ")
.or_else(|| didl.find("<res>"))
.or_else(|| didl.find("<res\n"))
.or_else(|| didl.find("<res\t"))?;
let after_res = &didl[res_start..];
// Recherche de l'attribut duration dans cet élément <res>
// Il doit être avant la fermeture du tag (avant '>')
if let Some(tag_close) = after_res.find('>') {
let tag_attrs = &after_res[..tag_close];
if let Some(duration_start) = tag_attrs.find("duration=\"") {
let duration_offset = duration_start + "duration=\"".len();
if let Some(duration_end) = tag_attrs[duration_offset..].find('"') {
let duration = &tag_attrs[duration_offset..duration_offset + duration_end];
return Some(duration.to_string());
}
}
}
tracing::warn!("No duration attribute found in DIDL <res> element");
None
}
/// Implémentation UPnP AV de `TransportControl` pour [`UpnpRenderer`].
///
/// Cette impl se base sur AVTransport (InstanceID = 0).
@@ -422,23 +231,14 @@ impl TransportControl for UpnpRenderer {
);
}
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
let is_stream = crate::music_renderer::is_continuous_stream_url(uri);
*self.continuous_stream.lock().unwrap() = is_stream;
tracing::debug!(
"UpnpRenderer play_uri: URI={}, continuous_stream={}",
uri,
is_stream
);
// Parse le DIDL pour extraire la durée (fallback pour certains amplis)
let duration = parse_didl_duration(meta);
if let Some(ref dur) = duration {
tracing::debug!("Caching duration from DIDL: {}", dur);
*self.cached_duration.lock().unwrap() = Some(dur.clone());
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = Some(dur.clone());
} else {
tracing::debug!("No duration to cache from DIDL");
*self.cached_duration.lock().unwrap() = None;
*self.cached_duration.lock().expect("cached_duration mutex poisoned") = None;
}
let avt = self.avtransport()?;
@@ -517,57 +317,30 @@ impl PlaybackPosition for UpnpRenderer {
);
// Normalize "00:00:00" or "0:00:00" to None (some renderers return this for unknown duration)
let normalized_duration = raw.track_duration.as_ref().and_then(|d| {
if d == "00:00:00" || d == "0:00:00" {
None
} else {
Some(d.clone())
}
let track_duration = raw.track_duration.as_ref().and_then(|d| {
if d == "00:00:00" || d == "0:00:00" { None } else { Some(d.clone()) }
});
let track_duration = normalized_duration;
// Récupérer les métadonnées depuis la queue (avec protection contre diminution de durée)
// plutôt que depuis GetPositionInfo qui peut retourner des métadonnées obsolètes
let mut track_metadata_xml = None;
let mut track_uri = raw.track_uri.clone();
let mut queue_guard = self.queue.lock().unwrap();
// Récupérer l'item courant de la queue
// Normalement current_index est toujours Some() si la queue n'est pas vide (règle métier)
let queue_item = queue_guard.peek_current().ok().flatten();
if let Some((current_item, _)) = queue_item {
track_uri = Some(current_item.uri.clone());
// Build DIDL metadata XML from cached/protected TrackMetadata
if let Some(ref metadata) = current_item.metadata {
track_metadata_xml = Some(
crate::music_renderer::musicrenderer::build_didl_lite_metadata(
metadata,
&current_item.uri,
&current_item.protocol_info,
),
);
}
}
drop(queue_guard);
tracing::trace!(
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
track_duration,
raw.rel_time,
track_metadata_xml.is_some()
);
Ok(PlaybackPositionInfo {
let mut position = PlaybackPositionInfo {
track: Some(raw.track),
rel_time: raw.rel_time,
abs_time: raw.abs_time,
track_duration,
track_metadata: track_metadata_xml,
track_uri,
})
track_metadata: None,
track_uri: raw.track_uri,
};
// Replace device metadata with queue metadata (queue is authoritative and protected
// against stale / decreasing durations for streams).
crate::music_renderer::musicrenderer::enrich_position_from_queue(self, &mut position);
tracing::trace!(
"UPnP playback_position: track_duration={:?}, rel_time={:?}, using_queue_metadata={}",
position.track_duration,
position.rel_time,
position.track_metadata.is_some()
);
Ok(position)
}
}

View File

@@ -2211,7 +2211,7 @@ async fn browse_container(
title: e.title,
class: e.class,
is_container: e.is_container,
child_count: None,
child_count: e.child_count,
artist: e.artist,
album: e.album,
album_art_uri,
@@ -2496,7 +2496,7 @@ async fn search_server(
title: e.title,
class: e.class,
is_container: e.is_container,
child_count: None,
child_count: e.child_count,
artist: e.artist,
album: e.album,
album_art_uri: e.album_art_uri,

View File

@@ -28,8 +28,85 @@
//! - This identity is used by the sync helpers to preserve the current
//! track across queue rebuilds when the MediaServer content changes.
use crate::music_renderer::HasQueue;
use crate::queue::MusicQueue;
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
use std::sync::{atomic::AtomicBool, Arc};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
/// Blanket implementation of QueueBackend for types that have a queue.
/// All methods simply delegate to the underlying MusicQueue.
impl<T: HasQueue> QueueBackend for T {
fn len(&self) -> Result<usize, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").len()
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").track_ids()
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").id_to_position(id)
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").position_to_id(id)
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").current_track()
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").current_index()
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").queue_snapshot()
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").set_index(index)
}
fn replace_queue(
&mut self,
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
self.queue()
.lock()
.unwrap()
.replace_queue(items, current_index)
}
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue()
.lock()
.unwrap()
.sync_queue(items, cancel_token, on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").get_item(index)
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").replace_item(index, item)
}
fn enqueue_items(
&mut self,
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
self.queue().lock().expect("queue mutex poisoned").enqueue_items(items, mode)
}
}
/// High-level enqueue mode.
///

View File

@@ -19,7 +19,7 @@ use crate::{errors::ControlPointError, RendererInfo};
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
/// Used to protect stream durations from decreasing for the same track.
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
pub(crate) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),
@@ -30,7 +30,7 @@ pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
}
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
pub(crate) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),

View File

@@ -106,7 +106,7 @@ impl MusicQueue {
on_complete: Box<dyn Fn(usize) + Send + 'static>,
) -> SyncScheduleOutcome {
let (sync_in_progress, sync_pending, sync_cancel_token) = {
let q = queue_arc.lock().unwrap();
let q = queue_arc.lock().expect("MusicQueue mutex poisoned");
(
Arc::clone(&q.sync_in_progress),
Arc::clone(&q.sync_pending),
@@ -129,6 +129,14 @@ impl MusicQueue {
thread::Builder::new()
.name(thread_name)
.spawn(move || {
// Protocol for the three AtomicBools:
// sync_in_progress : set to true before spawn, cleared on Drop via Guard.
// sync_pending : set to true by a concurrent caller that arrives while
// a sync is already running. The worker re-fetches items
// and loops when it detects this flag on exit.
// sync_cancel_token: set to true when a new sync request interrupts an
// in-progress one. Passed into QueueBackend::sync_queue
// so it can abort early.
struct Guard(Arc<AtomicBool>);
impl Drop for Guard {
fn drop(&mut self) {
@@ -136,113 +144,137 @@ impl MusicQueue {
}
}
let _guard = Guard(Arc::clone(&sync_in_progress));
let mut current_items = items;
let mut current_on_ready = Some(on_ready);
let mut on_complete = Some(on_complete);
// Error policy: sync errors are logged by sync_worker_loop (warn level) and
// the thread exits normally. No restart — a new sync can be scheduled via
// schedule_sync. The Guard Drop clears sync_in_progress unconditionally,
// even on panic, keeping the AtomicBool protocol consistent.
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
loop {
sync_pending.store(false, SeqCst);
sync_cancel_token.store(false, SeqCst);
// Extract the real on_ready BEFORE locking the queue.
// on_ready may call play_from_queue() which re-locks the queue,
// so we must NOT call it while holding queue_arc.
let real_on_ready = current_on_ready.take().flatten();
let on_ready_triggered = Arc::new(AtomicBool::new(false));
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
real_on_ready.as_ref().map(|_| {
let flag = Arc::clone(&on_ready_triggered);
Box::new(move || {
flag.store(true, SeqCst);
}) as Box<dyn FnOnce() + Send + 'static>
});
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
items = current_items.len(),
has_on_ready = real_on_ready.is_some(),
"queue-sync: calling sync_queue"
);
let result = {
let mut q = queue_arc.lock().unwrap();
<MusicQueue as QueueBackend>::sync_queue(
&mut q,
current_items,
&sync_cancel_token,
proxy_on_ready,
)
};
// Queue lock is released here.
// Now safe to call on_ready (which may re-lock the queue).
// If on_ready was triggered by the proxy, consume and call it.
// If not (cancelled before first insert), keep it to pass to retry.
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready triggered, calling callback"
);
if let Some(f) = real_on_ready {
f();
}
None
} else {
if real_on_ready.is_some() {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready not triggered, carrying to next attempt"
);
}
real_on_ready
};
match result {
Err(ControlPointError::SyncCancelled) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: cancelled"
);
}
Err(e) => {
tracing::warn!("queue-sync error: {}", e);
}
Ok(()) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: completed successfully"
);
if let Some(cb) = on_complete.take() {
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
cb(queue_len);
}
}
}
if !sync_pending.load(SeqCst) {
break;
}
match pending_items_fn() {
Ok(new_items) => {
current_items = new_items;
current_on_ready = Some(carry_on_ready);
}
Err(e) => {
tracing::warn!("queue-sync pending re-fetch error: {}", e);
break;
}
}
}
Self::sync_worker_loop(
queue_arc,
items,
pending_items_fn,
on_ready,
on_complete,
sync_pending,
sync_cancel_token,
);
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
})
.expect("Failed to spawn queue-sync thread");
SyncScheduleOutcome::Scheduled
}
/// Inner loop executed by the sync worker thread.
///
/// Runs at least once with `initial_items`. If a new sync request arrives while the
/// loop is running (`sync_pending` becomes true), it re-fetches items via
/// `pending_items_fn` and iterates again, allowing the latest playlist state to win.
fn sync_worker_loop(
queue_arc: Arc<Mutex<MusicQueue>>,
initial_items: Vec<PlaybackItem>,
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send>,
initial_on_ready: Option<Box<dyn FnOnce() + Send>>,
on_complete: Box<dyn Fn(usize) + Send>,
sync_pending: Arc<AtomicBool>,
sync_cancel_token: Arc<AtomicBool>,
) {
let mut current_items = initial_items;
let mut current_on_ready = Some(initial_on_ready);
let mut on_complete = Some(on_complete);
loop {
sync_pending.store(false, SeqCst);
sync_cancel_token.store(false, SeqCst);
// Extract the real on_ready BEFORE locking the queue.
// on_ready may call play_from_queue() which re-locks the queue,
// so we must NOT call it while holding queue_arc.
let real_on_ready = current_on_ready.take().flatten();
let on_ready_triggered = Arc::new(AtomicBool::new(false));
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
real_on_ready.as_ref().map(|_| {
let flag = Arc::clone(&on_ready_triggered);
Box::new(move || {
flag.store(true, SeqCst);
}) as Box<dyn FnOnce() + Send + 'static>
});
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
items = current_items.len(),
has_on_ready = real_on_ready.is_some(),
"queue-sync: calling sync_queue"
);
let result = {
let mut q = queue_arc.lock().expect("MusicQueue mutex poisoned");
<MusicQueue as QueueBackend>::sync_queue(
&mut q,
current_items,
&sync_cancel_token,
proxy_on_ready,
)
};
// Queue lock is released here.
// Now safe to call on_ready (which may re-lock the queue).
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready triggered, calling callback"
);
if let Some(f) = real_on_ready {
f();
}
None
} else {
if real_on_ready.is_some() {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready not triggered, carrying to next attempt"
);
}
real_on_ready
};
match result {
Err(ControlPointError::SyncCancelled) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: cancelled"
);
}
Err(e) => {
tracing::warn!("queue-sync error: {}", e);
}
Ok(()) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: completed successfully"
);
if let Some(cb) = on_complete.take() {
let queue_len = queue_arc.lock().expect("MusicQueue mutex poisoned").len().unwrap_or(0);
cb(queue_len);
}
}
}
if !sync_pending.load(SeqCst) {
break;
}
match pending_items_fn() {
Ok(new_items) => {
current_items = new_items;
current_on_ready = Some(carry_on_ready);
}
Err(e) => {
tracing::warn!("queue-sync pending re-fetch error: {}", e);
break;
}
}
}
}
}
impl QueueBackend for MusicQueue {

View File

@@ -233,16 +233,16 @@ impl OpenHomeQueue {
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
fn invalidate_track_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
}
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
fn invalidate_all_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.uri_by_id.lock().unwrap().clear();
self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned").invalidate();
self.read_list_cache.lock().expect("read_list_cache mutex poisoned").invalidate();
self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned").invalidate();
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").clear();
}
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
@@ -390,8 +390,8 @@ impl OpenHomeQueue {
new_metadata: Option<crate::model::TrackMetadata>,
uri: &str,
) {
let mut cache = self.metadata_cache.lock().unwrap();
let mut uri_cache = self.uri_by_id.lock().unwrap();
let mut cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
// Update URI cache
if !uri.is_empty() {
@@ -481,7 +481,7 @@ impl OpenHomeQueue {
// Le cache contient les métadonnées stables mises lors de l'insertion
// Les métadonnées de l'entry (venant de ReadList) changent pour les streams
let metadata = {
let cache = self.metadata_cache.lock().unwrap();
let cache = self.metadata_cache.lock().expect("metadata_cache mutex poisoned");
if let Some(cached_meta) = cache.get(&entry.id) {
// Utiliser les métadonnées stables du cache
tracing::trace!(
@@ -557,7 +557,7 @@ impl OpenHomeQueue {
}
if track_id as usize != playing_id {
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
}
}
@@ -616,7 +616,7 @@ impl OpenHomeQueue {
track_id
);
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
}
}
Ok(())
@@ -858,7 +858,7 @@ impl OpenHomeQueue {
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
}
} else {
for idx in (0..current_track_ids.len()).rev() {
@@ -868,7 +868,7 @@ impl OpenHomeQueue {
if !keep_current[idx] {
let track_id = current_track_ids[idx];
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
}
}
}
@@ -1099,7 +1099,7 @@ impl QueueBackend for OpenHomeQueue {
self.ensure_playlist_source_selected()?;
// Lock the cache for the entire operation to prevent race conditions
let mut cache = self.track_ids_cache.lock().unwrap();
let mut cache = self.track_ids_cache.lock().expect("track_ids_cache mutex poisoned");
// Check if cache is valid
if let Some(cached_ids) = cache.get() {
@@ -1147,7 +1147,7 @@ impl QueueBackend for OpenHomeQueue {
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
// Hold lock during entire operation to prevent race conditions
let mut cache = self.current_track_id_cache.lock().unwrap();
let mut cache = self.current_track_id_cache.lock().expect("current_track_id_cache mutex poisoned");
// Return cached value if valid
if let Some(cached_id) = cache.get() {
@@ -1192,7 +1192,7 @@ impl QueueBackend for OpenHomeQueue {
const MAX_BATCH: usize = 256;
let mut entries = Vec::with_capacity(ids.len());
for chunk in ids.chunks(MAX_BATCH) {
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
if let Some(cached) = self.read_list_cache.lock().expect("read_list_cache mutex poisoned").get(chunk) {
trace!(
renderer = self.renderer_id.0.as_str(),
"ReadList cache hit for {} IDs",
@@ -1283,7 +1283,7 @@ impl QueueBackend for OpenHomeQueue {
self.ensure_playlist_source_selected()?;
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
// Invalidate caches after delete_all (clears queue and current track)
self.invalidate_all_caches();
@@ -1340,7 +1340,7 @@ impl QueueBackend for OpenHomeQueue {
"sync_queue: Empty playlist - clearing queue with delete_all"
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").clear();
self.invalidate_all_caches();
let post_current_track = self.playlist_client.id().ok();
@@ -1364,7 +1364,7 @@ impl QueueBackend for OpenHomeQueue {
.last()
.copied()
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
let mut uri_cache = self.uri_by_id.lock().unwrap();
let mut uri_cache = self.uri_by_id.lock().expect("uri_by_id mutex poisoned");
for item in &new_items {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
@@ -1583,8 +1583,8 @@ impl QueueBackend for OpenHomeQueue {
.insert(before_id, &item.uri, &metadata)?;
// Mettre à jour le cache avec les nouvelles métadonnées
self.metadata_cache.lock().unwrap().remove(&track_id);
self.uri_by_id.lock().unwrap().remove(&track_id);
self.metadata_cache.lock().expect("metadata_cache mutex poisoned").remove(&track_id);
self.uri_by_id.lock().expect("uri_by_id mutex poisoned").remove(&track_id);
self.cache_metadata(new_id, item.metadata, &item.uri);
if ci == Some(index) {

View File

@@ -4,13 +4,18 @@ use std::time::Duration;
use anyhow::{Context, Result};
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
use tracing::{debug, trace, warn};
use tracing::{debug, trace, warn, info};
use ureq::Agent;
use crate::errors::ControlPointError;
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
/// Number of retries for transient transport failures on control actions.
const SOAP_CONTROL_MAX_RETRIES: usize = 2;
/// Delay between retries (ms). Kept short: devices usually recover in <200 ms.
const SOAP_CONTROL_RETRY_DELAY_MS: u64 = 300;
fn get_soap_agent() -> Arc<Agent> {
SOAP_AGENT
.get_or_init(|| {
@@ -43,19 +48,44 @@ pub fn build_soap_body(
build_soap_request(service_type, action, args)
}
/// Invoke a UPnP SOAP action on a control URL.
/// Invoke a UPnP SOAP control action with automatic retry on transient failures.
///
/// - `control_url`: full HTTP URL of the service control endpoint
/// - `service_type`: service URN
/// - `action`: action name
/// - `args`: list of (name, value)
/// Use this for **control operations** (Play, Pause, Stop, SetAVTransportURI, …)
/// where a transient TCP error should be absorbed silently.
///
/// Retries are attempted only on [`ControlPointError::is_transient_soap_error`]
/// (i.e. transport-level failures). Protocol-level errors (UPnP faults,
/// HTTP 4xx/5xx with a valid body) propagate immediately without retry.
///
/// For **polling reads** (GetTransportInfo, GetPositionInfo, …) prefer
/// [`invoke_upnp_action_with_timeout`] directly so that a slow device does
/// not hold the watcher thread for multiple retry cycles.
pub fn invoke_upnp_action(
control_url: &str,
service_type: &str,
action: &str,
args: &[(&str, &str)],
) -> Result<SoapCallResult, ControlPointError> {
invoke_upnp_action_with_timeout(control_url, service_type, action, args, None)
let retry_delay = Duration::from_millis(SOAP_CONTROL_RETRY_DELAY_MS);
for attempt in 0..=SOAP_CONTROL_MAX_RETRIES {
match invoke_upnp_action_with_timeout(control_url, service_type, action, args, None) {
Ok(result) => return Ok(result),
Err(e) if e.is_transient_soap_error() && attempt < SOAP_CONTROL_MAX_RETRIES => {
info!(
url = control_url,
action = action,
attempt = attempt + 1,
error = %e,
"Transient SOAP error, retrying"
);
std::thread::sleep(retry_delay);
}
Err(e) => return Err(e),
}
}
// Unreachable: the loop either returns Ok or propagates Err above.
unreachable!()
}
pub fn invoke_upnp_action_with_timeout(

View File

@@ -12,7 +12,11 @@ use crate::{
use pmoupnp::soap::SoapEnvelope;
use xmltree::{Element, XMLNode};
/// Timeout for slow/long control actions (SetAVTransportURI, SetNextAVTransportURI).
const AVTRANSPORT_ACTION_TIMEOUT: Duration = Duration::from_secs(5);
/// Timeout for fast polling-read actions (GetTransportInfo, GetPositionInfo).
/// Must be well below the watcher short-interval (500 ms) to avoid cascading lateness.
const AVTRANSPORT_POLL_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone)]
pub struct AvTransportClient {
@@ -40,11 +44,12 @@ impl AvTransportClient {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
let call_result = invoke_upnp_action(
let call_result = invoke_upnp_action_with_timeout(
&self.control_url,
&self.service_type,
"GetTransportInfo",
&args,
Some(AVTRANSPORT_POLL_TIMEOUT),
)?;
if !call_result.status.is_success() {
@@ -381,11 +386,12 @@ impl AvTransportClient {
let instance_id_str = instance_id.to_string();
let args = [("InstanceID", instance_id_str.as_str())];
let call_result = invoke_upnp_action(
let call_result = invoke_upnp_action_with_timeout(
&self.control_url,
&self.service_type,
"GetPositionInfo",
&args,
Some(AVTRANSPORT_POLL_TIMEOUT),
)?;
if !call_result.status.is_success() {

View File

@@ -817,7 +817,7 @@ impl OhProductClient {
pub fn source_xml(&self) -> Result<Vec<OhProductSource>> {
// Lock the cache for the entire operation to prevent race conditions
let mut cache = self.source_xml_cache.lock().unwrap();
let mut cache = self.source_xml_cache.lock().expect("source_xml_cache mutex poisoned");
// Check if cache is valid
if let Some(cached_sources) = cache.get() {
@@ -841,7 +841,7 @@ impl OhProductClient {
pub fn source_index(&self) -> Result<u32, ControlPointError> {
// Lock the cache for the entire operation to prevent race conditions
let mut cache = self.source_index_cache.lock().unwrap();
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
// Check if cache is valid
if let Some(cached_index) = cache.get() {
@@ -881,7 +881,7 @@ impl OhProductClient {
// Invalidate cache after write operation
if result.is_ok() {
let mut cache = self.source_index_cache.lock().unwrap();
let mut cache = self.source_index_cache.lock().expect("source_index_cache mutex poisoned");
cache.invalidate();
}

View File

@@ -1,12 +1,17 @@
use std::time::Duration;
use crate::{
errors::ControlPointError,
soap_client::{
ensure_success, extract_child_text, find_child_with_suffix, handle_action_response,
invoke_upnp_action, parse_upnp_error,
invoke_upnp_action, invoke_upnp_action_with_timeout, parse_upnp_error,
},
};
use tracing::debug;
/// Timeout for fast polling-read actions (GetVolume, GetMute).
const RENDERING_CONTROL_POLL_TIMEOUT: Duration = Duration::from_secs(3);
#[derive(Debug, Clone)]
pub struct RenderingControlClient {
pub control_url: String,
@@ -30,8 +35,13 @@ impl RenderingControlClient {
("Channel", channel),
];
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "GetVolume", &args)?;
let call_result = invoke_upnp_action_with_timeout(
&self.control_url,
&self.service_type,
"GetVolume",
&args,
Some(RENDERING_CONTROL_POLL_TIMEOUT),
)?;
ensure_success("GetVolume", &call_result)?;
@@ -90,8 +100,13 @@ impl RenderingControlClient {
("Channel", channel),
];
let call_result =
invoke_upnp_action(&self.control_url, &self.service_type, "GetMute", &args)?;
let call_result = invoke_upnp_action_with_timeout(
&self.control_url,
&self.service_type,
"GetMute",
&args,
Some(RENDERING_CONTROL_POLL_TIMEOUT),
)?;
ensure_success("GetMute", &call_result)?;

2
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

View File

@@ -1 +1 @@
0.3.48
0.3.50