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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -4,7 +4,7 @@ version = 4
|
||||
|
||||
[[package]]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.51"
|
||||
version = "0.3.52"
|
||||
dependencies = [
|
||||
"axum 0.8.7",
|
||||
"console-subscriber",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "PMOMusic"
|
||||
version = "0.3.51"
|
||||
version = "0.3.52"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.3.51
|
||||
0.3.52
|
||||
|
||||
Reference in New Issue
Block a user