feat: unify search state and dynamic container routing

Refactor search handling across the frontend and backend to use a unified reactive state and dynamic container ID generation. The frontend now leverages a centralized `useMediaServers` composable for search queries and results, while the backend computes container IDs dynamically from source entries instead of using hardcoded values. Bumps version to 0.3.52.
This commit is contained in:
2026-06-11 21:50:10 +02:00
parent 8179c0e239
commit 0dab3077cf
8 changed files with 57 additions and 91 deletions

2
Cargo.lock generated
View File

@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "PMOMusic"
version = "0.3.51"
version = "0.3.52"
dependencies = [
"axum 0.8.7",
"console-subscriber",

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.51"
version = "0.3.52"
edition = "2024"
[dependencies]

View File

@@ -22,17 +22,14 @@ const {
loading,
loadingMore,
error,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
async function handleSearch() {
if (searchInput.value.trim()) {
const virtualId = await searchServer(props.serverId, searchInput.value.trim(), props.containerId);
const virtualId = await searchServer(props.serverId, searchInput.value.trim());
if (virtualId) {
emit("navigate", virtualId);
}
@@ -41,11 +38,8 @@ async function handleSearch() {
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const isSearchMode = computed(() => searchQuery.value !== '');
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
useRenderers();
const uiStore = useUIStore();
@@ -55,9 +49,7 @@ const sentinelRef = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
const browseData = computed(() =>
isSearchMode.value
? searchResults.value
: getBrowseCached(props.serverId, props.containerId),
getBrowseCached(props.serverId, props.containerId),
);
const containers = computed(
@@ -68,7 +60,7 @@ const items = computed(
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
);
const canLoadMore = computed(() => !isSearchMode.value && hasMore(props.serverId, props.containerId));
const canLoadMore = computed(() => hasMore(props.serverId, props.containerId));
function setupObserver() {
if (observer) observer.disconnect();
@@ -201,7 +193,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
v-if="searchInput"
class="search-clear"
@click="handleClearSearch"
title="Effacer"
@@ -269,7 +261,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
v-if="!containers.length && !items.length"
class="browser-empty"
>
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
<p>{{ 'Ce dossier est vide' }}</p>
</div>
<!-- Sentinel infinite scroll -->

View File

@@ -41,24 +41,33 @@ const {
currentPath,
setPath,
clearPath,
searchResults,
searchQuery,
searchServer,
clearSearch,
} = useMediaServers();
const searchInput = ref('');
const isSearchMode = computed(() => searchQuery.value !== '');
async function handleSearch() {
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
if (!currentServer.value || !searchInput.value.trim()) return;
await searchServer(currentServer.value.id, searchInput.value.trim());
const query = searchInput.value.trim();
isLoading.value = true;
try {
const virtualId = await searchServer(currentServer.value.id, query);
if (!virtualId) return;
currentContainerId.value = virtualId;
setPath([
{ id: '0', title: currentServer.value.friendly_name },
{ id: virtualId, title: `Recherche : ${query}` },
]);
} finally {
isLoading.value = false;
}
}
function handleClearSearch() {
searchInput.value = '';
clearSearch();
}
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
@@ -473,7 +482,7 @@ function handleSettingsClick() {
@keyup.enter="handleSearch"
/>
<button
v-if="searchInput || isSearchMode"
v-if="searchInput"
class="search-clear-btn"
@click="handleClearSearch"
title="Effacer"
@@ -578,53 +587,6 @@ 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

View File

@@ -32,8 +32,6 @@ function browseCacheKey(serverId: string, containerId: string): string {
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
}
const currentPath = ref<BreadcrumbItem[]>([])
const searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
const CACHE_DURATION_MS = 2000
const BROWSE_WINDOW_SIZE = 200
@@ -215,14 +213,14 @@ export function useMediaServers() {
}
// Recherche dans un serveur — retourne l'ID du container virtuel de résultats
async function searchServer(serverId: string, query: string, context?: string): Promise<string | null> {
async function searchServer(serverId: string, query: string): Promise<string | null> {
if (!query.trim()) return null
try {
loading.value = true
error.value = null
const data = await api.searchServer(serverId, query, context)
const data = await api.searchServer(serverId, query)
// data.container_id est l'ID virtuel réel (ex: "qobuz:search:catalog:all:camille")
const key = browseCacheKey(serverId, data.container_id)
browseCache.value.set(key, {
@@ -240,11 +238,6 @@ export function useMediaServers() {
}
}
function clearSearch() {
searchResults.value = null
searchQuery.value = ''
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
@@ -299,13 +292,9 @@ export function useMediaServers() {
getServerById,
getBrowseCached,
hasMore,
// Search
searchResults,
searchQuery,
searchServer,
clearSearch,
// Actions
fetchServers,
searchServer,
browseContainer,
loadMoreBrowse,
setPath,

View File

@@ -8,6 +8,8 @@ use crate::control_point::ControlPoint;
#[cfg(feature = "pmoserver")]
use crate::media_server::{MediaBrowser, playback_item_from_entry};
#[cfg(feature = "pmoserver")]
use crate::MediaEntry;
#[cfg(feature = "pmoserver")]
use crate::model::{RendererCapabilities, RendererProtocol};
#[cfg(feature = "pmoserver")]
use crate::openapi::{
@@ -2392,6 +2394,22 @@ struct SearchQuery {
q: String,
}
#[cfg(feature = "pmoserver")]
fn search_result_container_id(entries: &[ContainerEntry]) -> String {
entries
.iter()
.find(|entry| entry.is_container)
.and_then(|entry| {
let parts: Vec<&str> = entry.id.splitn(5, ':').collect();
if parts.len() == 5 && parts[0] == "qobuz" && parts[1] == "search" {
Some(format!("qobuz:search:{}:all:{}", parts[2], parts[4]))
} else {
None
}
})
.unwrap_or_else(|| "search".to_string())
}
/// GET /control/servers/{server_id}/search?q=<query> - Recherche dans un serveur
#[cfg(feature = "pmoserver")]
#[utoipa::path(
@@ -2503,8 +2521,10 @@ async fn search_server(
})
.collect();
let container_id = search_result_container_id(&container_entries);
Ok(Json(BrowseResponse {
container_id: "search".to_string(),
container_id,
entries: container_entries,
total_count,
offset: 0,

View File

@@ -1898,7 +1898,8 @@ impl MusicSource for QobuzSource {
}
ObjectIdType::SearchResult(scope, media_type, query) => {
tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → execute_search");
tracing::debug!(scope = ?scope, media_type = ?media_type, query, "browse → search");
let is_all_search = media_type == MediaSearchType::All;
let sq = SearchQuery {
text: query,
media_type,
@@ -1906,9 +1907,11 @@ impl MusicSource for QobuzSource {
limit: 200,
offset: 0,
};
let result = self.execute_search(&sq).await;
tracing::debug!(ok = result.is_ok(), "execute_search returned");
result
if is_all_search {
self.search_grouped(&sq).await
} else {
self.execute_search(&sq).await
}
}
ObjectIdType::Track(_) => {
@@ -2410,10 +2413,11 @@ impl QobuzSource {
};
let (n_albums, n_artists, n_tracks, n_playlists) = counts;
let parent_id = format!("qobuz:search:{}:all:{}", scope_str, query.text);
let mk_container = |type_str: &str, title: &str, count: usize| Container {
id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
parent_id: "qobuz:search".to_string(),
parent_id: parent_id.clone(),
restricted: Some("1".to_string()),
child_count: Some(count.to_string()),
searchable: None,
@@ -2466,7 +2470,6 @@ impl QobuzSource {
use tracing::debug;
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
let text = &query.text;
let limit = query.limit;
let scope_str = match query.scope {
SearchScope::Catalog => "catalog",
SearchScope::UserLibrary => "favorites",

View File

@@ -1 +1 @@
0.3.51
0.3.52