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
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:
41
Blackboard/Done/bug_play_single_track.md
Normal file
41
Blackboard/Done/bug_play_single_track.md
Normal 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é.
|
||||
90
Blackboard/Done/bug_transfer_queue.md
Normal file
90
Blackboard/Done/bug_transfer_queue.md
Normal 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é
|
||||
37
Blackboard/Report/bug_play_single_track.md
Normal file
37
Blackboard/Report/bug_play_single_track.md
Normal 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é.
|
||||
91
Blackboard/Report/bug_transfer_queue.md
Normal file
91
Blackboard/Report/bug_transfer_queue.md
Normal 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
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.6"
|
||||
version = "0.3.7"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.6"
|
||||
version = "0.3.7"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -1221,15 +1221,29 @@ impl ControlPoint {
|
||||
dest_renderer
|
||||
.replace_queue(source_snapshot.items.clone(), source_snapshot.current_index)?;
|
||||
|
||||
// 4. Recreate playlist binding on destination if source had one
|
||||
if let Some((server_id, container_id, _)) = source_binding {
|
||||
// 4. Transfer playlist binding to destination if source had one
|
||||
// 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!(
|
||||
dest = dest_renderer_id.0.as_str(),
|
||||
server = server_id.0.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
|
||||
|
||||
@@ -1599,9 +1599,22 @@ async fn play_content(
|
||||
// The UI will be updated via SSE events when playback starts
|
||||
tokio::task::spawn(async 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
|
||||
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() {
|
||||
return Err(anyhow::anyhow!("No playable content found"));
|
||||
}
|
||||
@@ -2144,8 +2157,16 @@ fn fetch_playback_items(
|
||||
));
|
||||
}
|
||||
|
||||
// Browse the object to get entries
|
||||
let entries = server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?;
|
||||
// First, get metadata for the object to determine if it's a container or item
|
||||
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!(
|
||||
server_id = server_id.0.as_str(),
|
||||
|
||||
@@ -156,7 +156,7 @@ impl ToDIDL for Playlist {
|
||||
}
|
||||
|
||||
/// 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 minutes = (seconds % 3600) / 60;
|
||||
let secs = seconds % 60;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! providing a complete music catalog browsing and searching experience.
|
||||
|
||||
use crate::client::QobuzClient;
|
||||
use crate::didl::ToDIDL;
|
||||
use crate::didl::{format_duration, ToDIDL};
|
||||
use crate::lazy_provider::QobuzLazyProvider;
|
||||
use crate::models::Track;
|
||||
use pmoaudiocache::{AudioMetadata, Cache as AudioCache};
|
||||
@@ -1690,6 +1690,88 @@ impl MusicSource for QobuzSource {
|
||||
.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 {
|
||||
// Qobuz is a catalog, not a dynamic stream
|
||||
false
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.6
|
||||
0.3.7
|
||||
|
||||
Reference in New Issue
Block a user