feat: ajouter la recherche dans les serveurs media
Implémente une fonctionnalité de recherche pour les serveurs media (Qobuz et autres) avec:
- Nouvelle API endpoint /servers/{serverId}/search côté backend
- Fonction searchServer dans useMediaServers.ts pour gérer la requête
- Composants MediaBrowser.vue et ServerDrawer.vue avec barre de recherche interactive
- Support des résultats mixtes (albums, artistes, pistes, playlists)
- Gestion des états de chargement, erreur et résultat vide
- Affichage conditionnel selon le mode navigation/recherche
This commit is contained in:
@@ -6,7 +6,7 @@ import { useUIStore } from "@/stores/ui";
|
||||
import Breadcrumb from "./Breadcrumb.vue";
|
||||
import ContainerItem from "./ContainerItem.vue";
|
||||
import MediaItem from "./MediaItem.vue";
|
||||
import { Loader2 } from "lucide-vue-next";
|
||||
import { Loader2, Search, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string;
|
||||
@@ -22,8 +22,27 @@ const {
|
||||
loading,
|
||||
loadingMore,
|
||||
error,
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
|
||||
async function handleSearch() {
|
||||
if (searchInput.value.trim()) {
|
||||
await searchServer(props.serverId, searchInput.value.trim());
|
||||
}
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||
useRenderers();
|
||||
const uiStore = useUIStore();
|
||||
@@ -33,7 +52,9 @@ const sentinelRef = ref<HTMLElement | null>(null);
|
||||
let observer: IntersectionObserver | null = null;
|
||||
|
||||
const browseData = computed(() =>
|
||||
getBrowseCached(props.serverId, props.containerId),
|
||||
isSearchMode.value
|
||||
? searchResults.value
|
||||
: getBrowseCached(props.serverId, props.containerId),
|
||||
);
|
||||
|
||||
const containers = computed(
|
||||
@@ -44,7 +65,7 @@ const items = computed(
|
||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
||||
);
|
||||
|
||||
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
|
||||
const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
|
||||
|
||||
function setupObserver() {
|
||||
if (observer) observer.disconnect();
|
||||
@@ -165,6 +186,31 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
@navigate="handleNavigate"
|
||||
/>
|
||||
|
||||
<!-- Search bar -->
|
||||
<div class="search-bar">
|
||||
<div class="search-input-wrapper">
|
||||
<Search :size="16" class="search-icon" />
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn btn-primary search-btn" @click="handleSearch">
|
||||
Rechercher
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading state -->
|
||||
<div v-if="loading" class="browser-loading">
|
||||
<Loader2 :size="32" class="spinner" />
|
||||
@@ -220,7 +266,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
v-if="!containers.length && !items.length"
|
||||
class="browser-empty"
|
||||
>
|
||||
<p>Ce dossier est vide</p>
|
||||
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sentinel infinite scroll -->
|
||||
@@ -328,6 +374,64 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-sm);
|
||||
color: var(--color-text-tertiary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-xs) var(--spacing-xl) var(--spacing-xs) calc(var(--spacing-sm) + 20px);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-clear {
|
||||
position: absolute;
|
||||
right: var(--spacing-xs);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-tertiary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.search-clear:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
white-space: nowrap;
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.browser-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
|
||||
@@ -41,8 +41,25 @@ const {
|
||||
currentPath,
|
||||
setPath,
|
||||
clearPath,
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
} = useMediaServers();
|
||||
|
||||
const searchInput = ref('');
|
||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
||||
|
||||
async function handleSearch() {
|
||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
||||
await searchServer(currentServer.value.id, searchInput.value.trim());
|
||||
}
|
||||
|
||||
function handleClearSearch() {
|
||||
searchInput.value = '';
|
||||
clearSearch();
|
||||
}
|
||||
|
||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||
useRenderers();
|
||||
|
||||
@@ -427,6 +444,28 @@ function handleSettingsClick() {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Search bar (visible en mode navigation) -->
|
||||
<div v-if="isNavigating" class="search-bar">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="Rechercher..."
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button
|
||||
v-if="searchInput || isSearchMode"
|
||||
class="search-clear-btn"
|
||||
@click="handleClearSearch"
|
||||
title="Effacer"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
<button class="search-btn" @click="handleSearch" title="Rechercher">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Contenu -->
|
||||
<div ref="drawerContentRef" class="drawer-content">
|
||||
<!-- Liste des serveurs -->
|
||||
@@ -520,6 +559,53 @@ function handleSettingsClick() {
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
|
||||
<!-- Résultats de recherche -->
|
||||
<div v-else-if="isSearchMode && searchResults">
|
||||
<p v-if="searchResults.entries.length === 0" class="empty-state">Aucun résultat</p>
|
||||
<ul v-else class="content-list">
|
||||
<li
|
||||
v-for="item in searchResults.entries"
|
||||
:key="item.id"
|
||||
class="content-item"
|
||||
:class="{ navigable: item.is_container }"
|
||||
@click="handleItemClick(item)"
|
||||
>
|
||||
<div class="content-cover">
|
||||
<img
|
||||
v-if="item.album_art_uri && !getImageState(item.id).error"
|
||||
:src="item.album_art_uri"
|
||||
:alt="item.title"
|
||||
class="cover-img"
|
||||
:class="{ loaded: getImageState(item.id).loaded }"
|
||||
@load="handleImageLoad(item.id)"
|
||||
@error="handleImageError(item.id)"
|
||||
/>
|
||||
<div v-else class="cover-placeholder">
|
||||
<Folder v-if="item.is_container" :size="24" />
|
||||
<Music v-else :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-info">
|
||||
<p class="content-title">{{ item.title }}</p>
|
||||
<p v-if="item.artist" class="content-subtitle">{{ item.artist }}</p>
|
||||
</div>
|
||||
<div class="item-actions" @click.stop>
|
||||
<button class="action-btn play-btn" @click="handlePlayItem($event, item)" title="Lire">
|
||||
<Play :size="14" />
|
||||
</button>
|
||||
<button class="action-btn" @click="toggleMenu(item.id, $event)" title="Plus">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
<div v-if="openMenuId === item.id" class="item-menu">
|
||||
<button @click="handleAddToQueue($event, item)"><Plus :size="14" /> Ajouter à la queue</button>
|
||||
<button @click="handleAddAfterCurrent($event, item)"><Plus :size="14" /> Après le current</button>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight v-if="item.is_container" :size="16" class="content-chevron" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Contenu du serveur -->
|
||||
<ul v-else-if="browseData" class="content-list">
|
||||
<li
|
||||
@@ -735,6 +821,62 @@ function handleSettingsClick() {
|
||||
}
|
||||
|
||||
/* Breadcrumb */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.search-input::placeholder {
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
.search-clear-btn,
|
||||
.search-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover,
|
||||
.search-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-xl);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface BrowseState {
|
||||
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
|
||||
const browseCache = ref<Map<string, BrowseState>>(new Map())
|
||||
const currentPath = ref<BreadcrumbItem[]>([])
|
||||
const searchResults = ref<BrowseState | null>(null)
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
// Timestamps
|
||||
const lastFetch = {
|
||||
@@ -193,6 +195,39 @@ export function useMediaServers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Recherche dans un serveur
|
||||
async function searchServer(serverId: string, query: string) {
|
||||
if (!query.trim()) {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
searchQuery.value = query
|
||||
|
||||
const data = await api.searchServer(serverId, query)
|
||||
searchResults.value = {
|
||||
container_id: 'search',
|
||||
entries: data.entries,
|
||||
total_count: data.total_count,
|
||||
}
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Erreur recherche'
|
||||
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchResults.value = null
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
// Getters
|
||||
function getServerById(id: string) {
|
||||
return serversCache.value.get(id)
|
||||
@@ -247,6 +282,11 @@ export function useMediaServers() {
|
||||
getServerById,
|
||||
getBrowseCached,
|
||||
hasMore,
|
||||
// Search
|
||||
searchResults,
|
||||
searchQuery,
|
||||
searchServer,
|
||||
clearSearch,
|
||||
// Actions
|
||||
fetchServers,
|
||||
browseContainer,
|
||||
|
||||
@@ -426,6 +426,16 @@ class PMOControlAPI {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recherche dans un serveur media
|
||||
* GET /api/control/servers/{serverId}/search?q={query}
|
||||
*/
|
||||
async searchServer(serverId: string, query: string): Promise<BrowseResponse> {
|
||||
return this.request<BrowseResponse>(
|
||||
`/servers/${encodeURIComponent(serverId)}/search?q=${encodeURIComponent(query)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SLEEP TIMER
|
||||
// ============================================================================
|
||||
|
||||
@@ -2307,6 +2307,132 @@ fn capability_summary(caps: &RendererCapabilities) -> RendererCapabilitiesSummar
|
||||
}
|
||||
}
|
||||
|
||||
/// Paramètres de recherche
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct SearchQuery {
|
||||
q: String,
|
||||
}
|
||||
|
||||
/// GET /control/servers/{server_id}/search?q=<query> - Recherche dans un serveur
|
||||
#[cfg(feature = "pmoserver")]
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/servers/{server_id}/search",
|
||||
params(
|
||||
("server_id" = String, Path, description = "ID unique du serveur"),
|
||||
("q" = String, Query, description = "Requête de recherche"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Résultats de recherche", body = BrowseResponse),
|
||||
(status = 404, description = "Serveur non trouvé", body = ErrorResponse),
|
||||
(status = 500, description = "Erreur lors de la recherche", body = ErrorResponse)
|
||||
),
|
||||
tag = "control"
|
||||
)]
|
||||
async fn search_server(
|
||||
State(state): State<ControlPointState>,
|
||||
Path(server_id): Path<String>,
|
||||
Query(params): Query<SearchQuery>,
|
||||
) -> Result<Json<BrowseResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let sid = DeviceId(server_id.clone());
|
||||
|
||||
let server = state.control_point.media_server(&sid).ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} not found", server_id),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if !server.is_online() {
|
||||
return Err((
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} is offline", server_id),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
if !server.has_content_directory() {
|
||||
return Err((
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Server {} does not support ContentDirectory", server_id),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
debug!(server_id = %server_id, query = %params.q, "Search request");
|
||||
|
||||
let query = params.q.clone();
|
||||
let server_clone = server.clone();
|
||||
let search_task = tokio::task::spawn_blocking(move || {
|
||||
server_clone.search("0", &query, 0, 200)
|
||||
});
|
||||
|
||||
let entries = time::timeout(BROWSE_REQUEST_TIMEOUT, search_task)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
warn!(
|
||||
"Search request on server {} exceeded {:?}",
|
||||
server_id, BROWSE_REQUEST_TIMEOUT
|
||||
);
|
||||
(
|
||||
StatusCode::GATEWAY_TIMEOUT,
|
||||
Json(ErrorResponse {
|
||||
error: format!(
|
||||
"Search request timed out after {}s",
|
||||
BROWSE_REQUEST_TIMEOUT.as_secs()
|
||||
),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Task join error during search: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Internal task error: {}", e),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| {
|
||||
warn!("Failed to search on server {}: {}", server_id, e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: format!("Failed to search: {}", e),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let total_count = entries.len() as u32;
|
||||
debug!(server_id = %server_id, count = total_count, "Search results");
|
||||
|
||||
let container_entries: Vec<ContainerEntry> = entries
|
||||
.into_iter()
|
||||
.map(|e| ContainerEntry {
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
class: e.class,
|
||||
is_container: e.is_container,
|
||||
child_count: None,
|
||||
artist: e.artist,
|
||||
album: e.album,
|
||||
album_art_uri: e.album_art_uri,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(BrowseResponse {
|
||||
container_id: "search".to_string(),
|
||||
entries: container_entries,
|
||||
total_count,
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// ROUTER & TRAIT
|
||||
// ============================================================================
|
||||
@@ -2405,6 +2531,7 @@ pub fn create_api_router(state: ControlPointState, control_point: Arc<ControlPoi
|
||||
"/servers/{server_id}/containers/{container_id}",
|
||||
get(browse_container),
|
||||
)
|
||||
.route("/servers/{server_id}/search", get(search_server))
|
||||
.with_state(state)
|
||||
// SSE events - merge the SSE router
|
||||
.merge(crate::sse::create_sse_router(control_point))
|
||||
|
||||
@@ -2015,7 +2015,10 @@ impl MusicSource for QobuzSource {
|
||||
}
|
||||
|
||||
async fn search(&self, query: &str) -> Result<BrowseResult> {
|
||||
// Search across Qobuz catalog
|
||||
use tracing::debug;
|
||||
debug!(query = %query, "Qobuz search started");
|
||||
|
||||
// Search across Qobuz catalog (albums, tracks, artists, playlists)
|
||||
let results = self
|
||||
.inner
|
||||
.client
|
||||
@@ -2023,22 +2026,72 @@ impl MusicSource for QobuzSource {
|
||||
.await
|
||||
.map_err(|e| MusicSourceError::BrowseError(e.to_string()))?;
|
||||
|
||||
let (albums, tracks) = tokio::join!(
|
||||
self.cache_covers(results.albums),
|
||||
self.cache_covers(results.tracks),
|
||||
debug!(
|
||||
albums = results.albums.len(),
|
||||
artists = results.artists.len(),
|
||||
tracks = results.tracks.len(),
|
||||
playlists = results.playlists.len(),
|
||||
"Qobuz search API results"
|
||||
);
|
||||
let containers: Vec<Container> = albums
|
||||
|
||||
// Cache covers in parallel for all types
|
||||
let (albums, tracks, artists, playlists) = tokio::join!(
|
||||
self.cache_album_covers(results.albums),
|
||||
self.cache_track_covers(results.tracks),
|
||||
self.cache_artist_covers(results.artists),
|
||||
self.cache_playlist_covers(results.playlists),
|
||||
);
|
||||
|
||||
// Build containers from albums
|
||||
let album_containers: Vec<Container> = albums
|
||||
.into_iter()
|
||||
.filter_map(|album| album.to_didl_container("qobuz").ok())
|
||||
.filter_map(|a| a.to_didl_container("qobuz:search").ok())
|
||||
.collect();
|
||||
|
||||
let items: Vec<Item> = tracks
|
||||
// Build containers from artists (manual construction)
|
||||
let artist_containers: Vec<Container> = artists
|
||||
.into_iter()
|
||||
.filter_map(|track| track.to_didl_item("qobuz").ok())
|
||||
.map(|artist| Container {
|
||||
id: format!("qobuz:artist:{}", artist.id),
|
||||
parent_id: "qobuz:search".to_string(),
|
||||
restricted: Some("1".to_string()),
|
||||
child_count: None,
|
||||
searchable: Some("1".to_string()),
|
||||
title: artist.name.clone(),
|
||||
class: "object.container".to_string(),
|
||||
artist: Some(artist.name.clone()),
|
||||
album_art: artist.image_cached,
|
||||
containers: vec![],
|
||||
items: vec![],
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !containers.is_empty() || !items.is_empty() {
|
||||
Ok(BrowseResult::Mixed { containers, items })
|
||||
// Build containers from playlists
|
||||
let playlist_containers: Vec<Container> = playlists
|
||||
.into_iter()
|
||||
.filter_map(|p| p.to_didl_container("qobuz:search").ok())
|
||||
.collect();
|
||||
|
||||
// Combine all containers
|
||||
let mut all_containers = Vec::new();
|
||||
all_containers.extend(album_containers);
|
||||
all_containers.extend(artist_containers);
|
||||
all_containers.extend(playlist_containers);
|
||||
|
||||
// Build items from tracks
|
||||
let track_items: Vec<Item> = tracks
|
||||
.into_iter()
|
||||
.filter_map(|t| t.to_didl_item("qobuz:search").ok())
|
||||
.collect();
|
||||
|
||||
debug!(
|
||||
containers = all_containers.len(),
|
||||
items = track_items.len(),
|
||||
"Qobuz search done"
|
||||
);
|
||||
|
||||
if !all_containers.is_empty() || !track_items.is_empty() {
|
||||
Ok(BrowseResult::Mixed { containers: all_containers, items: track_items })
|
||||
} else {
|
||||
Ok(BrowseResult::Items(vec![]))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user