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]]
|
[[package]]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.51"
|
version = "0.3.52"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum 0.8.7",
|
"axum 0.8.7",
|
||||||
"console-subscriber",
|
"console-subscriber",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "PMOMusic"
|
name = "PMOMusic"
|
||||||
version = "0.3.51"
|
version = "0.3.52"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -22,17 +22,14 @@ const {
|
|||||||
loading,
|
loading,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
error,
|
error,
|
||||||
searchResults,
|
|
||||||
searchQuery,
|
|
||||||
searchServer,
|
searchServer,
|
||||||
clearSearch,
|
|
||||||
} = useMediaServers();
|
} = useMediaServers();
|
||||||
|
|
||||||
const searchInput = ref('');
|
const searchInput = ref('');
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
if (searchInput.value.trim()) {
|
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) {
|
if (virtualId) {
|
||||||
emit("navigate", virtualId);
|
emit("navigate", virtualId);
|
||||||
}
|
}
|
||||||
@@ -41,11 +38,8 @@ async function handleSearch() {
|
|||||||
|
|
||||||
function handleClearSearch() {
|
function handleClearSearch() {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
clearSearch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
|
||||||
|
|
||||||
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
const { playContent, addToQueue, attachAndPlayPlaylist, attachPlaylist } =
|
||||||
useRenderers();
|
useRenderers();
|
||||||
const uiStore = useUIStore();
|
const uiStore = useUIStore();
|
||||||
@@ -55,9 +49,7 @@ const sentinelRef = ref<HTMLElement | null>(null);
|
|||||||
let observer: IntersectionObserver | null = null;
|
let observer: IntersectionObserver | null = null;
|
||||||
|
|
||||||
const browseData = computed(() =>
|
const browseData = computed(() =>
|
||||||
isSearchMode.value
|
getBrowseCached(props.serverId, props.containerId),
|
||||||
? searchResults.value
|
|
||||||
: getBrowseCached(props.serverId, props.containerId),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const containers = computed(
|
const containers = computed(
|
||||||
@@ -68,7 +60,7 @@ const items = computed(
|
|||||||
() => browseData.value?.entries.filter((e) => !e.is_container) || [],
|
() => 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() {
|
function setupObserver() {
|
||||||
if (observer) observer.disconnect();
|
if (observer) observer.disconnect();
|
||||||
@@ -201,7 +193,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="searchInput || isSearchMode"
|
v-if="searchInput"
|
||||||
class="search-clear"
|
class="search-clear"
|
||||||
@click="handleClearSearch"
|
@click="handleClearSearch"
|
||||||
title="Effacer"
|
title="Effacer"
|
||||||
@@ -269,7 +261,7 @@ async function handleQueueItem(itemId: string, rendererId: string) {
|
|||||||
v-if="!containers.length && !items.length"
|
v-if="!containers.length && !items.length"
|
||||||
class="browser-empty"
|
class="browser-empty"
|
||||||
>
|
>
|
||||||
<p>{{ isSearchMode ? 'Aucun résultat' : 'Ce dossier est vide' }}</p>
|
<p>{{ 'Ce dossier est vide' }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sentinel infinite scroll -->
|
<!-- Sentinel infinite scroll -->
|
||||||
|
|||||||
@@ -41,24 +41,33 @@ const {
|
|||||||
currentPath,
|
currentPath,
|
||||||
setPath,
|
setPath,
|
||||||
clearPath,
|
clearPath,
|
||||||
searchResults,
|
|
||||||
searchQuery,
|
|
||||||
searchServer,
|
searchServer,
|
||||||
clearSearch,
|
|
||||||
} = useMediaServers();
|
} = useMediaServers();
|
||||||
|
|
||||||
const searchInput = ref('');
|
const searchInput = ref('');
|
||||||
const isSearchMode = computed(() => searchQuery.value !== '');
|
|
||||||
|
|
||||||
async function handleSearch() {
|
async function handleSearch() {
|
||||||
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
|
console.log('[ServerDrawer] handleSearch called, currentServer:', currentServer.value?.id, 'searchInput:', searchInput.value);
|
||||||
if (!currentServer.value || !searchInput.value.trim()) return;
|
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() {
|
function handleClearSearch() {
|
||||||
searchInput.value = '';
|
searchInput.value = '';
|
||||||
clearSearch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
const { playContent, addToQueue, addAfterCurrent, attachAndPlayPlaylist } =
|
||||||
@@ -473,7 +482,7 @@ function handleSettingsClick() {
|
|||||||
@keyup.enter="handleSearch"
|
@keyup.enter="handleSearch"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="searchInput || isSearchMode"
|
v-if="searchInput"
|
||||||
class="search-clear-btn"
|
class="search-clear-btn"
|
||||||
@click="handleClearSearch"
|
@click="handleClearSearch"
|
||||||
title="Effacer"
|
title="Effacer"
|
||||||
@@ -578,53 +587,6 @@ function handleSettingsClick() {
|
|||||||
<p>Chargement...</p>
|
<p>Chargement...</p>
|
||||||
</div>
|
</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 -->
|
<!-- Contenu du serveur -->
|
||||||
<ul v-else-if="browseData" class="content-list">
|
<ul v-else-if="browseData" class="content-list">
|
||||||
<li
|
<li
|
||||||
|
|||||||
@@ -32,8 +32,6 @@ function browseCacheKey(serverId: string, containerId: string): string {
|
|||||||
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
|
||||||
}
|
}
|
||||||
const currentPath = ref<BreadcrumbItem[]>([])
|
const currentPath = ref<BreadcrumbItem[]>([])
|
||||||
const searchResults = ref<BrowseState | null>(null)
|
|
||||||
const searchQuery = ref<string>('')
|
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000
|
const CACHE_DURATION_MS = 2000
|
||||||
const BROWSE_WINDOW_SIZE = 200
|
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
|
// 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
|
if (!query.trim()) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
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")
|
// data.container_id est l'ID virtuel réel (ex: "qobuz:search:catalog:all:camille")
|
||||||
const key = browseCacheKey(serverId, data.container_id)
|
const key = browseCacheKey(serverId, data.container_id)
|
||||||
browseCache.value.set(key, {
|
browseCache.value.set(key, {
|
||||||
@@ -240,11 +238,6 @@ export function useMediaServers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearch() {
|
|
||||||
searchResults.value = null
|
|
||||||
searchQuery.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
function getServerById(id: string) {
|
function getServerById(id: string) {
|
||||||
return serversCache.value.get(id)
|
return serversCache.value.get(id)
|
||||||
@@ -299,13 +292,9 @@ export function useMediaServers() {
|
|||||||
getServerById,
|
getServerById,
|
||||||
getBrowseCached,
|
getBrowseCached,
|
||||||
hasMore,
|
hasMore,
|
||||||
// Search
|
|
||||||
searchResults,
|
|
||||||
searchQuery,
|
|
||||||
searchServer,
|
|
||||||
clearSearch,
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchServers,
|
fetchServers,
|
||||||
|
searchServer,
|
||||||
browseContainer,
|
browseContainer,
|
||||||
loadMoreBrowse,
|
loadMoreBrowse,
|
||||||
setPath,
|
setPath,
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use crate::control_point::ControlPoint;
|
|||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::media_server::{MediaBrowser, playback_item_from_entry};
|
use crate::media_server::{MediaBrowser, playback_item_from_entry};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
|
use crate::MediaEntry;
|
||||||
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::model::{RendererCapabilities, RendererProtocol};
|
use crate::model::{RendererCapabilities, RendererProtocol};
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
use crate::openapi::{
|
use crate::openapi::{
|
||||||
@@ -2392,6 +2394,22 @@ struct SearchQuery {
|
|||||||
q: String,
|
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
|
/// GET /control/servers/{server_id}/search?q=<query> - Recherche dans un serveur
|
||||||
#[cfg(feature = "pmoserver")]
|
#[cfg(feature = "pmoserver")]
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -2503,8 +2521,10 @@ async fn search_server(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
let container_id = search_result_container_id(&container_entries);
|
||||||
|
|
||||||
Ok(Json(BrowseResponse {
|
Ok(Json(BrowseResponse {
|
||||||
container_id: "search".to_string(),
|
container_id,
|
||||||
entries: container_entries,
|
entries: container_entries,
|
||||||
total_count,
|
total_count,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|||||||
@@ -1898,7 +1898,8 @@ impl MusicSource for QobuzSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ObjectIdType::SearchResult(scope, media_type, query) => {
|
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 {
|
let sq = SearchQuery {
|
||||||
text: query,
|
text: query,
|
||||||
media_type,
|
media_type,
|
||||||
@@ -1906,9 +1907,11 @@ impl MusicSource for QobuzSource {
|
|||||||
limit: 200,
|
limit: 200,
|
||||||
offset: 0,
|
offset: 0,
|
||||||
};
|
};
|
||||||
let result = self.execute_search(&sq).await;
|
if is_all_search {
|
||||||
tracing::debug!(ok = result.is_ok(), "execute_search returned");
|
self.search_grouped(&sq).await
|
||||||
result
|
} else {
|
||||||
|
self.execute_search(&sq).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ObjectIdType::Track(_) => {
|
ObjectIdType::Track(_) => {
|
||||||
@@ -2410,10 +2413,11 @@ impl QobuzSource {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let (n_albums, n_artists, n_tracks, n_playlists) = counts;
|
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 {
|
let mk_container = |type_str: &str, title: &str, count: usize| Container {
|
||||||
id: format!("qobuz:search:{}:{}:{}", scope_str, type_str, query.text),
|
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()),
|
restricted: Some("1".to_string()),
|
||||||
child_count: Some(count.to_string()),
|
child_count: Some(count.to_string()),
|
||||||
searchable: None,
|
searchable: None,
|
||||||
@@ -2466,7 +2470,6 @@ impl QobuzSource {
|
|||||||
use tracing::debug;
|
use tracing::debug;
|
||||||
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
|
debug!(text = %query.text, scope = ?query.scope, media_type = ?query.media_type, "execute_search entry");
|
||||||
let text = &query.text;
|
let text = &query.text;
|
||||||
let limit = query.limit;
|
|
||||||
let scope_str = match query.scope {
|
let scope_str = match query.scope {
|
||||||
SearchScope::Catalog => "catalog",
|
SearchScope::Catalog => "catalog",
|
||||||
SearchScope::UserLibrary => "favorites",
|
SearchScope::UserLibrary => "favorites",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0.3.51
|
0.3.52
|
||||||
|
|||||||
Reference in New Issue
Block a user