feat: implement infinite scroll with pagination for media browsing

Add infinite scroll functionality to MediaBrowser component using IntersectionObserver, introduce BrowseState and pagination support (offset/limit) in API and backend, update version to 0.3.24
This commit is contained in:
2026-03-24 15:24:20 +01:00
parent 76b7c126c2
commit b08112699e
10 changed files with 204 additions and 35 deletions

View File

@@ -91,6 +91,16 @@ impl UpnpMediaServer {
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> {
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
}
fn browse_with_flag_paged(
&self,
object_id: &str,
browse_flag: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
let start_str = start.to_string();
let count_str = count.to_string();
let args = vec![
@@ -104,10 +114,12 @@ impl UpnpMediaServer {
let response = self.invoke_content_directory("Browse", None, args)?;
let envelope = response.envelope.ok_or_else(|| {
ControlPointError::MediaServerError(format!("Missing SOAP envelope in Browse response"))
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
})?;
let total_count = extract_total_matches(&envelope, "BrowseResponse");
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
map_didl_entries(&didl_xml)
let entries = map_didl_entries(&didl_xml)?;
Ok(BrowsePage { entries, total_count })
}
fn has_content_directory(&self) -> bool {
@@ -362,6 +374,13 @@ pub struct MediaEntry {
pub creator: Option<String>,
}
/// Résultat paginé d'un browse avec total connu.
#[derive(Debug)]
pub struct BrowsePage {
pub entries: Vec<MediaEntry>,
pub total_count: u32,
}
/// Backend-agnostic media browsing contract.
pub trait MediaBrowser {
fn browse_root(&self) -> Result<Vec<MediaEntry>, ControlPointError>;
@@ -371,6 +390,16 @@ pub trait MediaBrowser {
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError>;
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
let entries = self.browse_children(object_id, start, count)?;
let total_count = entries.len() as u32 + start;
Ok(BrowsePage { entries, total_count })
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
fn search(
&self,
@@ -488,6 +517,17 @@ impl MediaBrowser for MusicServer {
}
}
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_children_paged(object_id, start, count),
}
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
match self {
MusicServer::Upnp(upnp) => upnp.browse_object(object_id),
@@ -603,6 +643,15 @@ impl MediaBrowser for UpnpMediaServer {
self.browse_with_flag(object_id, "BrowseDirectChildren", start, count)
}
fn browse_children_paged(
&self,
object_id: &str,
start: u32,
count: u32,
) -> Result<BrowsePage, ControlPointError> {
self.browse_with_flag_paged(object_id, "BrowseDirectChildren", start, count)
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError> {
let entries = self.browse_with_flag(object_id, "BrowseMetadata", 0, 1)?;
entries.into_iter().next().ok_or_else(|| {
@@ -721,6 +770,17 @@ fn extract_result_payload(
Ok(payload)
}
fn extract_total_matches(envelope: &SoapEnvelope, response_suffix: &str) -> u32 {
let response = match find_child_with_suffix(&envelope.body.content, response_suffix) {
Some(r) => r,
None => return 0,
};
find_child_with_suffix(response, "TotalMatches")
.and_then(|e| e.get_text())
.and_then(|t| t.trim().parse::<u32>().ok())
.unwrap_or(0)
}
fn find_child_with_suffix<'a>(parent: &'a Element, suffix: &str) -> Option<&'a Element> {
parent.children.iter().find_map(|node| match node {
XMLNode::Element(elem) if elem.name.ends_with(suffix) => Some(elem),

View File

@@ -197,8 +197,12 @@ pub struct ContainerEntry {
pub struct BrowseResponse {
/// ID du container browsé
pub container_id: String,
/// Entrées du container
/// Entrées du container (page courante)
pub entries: Vec<ContainerEntry>,
/// Nombre total d'entrées dans le container
pub total_count: u32,
/// Offset de la page courante
pub offset: u32,
}
// ============================================================================

View File

@@ -27,7 +27,7 @@ use async_trait::async_trait;
#[cfg(feature = "pmoserver")]
use axum::{
Json, Router,
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
routing::{get, post},
};
@@ -43,7 +43,7 @@ use tracing::{debug, warn};
use utoipa::OpenApi;
#[cfg(feature = "pmoserver")]
const BROWSE_PAGE_SIZE: u32 = 100;
const BROWSE_DEFAULT_LIMIT: u32 = 50;
#[cfg(feature = "pmoserver")]
const BROWSE_REQUEST_TIMEOUT: Duration = Duration::from_secs(20);
@@ -2050,6 +2050,15 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
Json(summaries)
}
/// Paramètres de pagination pour le browse
#[cfg(feature = "pmoserver")]
#[derive(Debug, serde::Deserialize)]
struct BrowseParams {
#[serde(default)]
offset: u32,
limit: Option<u32>,
}
/// GET /control/servers/{server_id}/containers/{container_id} - Browse un container
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -2057,7 +2066,9 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
path = "/servers/{server_id}/containers/{container_id}",
params(
("server_id" = String, Path, description = "ID unique du serveur"),
("container_id" = String, Path, description = "ID du container (use '0' for root)")
("container_id" = String, Path, description = "ID du container (use '0' for root)"),
("offset" = Option<u32>, Query, description = "Index de départ (défaut: 0)"),
("limit" = Option<u32>, Query, description = "Nombre max d'items (défaut: 50)"),
),
responses(
(status = 200, description = "Contenu du container", body = BrowseResponse),
@@ -2069,6 +2080,7 @@ async fn list_servers(State(state): State<ControlPointState>) -> Json<Vec<MediaS
async fn browse_container(
State(state): State<ControlPointState>,
Path((server_id, container_id)): Path<(String, String)>,
Query(params): Query<BrowseParams>,
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
let sid = DeviceId(server_id.clone());
@@ -2099,14 +2111,17 @@ async fn browse_container(
));
}
let offset = params.offset;
let limit = params.limit.unwrap_or(BROWSE_DEFAULT_LIMIT);
// Use spawn_blocking to avoid blocking the async runtime with synchronous SOAP calls
let container_id_clone = container_id.clone();
let server_clone = server.clone();
let browse_task = tokio::task::spawn_blocking(move || {
server_clone.browse_children(&container_id_clone, 0, BROWSE_PAGE_SIZE)
server_clone.browse_children_paged(&container_id_clone, offset, limit)
});
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
let page = time::timeout(BROWSE_REQUEST_TIMEOUT, browse_task)
.await
.map_err(|_| {
warn!(
@@ -2145,14 +2160,14 @@ async fn browse_container(
)
})?;
let container_entries: Vec<ContainerEntry> = entries
let container_entries: Vec<ContainerEntry> = page.entries
.into_iter()
.map(|e| ContainerEntry {
id: e.id,
title: e.title,
class: e.class,
is_container: e.is_container,
child_count: None, // Could be extracted from DIDL-Lite if needed
child_count: None,
artist: e.artist,
album: e.album,
album_art_uri: e.album_art_uri,
@@ -2162,6 +2177,8 @@ async fn browse_container(
Ok(Json(BrowseResponse {
container_id,
entries: container_entries,
total_count: page.total_count,
offset,
}))
}
@@ -2218,7 +2235,7 @@ fn fetch_playback_items(
let entries = if object_metadata.is_container {
// For containers, browse children to get all items
server.browse_children(object_id, 0, BROWSE_PAGE_SIZE)?
server.browse_children(object_id, 0, BROWSE_DEFAULT_LIMIT)?
} else {
// For items, use the object itself
vec![object_metadata]