push-pqqsxyupswry #21

Merged
eric merged 76 commits from push-pqqsxyupswry into main 2025-12-06 12:49:47 +01:00
13 changed files with 269 additions and 35 deletions
Showing only changes of commit 4d79c6d883 - Show all commits

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRenderersStore } from '@/stores/renderers'
import { usePlaybackStore } from '@/stores/playback'
import { Music } from 'lucide-vue-next'
const props = defineProps<{
@@ -9,10 +8,9 @@ const props = defineProps<{
}>()
const renderersStore = useRenderersStore()
const playbackStore = usePlaybackStore()
const state = computed(() => renderersStore.getStateById(props.rendererId))
const metadata = computed(() => playbackStore.getTrackMetadata(props.rendererId))
const metadata = computed(() => state.value?.current_track)
// Calcul du pourcentage de progression
const progressPercent = computed(() => {

View File

@@ -18,9 +18,17 @@ defineProps<{
<!-- Index (1-based pour l'affichage) -->
<span class="item-index">{{ item.index + 1 }}</span>
<!-- Cover miniature (placeholder pour l'instant, on ajoutera la cover plus tard) -->
<!-- Cover miniature -->
<div class="item-cover">
<Music :size="20" />
<img
v-if="item.album_art_uri"
:src="item.album_art_uri"
:alt="item.album || 'Album cover'"
class="cover-image"
loading="lazy"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<Music v-else :size="20" />
</div>
<!-- Métadonnées -->
@@ -85,6 +93,13 @@ defineProps<{
justify-content: center;
color: var(--color-text-tertiary);
flex-shrink: 0;
overflow: hidden;
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.item-metadata {

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { usePlaybackStore } from '@/stores/playback'
import type {
RendererCapabilitiesSummary,
RendererSummary,
@@ -16,9 +15,9 @@ const props = defineProps<{
}>()
const router = useRouter()
const playbackStore = usePlaybackStore()
const metadata = computed(() => playbackStore.getTrackMetadata(props.renderer.id))
// Métadonnées proviennent directement de l'état du renderer (API + SSE)
const metadata = computed(() => props.state?.current_track)
const protocolLabel = computed(() => {
switch (props.renderer.protocol) {

View File

@@ -6,7 +6,6 @@ import router from "./router";
// Stores
import { useRenderersStore } from "./stores/renderers";
import { useMediaServersStore } from "./stores/mediaServers";
import { usePlaybackStore } from "./stores/playback";
import { useUIStore } from "./stores/ui";
// Service SSE
@@ -31,13 +30,13 @@ app.mount("#app");
// Après montage, initialiser SSE et connecter aux stores
const renderersStore = useRenderersStore();
const mediaServersStore = useMediaServersStore();
const playbackStore = usePlaybackStore();
const uiStore = useUIStore();
// Connecter SSE aux stores
// Note: Les métadonnées proviennent de l'API (current_track dans RendererState)
// Les événements SSE ne servent qu'à notifier les changements
sse.onRendererEvent((event) => {
renderersStore.updateFromSSE(event);
playbackStore.updateFromSSE(event);
});
sse.onMediaServerEvent((event) => {

View File

@@ -40,6 +40,14 @@ export interface RendererState {
mute: boolean | null
queue_len: number
attached_playlist: AttachedPlaylistInfo | null
current_track: CurrentTrackMetadata | null
}
export interface CurrentTrackMetadata {
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
}
export interface AttachedPlaylistInfo {
@@ -58,6 +66,7 @@ export interface QueueItem {
title: string | null
artist: string | null
album: string | null
album_art_uri: string | null
server_id: string | null
object_id: string | null
}

View File

@@ -298,6 +298,10 @@ export const useRenderersStore = defineStore('renderers', () => {
const state = states.value.get(rendererId)
if (state) {
state.transport_state = event.state as any
// Effacer les métadonnées si stopped/no_media
if (event.state === 'STOPPED' || event.state === 'NO_MEDIA') {
state.current_track = null
}
}
break
}
@@ -339,7 +343,15 @@ export const useRenderersStore = defineStore('renderers', () => {
}
case 'metadata_changed': {
// Les métadonnées sont gérées par le store playback
const state = states.value.get(rendererId)
if (state) {
state.current_track = {
title: event.title,
artist: event.artist,
album: event.album,
album_art_uri: event.album_art_uri
}
}
break
}

View File

@@ -697,6 +697,14 @@ impl ControlPoint {
.ok_or_else(|| Self::runtime_entry_missing(renderer_id))
}
/// Retourne les métadonnées courantes depuis le snapshot en mémoire
pub fn get_current_track_metadata(
&self,
renderer_id: &RendererId,
) -> Option<TrackMetadata> {
self.runtime.current_track_metadata(renderer_id)
}
/// Force a resynchronization of the OpenHome playlist cache for a renderer.
///
/// This is used by external APIs after mutating the native playlist so that
@@ -713,6 +721,11 @@ impl ControlPoint {
renderer.openhome_playlist_snapshot()
}
pub fn get_openhome_playlist_len(&self, renderer_id: &RendererId) -> anyhow::Result<usize> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_len()
}
pub fn clear_openhome_playlist(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_clear()?;
@@ -1211,10 +1224,13 @@ impl ControlPoint {
}
let renderer = self.openhome_renderer(renderer_id)?;
let mut after_id = self
.runtime
.queue_full_snapshot(renderer_id)
.and_then(|(queue, _)| queue.last().and_then(openhome_track_id_from_item));
// Get the last track ID from the OpenHome native playlist
// This ensures we append to the end of the actual playlist, not just our local queue
let mut after_id = renderer
.openhome_playlist_ids()
.ok()
.and_then(|ids| ids.last().copied());
for item in items.iter() {
let metadata = item.to_didl_metadata();
@@ -1227,10 +1243,33 @@ impl ControlPoint {
}
fn play_current_openhome(&self, renderer_id: &RendererId) -> anyhow::Result<()> {
let renderer = self.openhome_renderer(renderer_id)?;
// Pour les renderers OpenHome, vérifier d'abord si la playlist native a des pistes
// Cela couvre le cas où l'utilisateur a ajouté des morceaux directement via l'interface OpenHome
let native_playlist_len = renderer.openhome_playlist_len().unwrap_or(0);
if native_playlist_len > 0 {
// La playlist native a des pistes, on peut simplement appeler play()
// Cela reprendra la lecture à partir du morceau actuel (ou du premier si rien n'est en cours)
renderer.play()?;
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
self.sync_openhome_playlist_for(renderer_id)?;
info!(
renderer = renderer_id.0.as_str(),
playlist_len = native_playlist_len,
"Started OpenHome native playlist playback"
);
return Ok(());
}
// Fallback: utiliser la PlaybackQueue locale si la playlist native est vide
// (ce cas se produit quand on a enqueue des items via le control point)
let Some((item, _)) = self.runtime.peek_current(renderer_id) else {
debug!(
renderer = renderer_id.0.as_str(),
"OpenHome playlist is empty or no current item"
"OpenHome playlist is empty or no current item (both native and queue)"
);
self.runtime
.set_playback_source(renderer_id, PlaybackSource::None);
@@ -1239,14 +1278,13 @@ impl ControlPoint {
let track_id = openhome_track_id_from_item(&item)
.ok_or_else(|| anyhow!("Current OpenHome item has no track id"))?;
let renderer = self.openhome_renderer(renderer_id)?;
renderer.openhome_playlist_play_id(track_id)?;
self.runtime
.set_playback_source(renderer_id, PlaybackSource::FromQueue);
self.sync_openhome_playlist_for(renderer_id)?;
info!(
renderer = renderer_id.0.as_str(),
track_id, "Started OpenHome playlist playback (current item)"
track_id, "Started OpenHome playlist playback (current item from queue)"
);
Ok(())
}
@@ -1387,6 +1425,13 @@ impl RuntimeState {
entries.get(id).map(|entry| entry.queue.full_snapshot())
}
fn current_track_metadata(&self, id: &RendererId) -> Option<TrackMetadata> {
let entries = self.entries.lock().unwrap();
entries
.get(id)
.and_then(|entry| entry.snapshot.last_metadata.clone())
}
fn dequeue_next(&self, id: &RendererId) -> Option<(PlaybackItem, usize)> {
let mut entries = self.entries.lock().unwrap();
let entry = entries.get_mut(id)?;
@@ -1747,6 +1792,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
item.date = entry.date.clone();
item.track_number = entry.track_number.clone();
item.creator = entry.creator.clone();
item.protocol_info = Some(resource.protocol_info.clone());
Some(item)
}
@@ -2788,6 +2834,13 @@ fn extract_track_metadata(position: &PlaybackPositionInfo) -> Option<TrackMetada
}
};
debug!(
title = item.title.as_str(),
has_album_art = item.album_art.is_some(),
album_art_uri = item.album_art.as_deref(),
"Extracted metadata from position info"
);
Some(TrackMetadata {
title: Some(item.title.clone()),
artist: item.artist.clone(),

View File

@@ -171,6 +171,26 @@ impl MusicRenderer {
}
}
pub fn openhome_playlist_len(&self) -> Result<usize> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_len(),
_ => Err(op_not_supported(
"openhome_playlist_len",
self.unsupported_backend_name(),
)),
}
}
pub fn openhome_playlist_ids(&self) -> Result<Vec<u32>> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.openhome_playlist_ids(),
_ => Err(op_not_supported(
"openhome_playlist_ids",
self.unsupported_backend_name(),
)),
}
}
pub fn openhome_playlist_clear(&self) -> Result<()> {
match self {
MusicRenderer::OpenHome(renderer) => renderer.clear_openhome_playlist(),

View File

@@ -79,6 +79,22 @@ pub struct RendererState {
pub queue_len: usize,
/// Playlist attachée (si applicable)
pub attached_playlist: Option<AttachedPlaylistInfo>,
/// Métadonnées du morceau courant (si en lecture)
pub current_track: Option<CurrentTrackMetadata>,
}
/// Métadonnées du morceau en cours de lecture
#[cfg(feature = "pmoserver")]
#[derive(Debug, Clone, Serialize, ToSchema)]
pub struct CurrentTrackMetadata {
/// Titre du morceau
pub title: Option<String>,
/// Artiste
pub artist: Option<String>,
/// Album
pub album: Option<String>,
/// URI de la pochette d'album
pub album_art_uri: Option<String>,
}
/// Information sur la playlist attachée
@@ -111,6 +127,8 @@ pub struct QueueItem {
pub artist: Option<String>,
/// Album
pub album: Option<String>,
/// URI de la pochette d'album
pub album_art_uri: Option<String>,
/// ID du serveur source
pub server_id: Option<String>,
/// ID de l'objet DIDL-Lite
@@ -358,6 +376,7 @@ GET /control/servers/{server_id}/containers/{container_id}
RendererProtocolSummary,
RendererCapabilitiesSummary,
RendererState,
CurrentTrackMetadata,
AttachedPlaylistInfo,
QueueItem,
QueueSnapshot,

View File

@@ -184,7 +184,20 @@ impl OhPlaylistClient {
let response = find_child_with_suffix(&envelope.body.content, "IdArrayResponse")
.ok_or_else(|| anyhow!("Missing IdArrayResponse element in SOAP body"))?;
let array_text = extract_child_text_any(response, &["aArray", "aIdArray"])?;
// Try to extract the array element. If missing, assume empty playlist.
let array_text = match extract_child_text_any(response, &["aArray", "aIdArray"]) {
Ok(text) => text,
Err(_) => {
// Element not found - playlist is likely empty
return Ok(Vec::new());
}
};
// Handle empty string (another way renderers indicate empty playlist)
if array_text.trim().is_empty() {
return Ok(Vec::new());
}
let bytes = decode_base64(&array_text)?;
if bytes.len() % 4 != 0 {
return Err(anyhow!(
@@ -231,6 +244,8 @@ impl OhInfoClient {
}
pub fn track(&self) -> Result<OhInfoTrack> {
use tracing::debug;
let call_result = invoke_upnp_action(&self.control_url, &self.service_type, "Track", &[])?;
let envelope = ensure_success("Track", &call_result)?;
@@ -242,6 +257,20 @@ impl OhInfoClient {
.unwrap_or(None)
.filter(|s| !s.is_empty());
debug!(
uri = uri.as_str(),
has_metadata = metadata_xml.is_some(),
metadata_length = metadata_xml.as_ref().map(|s| s.len()),
"OpenHome Info.Track() returned"
);
if let Some(ref xml) = metadata_xml {
debug!(
metadata_xml = xml.as_str(),
"OpenHome metadata XML content"
);
}
Ok(OhInfoTrack { uri, metadata_xml })
}
@@ -423,6 +452,8 @@ impl OhRadioClient {
}
pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
use tracing::debug;
if xml.trim().is_empty() {
return None;
}
@@ -430,6 +461,13 @@ pub fn parse_track_metadata_from_didl(xml: &str) -> Option<TrackMetadata> {
let parsed = pmodidl::parse_metadata::<pmodidl::DIDLLite>(xml).ok()?;
let item = parsed.data.items.first()?;
debug!(
title = item.title.as_str(),
has_album_art = item.album_art.is_some(),
album_art_uri = item.album_art.as_deref(),
"Parsed DIDL metadata for track"
);
Some(TrackMetadata {
title: Some(item.title.clone()),
artist: item.artist.clone(),

View File

@@ -108,6 +108,21 @@ impl OpenHomeRenderer {
})
}
/// Retourne la longueur de la playlist OpenHome sans récupérer toutes les métadonnées.
/// Plus rapide que snapshot_openhome_playlist() pour juste connaître le nombre de pistes.
pub(crate) fn openhome_playlist_len(&self) -> Result<usize> {
let playlist = self.playlist_client_for("openhome_playlist_len")?;
let ids = playlist.id_array()?;
Ok(ids.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>> {
let playlist = self.playlist_client_for("openhome_playlist_ids")?;
playlist.id_array()
}
pub(crate) fn clear_openhome_playlist(&self) -> Result<()> {
let playlist = self.playlist_client_for("clear_openhome_playlist")?;
playlist.delete_all()

View File

@@ -13,6 +13,7 @@ pub struct PlaybackItem {
pub date: Option<String>,
pub track_number: Option<String>,
pub creator: Option<String>,
pub protocol_info: Option<String>,
}
impl PlaybackItem {
@@ -29,12 +30,14 @@ impl PlaybackItem {
date: None,
track_number: None,
creator: None,
protocol_info: None,
}
}
/// Convert PlaybackItem to DIDL-Lite XML metadata for SetAVTransportURI
pub fn to_didl_metadata(&self) -> String {
use quick_xml::escape::escape;
use tracing::debug;
let title = self.title.as_deref().unwrap_or("Unknown");
let escaped_uri = escape(&self.uri);
@@ -43,7 +46,13 @@ impl PlaybackItem {
let mut didl = String::from(
r#"<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/">"#,
);
didl.push_str(r#"<item id="0" parentID="-1" restricted="1">"#);
// Use proper ID from object_id if available, otherwise use "0"
let item_id = self.object_id.as_deref().unwrap_or("0");
let escaped_item_id = escape(item_id);
didl.push_str(&format!(
r#"<item id="{}" parentID="-1" restricted="1">"#,
escaped_item_id
));
didl.push_str(&format!("<dc:title>{}</dc:title>", escaped_title));
if let Some(artist) = &self.artist {
@@ -64,10 +73,20 @@ impl PlaybackItem {
if let Some(album_art) = &self.album_art_uri {
let escaped_art = escape(album_art);
debug!(
title = title,
album_art_uri = album_art.as_str(),
"Including albumArtURI in DIDL metadata"
);
didl.push_str(&format!(
"<upnp:albumArtURI>{}</upnp:albumArtURI>",
escaped_art
));
} else {
debug!(
title = title,
"No album_art_uri in PlaybackItem - skipping albumArtURI in DIDL"
);
}
if let Some(date) = &self.date {
@@ -84,9 +103,23 @@ impl PlaybackItem {
}
// Add resource with URI
// Use the original protocolInfo if available, otherwise use a generic one
let protocol_info = self
.protocol_info
.as_deref()
.unwrap_or("http-get:*:audio/*:*");
// For protocolInfo, we only need to escape XML special chars, not ':'
// We manually escape only the necessary characters to preserve the protocolInfo format
let safe_protocol_info = protocol_info
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;");
didl.push_str(&format!(
r#"<res protocolInfo="http-get:*:audio/*:*">{}</res>"#,
escaped_uri
r#"<res protocolInfo="{}">{}</res>"#,
safe_protocol_info, escaped_uri
));
didl.push_str(r#"<upnp:class>object.item.audioItem.musicTrack</upnp:class>"#);

View File

@@ -11,10 +11,11 @@ use crate::media_server::{MediaBrowser, MediaEntry, MediaResource, MusicServer,
use crate::model::{RendererCapabilities, RendererId, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry, ErrorResponse,
MediaServerSummary, OpenHomePlaylistAddRequest, OpenHomePlaylistSnapshot, PlayContentRequest,
QueueItem, QueueSnapshot, RendererCapabilitiesSummary, RendererProtocolSummary, RendererState,
RendererSummary, SuccessResponse, VolumeSetRequest,
AttachPlaylistRequest, AttachedPlaylistInfo, BrowseResponse, ContainerEntry,
CurrentTrackMetadata, ErrorResponse, MediaServerSummary, OpenHomePlaylistAddRequest,
OpenHomePlaylistSnapshot, PlayContentRequest, QueueItem, QueueSnapshot,
RendererCapabilitiesSummary, RendererProtocolSummary, RendererState, RendererSummary,
SuccessResponse, VolumeSetRequest,
};
#[cfg(feature = "pmoserver")]
use crate::playback_queue::PlaybackItem;
@@ -150,19 +151,19 @@ async fn get_renderer_state(
})?;
let info = renderer.info();
let renderer_clone = renderer.clone();
let renderer = renderer.clone();
// Spawn blocking task for all SOAP calls to avoid blocking Tokio runtime
let state_task = tokio::task::spawn_blocking(move || {
// État de transport
let transport_state = renderer_clone
let transport_state = renderer
.playback_state()
.ok()
.map(state_to_string)
.unwrap_or_else(|| "UNKNOWN".to_string());
// Position et durée
let (position_ms, duration_ms) = renderer_clone
let (position_ms, duration_ms) = renderer
.playback_position()
.ok()
.and_then(|pos| {
@@ -173,11 +174,11 @@ async fn get_renderer_state(
.unwrap_or((None, None));
// Volume et mute
let volume = renderer_clone
let volume = renderer
.volume()
.ok()
.and_then(|v| u8::try_from(v).ok());
let mute = renderer_clone.mute().ok();
let mute = renderer.mute().ok();
(transport_state, position_ms, duration_ms, volume, mute)
});
@@ -211,11 +212,18 @@ async fn get_renderer_state(
})?;
// Queue (non-blocking, local data)
// Pour les renderers OpenHome, on utilise la playlist native au lieu de la PlaybackQueue
let queue_len = state
.control_point
.get_queue_snapshot(&rid)
.get_openhome_playlist_len(&rid)
.ok()
.map(|q| q.len())
.or_else(|| {
state
.control_point
.get_queue_snapshot(&rid)
.ok()
.map(|q| q.len())
})
.unwrap_or(0);
// Playlist binding (non-blocking, local data)
@@ -230,6 +238,17 @@ async fn get_renderer_state(
},
);
// Current track metadata from in-memory snapshot (non-blocking, fast)
let current_track = state
.control_point
.get_current_track_metadata(&rid)
.map(|metadata| CurrentTrackMetadata {
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
album_art_uri: metadata.album_art_uri,
});
Ok(Json(RendererState {
id: info.id.0.clone(),
friendly_name: info.friendly_name.clone(),
@@ -240,6 +259,7 @@ async fn get_renderer_state(
mute,
queue_len,
attached_playlist,
current_track,
}))
}
@@ -302,6 +322,7 @@ async fn get_renderer_queue(
title: item.title,
artist: item.artist,
album: item.album,
album_art_uri: item.album_art_uri,
server_id: item.server_id.map(|s| s.0),
object_id: item.object_id,
})
@@ -1512,7 +1533,9 @@ async fn play_content(
control_point.enqueue_items(&rid, items.clone())?;
// Start playback
renderer.play()?;
// Pour les renderers OpenHome, play_current_from_queue() va gérer automatiquement
// la lecture depuis la playlist native si elle existe
control_point.play_current_from_queue(&rid)?;
// Auto-bind if playing a container (playlist/album)
// Rule: if multiple items, it's a container that should be bound
@@ -1943,6 +1966,7 @@ fn playback_item_from_entry(server: &MusicServer, entry: &MediaEntry) -> Option<
item.date = entry.date.clone();
item.track_number = entry.track_number.clone();
item.creator = entry.creator.clone();
item.protocol_info = Some(resource.protocol_info.clone());
Some(item)
}