Merge pull request 'push-mkkrtyulmvrn' (#59) from push-mkkrtyulmvrn into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 28m44s

Reviewed-on: #59
This commit was merged in pull request #59.
This commit is contained in:
2026-01-17 09:17:52 +01:00
11 changed files with 387 additions and 11 deletions

View File

@@ -0,0 +1,41 @@
# Synthèse : bug_play_single_track
## Tâche originale
**Crates concernées** : pmocontrol, pmoapp/webapp, pmoqobuz
**Problème** : Lorsque dans le ServerDrawer on clique sur le bouton de lecture d'un item simple Qobuz, rien ne se produit.
**Comportement attendu** :
- Arrêt éventuel du renderer concerné
- Effacement et détachement de sa queue de lecture
- Ajout de la piste sélectionnée dans la queue de lecture
- Lancement de la lecture
---
## Résolution
### Cause racine
`QobuzSource` n'implémentait pas `get_item()`. Quand le ContentDirectory recevait un `BrowseMetadata` sur un track individuel (`qobuz:track:123`), il ne pouvait pas retourner les métadonnées avec une URL HTTP valide.
- **Albums** : `get_or_create_album_playlist_items()` génère des URLs HTTP via le cache (`http://base_url/audio/flac/QOBUZ:123`)
- **Tracks individuels** : URL symbolique `qobuz://track/123` non jouable par le renderer
### Solution
Implémentation de `get_item()` dans `QobuzSource` utilisant `add_track_lazy()` pour enregistrer le track dans le cache et retourner une URL HTTP absolue.
### Fichiers modifiés
| Fichier | Modification |
|---------|--------------|
| `pmoqobuz/src/source.rs` | Ajout de `get_item()` dans l'impl `MusicSource` |
| `pmoqobuz/src/didl.rs` | `format_duration()` rendue publique |
---
## Statut
**Résolu** - Testé et validé.

View File

@@ -0,0 +1,90 @@
# Bug transfert de queue entre renderers - RESOLU
## Tâche originale
**Crate concernée** : pmocontrol
**Problème rapporté** : Le transfert de la queue de lecture d'un renderer vers un autre ne semblait plus fonctionnel, avec des comportements erratiques. Le problème était potentiellement lié aux derniers changements sur le passage du renderer à un état stateless. L'interface utilisateur mettait également un certain temps à réagir.
---
## Synthèse de la résolution
### Cause racine
Dans la fonction `transfer_queue()` (`control_point.rs:1192-1291`), lorsqu'un binding de playlist existait sur le renderer source, le code appelait `attach_queue_to_playlist()` sur la destination **après** avoir rempli la queue avec les items sources.
Le problème : `attach_queue_to_playlist_internal()` effectue :
1. `clear_for_playlist_attach()` - efface la queue du renderer
2. `clear_queue()` - efface la queue locale
3. `refresh_attached_queue_for()` - browse le serveur et **remplace** la queue
Cela **écrasait complètement** les items transférés avec `replace_queue()`, perdant le `current_index` et la position de lecture.
### Séquence problématique (avant correction)
```
1. source_snapshot = get_renderer_queue_snapshot(source) // items + current_index
2. clear_renderer_queue(dest)
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie OK
4. attach_queue_to_playlist(dest, server, container) // ÉCRASE TOUT
└─> clear_for_playlist_attach()
└─> clear_queue()
└─> refresh_attached_queue_for() → browse serveur → replace queue
5. play() sur destination avec mauvaise queue
```
### Solution
Remplacement de l'appel `attach_queue_to_playlist()` par un transfert direct du binding sans déclencher de refresh :
```rust
// AVANT (problématique)
if let Some((server_id, container_id, _)) = source_binding {
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
}
// APRÈS (corrigé)
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
let binding = PlaylistBinding {
server_id,
container_id,
has_seen_update,
pending_refresh: false, // Pas de refresh immédiat
auto_play_on_refresh: false,
};
dest_renderer.set_playlist_binding(Some(binding));
}
```
### Séquence corrigée
```
1. source_snapshot = get_renderer_queue_snapshot(source)
2. clear_renderer_queue(dest)
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie OK
4. dest.set_playlist_binding(binding avec pending_refresh=false) // Binding transféré OK
5. play() sur destination avec bonne queue OK
```
### Analyse des événements
L'analyse a confirmé que les émissions d'événements étaient correctes :
| Méthode | Événement émis |
|---------|----------------|
| `replace_queue()` | `QueueUpdated` |
| `enqueue_items()` | `QueueUpdated` |
| `clear_queue()` | `QueueUpdated` |
| `set_playlist_binding()` | `BindingChanged` |
| `clear_playlist_binding()` | `BindingChanged` |
Le problème de lenteur UI était lié au fait que la queue était écrasée puis re-remplie, causant plusieurs événements successifs et une confusion dans l'état affiché.
### Fichier modifié
- `pmocontrol/src/control_point.rs` : Modification de `transfer_queue()` lignes 1223-1244
---
**Statut** : Corrigé et testé

View File

@@ -0,0 +1,37 @@
# Rapport : bug_play_single_track
## Résumé
Correction du bug empêchant la lecture d'un track Qobuz individuel depuis le ServerDrawer. La cause était l'absence d'implémentation de `get_item()` dans `QobuzSource`, résultant en des URLs symboliques non jouables.
## Travail effectué
1. **Analyse du flux** : Tracé du chemin depuis le clic sur le bouton play (frontend) jusqu'au backend pmocontrol
2. **Identification de la cause** : `QobuzSource` n'implémentait pas `get_item()`, donc les tracks individuels retournaient des URLs symboliques `qobuz://track/{id}` au lieu d'URLs HTTP
3. **Implémentation de la solution** : Ajout de `get_item()` utilisant le même mécanisme de cache lazy que les albums
## Fichiers modifiés
| Fichier | Modification |
|---------|--------------|
| `pmoqobuz/src/source.rs` | Ajout de `get_item()` dans l'impl `MusicSource`, import de `format_duration` |
| `pmoqobuz/src/didl.rs` | `format_duration()` rendue publique |
## Détails techniques
### Cause racine
- **Albums Qobuz** : `get_or_create_album_playlist_items()` crée une playlist avec URLs HTTP absolues (`http://base_url/audio/flac/QOBUZ:123`)
- **Tracks individuels** : `get_item()` non implémenté → fallback échoue → URL symbolique `qobuz://track/123` inutilisable par le renderer
### Solution
`get_item()` :
1. Parse l'object_id pour extraire le track_id
2. Récupère le track via l'API Qobuz
3. Enregistre le track dans le cache avec `add_track_lazy()`
4. Retourne un `Item` avec URL HTTP absolue : `http://base_url/audio/flac/QOBUZ:{track_id}`
## Statut
Bug résolu et testé.

View File

@@ -0,0 +1,91 @@
# Bug: Transfert de Queue entre Renderers
**Date**: 2026-01-17
**Fichier principal**: `pmocontrol/src/control_point.rs`
**Fonction affectée**: `transfer_queue()`
## Symptôme
Le transfert de la queue de lecture d'un renderer vers un autre ne fonctionnait plus correctement, avec des comportements erratiques. La queue transférée était écrasée et le current_index perdu.
## Cause Racine
Dans `transfer_queue()` (lignes 1192-1291), lorsqu'un binding de playlist existait sur le renderer source, le code appelait `attach_queue_to_playlist()` sur la destination après avoir rempli la queue.
**Problème**: `attach_queue_to_playlist_internal()` effectue les opérations suivantes:
1. `clear_for_playlist_attach()` - efface la queue du renderer
2. `clear_queue()` - efface la queue locale
3. `refresh_attached_queue_for()` - browse le serveur et remplace la queue
Cela **écrasait complètement** les items qu'on venait de transférer avec `replace_queue()`.
### Séquence problématique (avant correction)
```
1. source_snapshot = get_renderer_queue_snapshot(source) // items + current_index
2. clear_renderer_queue(dest)
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie ✓
4. attach_queue_to_playlist(dest, server, container) // ÉCRASE TOUT ✗
└─> clear_for_playlist_attach()
└─> clear_queue()
└─> refresh_attached_queue_for() → browse serveur → replace queue
5. play() sur destination avec mauvaise queue
```
## Correction Appliquée
Remplacement de l'appel `attach_queue_to_playlist()` par un transfert direct du binding sans déclencher de refresh:
```rust
// AVANT (problématique)
if let Some((server_id, container_id, _)) = source_binding {
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
}
// APRÈS (corrigé)
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
let binding = PlaylistBinding {
server_id,
container_id,
has_seen_update,
pending_refresh: false, // Pas de refresh immédiat
auto_play_on_refresh: false,
};
dest_renderer.set_playlist_binding(Some(binding));
}
```
### Séquence corrigée
```
1. source_snapshot = get_renderer_queue_snapshot(source)
2. clear_renderer_queue(dest)
3. dest.replace_queue(source_snapshot.items, current_index) // Queue remplie ✓
4. dest.set_playlist_binding(binding avec pending_refresh=false) // Binding transféré ✓
5. play() sur destination avec bonne queue ✓
```
## Événements
L'analyse a également confirmé que les émissions d'événements sont correctes:
| Méthode | Événement émis |
|---------|----------------|
| `replace_queue()` | `QueueUpdated` ✓ |
| `enqueue_items()` | `QueueUpdated` ✓ |
| `clear_queue()` | `QueueUpdated` ✓ |
| `set_playlist_binding()` | `BindingChanged` ✓ |
| `clear_playlist_binding()` | `BindingChanged` ✓ |
Le problème de lenteur UI mentionné était probablement lié au fait que la queue était écrasée puis re-remplie, causant plusieurs événements successifs et une confusion dans l'état affiché.
## Impact
- Transfert de queue fonctionnel à nouveau
- Préservation du current_index lors du transfert
- Binding de playlist correctement transféré sans perte de synchronisation
- UI réactive car un seul cycle d'événements cohérent
## Fichiers Modifiés
- `pmocontrol/src/control_point.rs`: Modification de `transfer_queue()` lignes 1223-1244

2
Cargo.lock generated
View File

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

View File

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

View File

@@ -1221,15 +1221,29 @@ impl ControlPoint {
dest_renderer dest_renderer
.replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?; .replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?;
// 4. Recreate playlist binding on destination if source had one // 4. Transfer playlist binding to destination if source had one
if let Some((server_id, container_id, _)) = source_binding { // IMPORTANT: We transfer the binding directly WITHOUT triggering a refresh.
// The queue items have already been copied in step 3, so we just need to
// set the binding so future server updates will sync to the destination.
// Using attach_queue_to_playlist() would clear the queue and re-fetch from server,
// losing the current playback position.
if let Some((server_id, container_id, has_seen_update)) = source_binding.clone() {
tracing::debug!( tracing::debug!(
dest = dest_renderer_id.0.as_str(), dest = dest_renderer_id.0.as_str(),
server = server_id.0.as_str(), server = server_id.0.as_str(),
container = container_id.as_str(), container = container_id.as_str(),
"Recreating playlist binding on destination renderer" "Transferring playlist binding to destination renderer (without refresh)"
); );
self.attach_queue_to_playlist(dest_renderer_id, server_id, container_id)?;
// Create binding with pending_refresh=false to avoid re-fetching
let binding = PlaylistBinding {
server_id,
container_id,
has_seen_update,
pending_refresh: false,
auto_play_on_refresh: false,
};
dest_renderer.set_playlist_binding(Some(binding));
} }
// 5. Stop playback on source renderer // 5. Stop playback on source renderer

View File

@@ -1599,9 +1599,22 @@ async fn play_content(
// The UI will be updated via SSE events when playback starts // The UI will be updated via SSE events when playback starts
tokio::task::spawn(async move { tokio::task::spawn(async move {
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
debug!(
renderer = rid.0.as_str(),
server = sid.0.as_str(),
object = object_id.as_str(),
"play_content: fetching playback items"
);
// Fetch playback items from server // Fetch playback items from server
let items = fetch_playback_items(&control_point, &sid, &object_id)?; let items = fetch_playback_items(&control_point, &sid, &object_id)?;
debug!(
renderer = rid.0.as_str(),
item_count = items.len(),
"play_content: fetched items"
);
if items.is_empty() { if items.is_empty() {
return Err(anyhow::anyhow!("No playable content found")); return Err(anyhow::anyhow!("No playable content found"));
} }
@@ -2144,8 +2157,16 @@ fn fetch_playback_items(
)); ));
} }
// Browse the object to get entries // First, get metadata for the object to determine if it's a container or item
let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?; let object_metadata = server.browse_object(object_id)?;
let entries = if object_metadata.is_container {
// For containers, browse children to get all items
server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?
} else {
// For items, use the object itself
vec![object_metadata]
};
debug!( debug!(
server_id = server_id.0.as_str(), server_id = server_id.0.as_str(),

View File

@@ -156,7 +156,7 @@ impl ToDIDL for Playlist {
} }
/// Formate une durée en secondes au format HH:MM:SS /// Formate une durée en secondes au format HH:MM:SS
fn format_duration(seconds: u32) -> String { pub fn format_duration(seconds: u32) -> String {
let hours = seconds / 3600; let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60; let minutes = (seconds % 3600) / 60;
let secs = seconds % 60; let secs = seconds % 60;

View File

@@ -4,7 +4,7 @@
//! providing a complete music catalog browsing and searching experience. //! providing a complete music catalog browsing and searching experience.
use crate::client::QobuzClient; use crate::client::QobuzClient;
use crate::didl::ToDIDL; use crate::didl::{format_duration, ToDIDL};
use crate::lazy_provider::QobuzLazyProvider; use crate::lazy_provider::QobuzLazyProvider;
use crate::models::Track; use crate::models::Track;
use pmoaudiocache::{AudioMetadata, Cache as AudioCache}; use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
@@ -1690,6 +1690,88 @@ impl MusicSource for QobuzSource {
.map_err(|e| MusicSourceError::UriResolutionError(e.to_string())) .map_err(|e| MusicSourceError::UriResolutionError(e.to_string()))
} }
async fn get_item(&self, object_id: &str) -> Result<Item> {
// Parse object_id to extract track ID
let track_id = match self.parse_object_id(object_id) {
ObjectIdType::Track(id) => id,
_ => {
return Err(MusicSourceError::ObjectNotFound(format!(
"Not a track: {}",
object_id
)))
}
};
// Get track from Qobuz API
let track = self
.inner
.client
.get_track(&track_id)
.await
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
// Register track in cache with lazy loading (same as albums)
let (_track_uri, cache_pk) = self.add_track_lazy(&track).await?;
// Build Item with HTTP URL pointing to cache
let parent_id = track
.album
.as_ref()
.map(|a| format!("qobuz:album:{}", a.id))
.unwrap_or_else(|| "qobuz".to_string());
// Get cover URL from cache if available
let cover_url = if let Some(ref album) = track.album {
if let Some(ref cached) = album.image_cached {
Some(format!("{}{}", self.inner.base_url, cached))
} else if let Some(ref image) = album.image {
// Try to cache it
if let Ok(pk) = self.inner.cache_manager.cache_cover(image).await {
Some(format!("{}/covers/jpeg/{}", self.inner.base_url, pk))
} else {
Some(image.clone())
}
} else {
None
}
} else {
None
};
// Build the resource with absolute HTTP URL
let audio_url = format!("{}/audio/flac/{}", self.inner.base_url, cache_pk);
let resource = pmodidl::Resource {
protocol_info: format!(
"http-get:*:{}:*",
track.mime_type.as_deref().unwrap_or("audio/flac")
),
bits_per_sample: track.bit_depth.map(|b| b.to_string()),
sample_frequency: track.sample_rate.map(|r| r.to_string()),
nr_audio_channels: track.channels.map(|c| c.to_string()),
duration: Some(format_duration(track.duration)),
url: audio_url,
};
Ok(Item {
id: format!("qobuz:track:{}", track.id),
parent_id,
restricted: Some("1".to_string()),
title: track.title.clone(),
creator: track.display_artist().map(|a| a.name.clone()),
class: "object.item.audioItem.musicTrack".to_string(),
artist: track.display_artist().map(|a| a.name.clone()),
album: track.album_name().map(|s| s.to_string()),
genre: None,
album_art: cover_url,
album_art_pk: None,
date: track.album.as_ref().and_then(|a| a.release_date.clone()),
original_track_number: Some(track.track_number.to_string()),
resources: vec![resource],
descriptions: Vec::new(),
})
}
fn supports_fifo(&self) -> bool { fn supports_fifo(&self) -> bool {
// Qobuz is a catalog, not a dynamic stream // Qobuz is a catalog, not a dynamic stream
false false

View File

@@ -1 +1 @@
0.3.6 0.3.7