refactor: rework search implementation and fix navigation bug

Replaces raw string queries with structured `SearchQuery` types across the `MusicSource` trait and UPnP handlers. Adds dedicated paginated endpoints, explicit caching, and type-specific filtering for the Qobuz source. Fixes a frontend navigation bug by aligning state management with UPnP browse semantics and properly routing virtual container IDs. Also updates the Makefile for dynamic `RUST_LOG` configuration, standardizes logging with `tracing`, and adds the dependency lockfile.
This commit is contained in:
2026-06-11 20:02:00 +02:00
parent 319a54ee8a
commit 8179c0e239
12 changed files with 1050 additions and 216 deletions

View File

@@ -12,7 +12,7 @@
use pmodidl::{Container, DIDLLite};
use pmosource::api::{get_source as get_source_from_registry, list_all_sources};
use pmosource::{BrowseResult, MusicSource, MusicSourceError};
use pmosource::{BrowseResult, MediaSearchType, MusicSource, MusicSourceError, SearchQuery, SearchScope};
use pmodidl::ToXmlElement;
use std::collections::HashSet;
use std::sync::Arc;
@@ -45,6 +45,82 @@ fn to_didl_lite(containers: &[Container], items: &[pmodidl::Item]) -> Result<Str
Ok(body)
}
/// Extrait le texte de recherche depuis un critère UPnP SearchCriteria.
///
/// Exemples supportés :
/// - `dc:title contains "Pink Floyd"` → `"Pink Floyd"`
/// - `upnp:artist contains "Miles"` → `"Miles"`
/// - `Pink Floyd` (texte nu) → `"Pink Floyd"`
/// - `*` → `""`
fn extract_search_text(criteria: &str) -> &str {
let trimmed = criteria.trim();
if trimmed == "*" || trimmed.is_empty() {
return "";
}
// Pattern : <property> contains "<query>" (UPnP CDS search syntax)
if let Some(pos) = trimmed.find("contains") {
let after = trimmed[pos + "contains".len()..].trim();
if after.starts_with('"') && after.ends_with('"') && after.len() >= 2 {
return &after[1..after.len() - 1];
}
}
trimmed
}
/// Détermine le scope et le type de média depuis le ContainerID UPnP.
fn container_to_search_context(container_id: &str) -> (SearchScope, MediaSearchType) {
// Containers virtuels de résultats de recherche : qobuz:search:{scope}:{type}:{query}
// Le CP peut appeler Search sur ces containers — on préserve leur scope+type.
let search_parts: Vec<&str> = container_id.splitn(5, ':').collect();
if search_parts.len() >= 4 && search_parts[0] == "qobuz" && search_parts[1] == "search" {
let scope = if search_parts[2] == "favorites" {
SearchScope::UserLibrary
} else {
SearchScope::Catalog
};
let media_type = match search_parts.get(3).copied().unwrap_or("") {
"albums" => MediaSearchType::Albums,
"tracks" => MediaSearchType::Tracks,
"artists" => MediaSearchType::Artists,
"playlists" => MediaSearchType::Playlists,
_ => MediaSearchType::All,
};
return (scope, media_type);
}
// Scope UserLibrary : tout ce qui est sous qobuz:favorites
if container_id.starts_with("qobuz:favorites") {
let media_type = match container_id {
"qobuz:favorites:albums" => MediaSearchType::Albums,
"qobuz:favorites:tracks" => MediaSearchType::Tracks,
"qobuz:favorites:artists" => MediaSearchType::Artists,
"qobuz:favorites:playlists" => MediaSearchType::Playlists,
_ => MediaSearchType::All,
};
return (SearchScope::UserLibrary, media_type);
}
// Un artiste spécifique → rechercher ses albums dans le catalog
if container_id.starts_with("qobuz:artist:") {
return (SearchScope::Catalog, MediaSearchType::Albums);
}
// Un album spécifique → rechercher ses pistes
if container_id.starts_with("qobuz:album:") {
return (SearchScope::Catalog, MediaSearchType::Tracks);
}
// Scope Catalog : reste de la hiérarchie
let media_type = if container_id.starts_with("qobuz:discover:artists") {
MediaSearchType::Artists
} else if container_id.starts_with("qobuz:discover:albums") {
MediaSearchType::Albums
} else {
MediaSearchType::All
};
(SearchScope::Catalog, media_type)
}
/// Handler pour le service ContentDirectory
///
/// Ce handler gère toutes les opérations du ContentDirectory en utilisant
@@ -518,13 +594,30 @@ impl ContentHandler {
"ContentDirectory::Search"
);
let text = extract_search_text(search_criteria);
let (scope, media_type) = container_to_search_context(container_id);
tracing::debug!(
container_id,
search_criteria,
extracted_text = text,
scope = ?scope,
media_type = ?media_type,
"ContentHandler::search resolved"
);
let query = SearchQuery {
text: text.to_string(),
media_type,
scope,
limit: 200,
offset: 0,
};
let mut all_containers = Vec::new();
let mut all_items = Vec::new();
// Rechercher dans toutes les sources qui supportent la recherche
for source in list_all_sources().await {
if source.capabilities().supports_search {
if let Ok(result) = source.search(search_criteria).await {
if let Ok(result) = source.search(&query).await {
match result {
BrowseResult::Containers(c) => all_containers.extend(c),
BrowseResult::Items(i) => all_items.extend(i),
@@ -540,7 +633,6 @@ impl ContentHandler {
let total = (all_containers.len() + all_items.len()) as u32;
let didl = to_didl_lite(&all_containers, &all_items)?;
// Compute a global update ID from active sources, ensure it starts at 1
let update_id = if total > 0 {
let sources = list_all_sources().await;
let mut combined_id = 0u32;

View File

@@ -50,7 +50,7 @@ use tracing::{debug, error, info};
pub fn browse_handler() -> ActionHandler {
action_handler!(|data| {
let mut data = data;
debug!("📂 Browse handler called");
tracing::warn!("━━━ BROWSE ━━━");
let handler = ContentHandler::new();
@@ -100,6 +100,7 @@ pub fn browse_handler() -> ActionHandler {
})?;
// Définir les arguments de sortie
tracing::warn!(object_id, returned, total, didl_preview = &didl[..didl.len().min(300)], "━━━ BROWSE DIDL ━━━");
set!(&mut data, "Result", didl);
set!(&mut data, "NumberReturned", returned);
set!(&mut data, "TotalMatches", total);
@@ -139,7 +140,7 @@ pub fn browse_handler() -> ActionHandler {
pub fn search_handler() -> ActionHandler {
action_handler!(|data| {
let mut data = data;
debug!("🔍 Search handler called");
tracing::warn!("━━━ SEARCH ━━━");
let handler = ContentHandler::new();