Files
pmomusic/pmoapp/webapp/src/composables/useMediaServers.ts
Eric Coissac 0dab3077cf 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.
2026-06-11 22:17:53 +02:00

305 lines
9.0 KiB
TypeScript

/**
* Composable pour gérer les media servers
* Architecture simple : l'API est la source de vérité, SSE invalide le cache
*/
import { ref, computed } from 'vue'
import { api } from '../services/pmocontrol/api'
import { useSSE } from './useSSE'
import { apiCache } from './apiCache'
import type {
MediaServerSummary,
ContainerEntry,
} from '../services/pmocontrol/types'
export interface BreadcrumbItem {
id: string
title: string
}
export interface BrowseState {
container_id: string
entries: ContainerEntry[]
total_count: number
hasMore?: boolean
currentOffset?: number
}
// Cache global partagé
const serversCache = ref<Map<string, MediaServerSummary>>(new Map())
const browseCache = ref<Map<string, BrowseState>>(new Map())
function browseCacheKey(serverId: string, containerId: string): string {
return `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`
}
const currentPath = ref<BreadcrumbItem[]>([])
const CACHE_DURATION_MS = 2000
const BROWSE_WINDOW_SIZE = 200
// Initialiser SSE une seule fois via le composable centralisé
let sseInitialized = false
function ensureSSEInitialized() {
if (sseInitialized) return
const { onMediaServerEvent, connect } = useSSE()
// Démarrer la connexion SSE
connect()
onMediaServerEvent((event) => {
const serverId = event.server_id
switch (event.type) {
case 'online':
// Ajouter au cache avec les infos disponibles
const server: MediaServerSummary = {
id: serverId,
friendly_name: event.friendly_name,
model_name: event.model_name,
online: true,
}
serversCache.value.set(serverId, server)
// Fetch la liste complète pour avoir les bonnes infos
// On ne le fait pas ici car on pourrait déclencher trop de requêtes
// La liste se mettra à jour au prochain refresh automatique
break
case 'offline':
// Marquer comme offline dans le cache
const existingServer = serversCache.value.get(serverId)
if (existingServer) {
existingServer.online = false
serversCache.value.set(serverId, existingServer)
}
// Invalider tout le cache browse de ce serveur
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => browseCache.value.delete(key))
break
case 'global_updated':
// Invalider tout le cache de ce serveur
const encodedServerIdGlobal = encodeURIComponent(serverId)
const globalKeysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(encodedServerIdGlobal + ':')) {
globalKeysToDelete.push(key)
}
})
globalKeysToDelete.forEach(key => browseCache.value.delete(key))
break
case 'containers_updated':
// Invalider les containers spécifiques
event.container_ids.forEach(containerId => {
const key = browseCacheKey(serverId, containerId)
browseCache.value.delete(key)
})
break
}
})
sseInitialized = true
}
/**
* Composable principal pour gérer les media servers
*/
export function useMediaServers() {
ensureSSEInitialized()
const loading = ref(false)
const loadingMore = ref(false)
const error = ref<string | null>(null)
// Getters computed
const allServers = computed(() => Array.from(serversCache.value.values()))
const onlineServers = computed(() => allServers.value.filter(s => s.online))
// Fetch servers list
async function fetchServers(force = false) {
try {
loading.value = true
error.value = null
// Utiliser le cache API centralisé
const data = await apiCache.fetch(
'/servers',
undefined,
() => api.getServers(),
{ force, ttl: CACHE_DURATION_MS }
)
serversCache.value.clear()
data.forEach(s => serversCache.value.set(s.id, s))
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
console.error('[useMediaServers] Erreur fetch:', e)
} finally {
loading.value = false
}
}
// Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = browseCacheKey(serverId, containerId)
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
}
try {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId, 0, 50)
browseCache.value.set(key, {
container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
hasMore: data.entries.length < data.total_count,
currentOffset: data.entries.length,
})
return browseCache.value.get(key)!
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur browse container'
console.error(`[useMediaServers] Erreur browse ${serverId}/${containerId}:`, e)
throw e
} finally {
loading.value = false
}
}
// Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) {
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return
if (!('hasMore' in state) || !state.hasMore) return
if (loadingMore.value) return
try {
loadingMore.value = true
// Le type cast est nécessaire car les anciens cached entries n'ont pas hasMore
const state = browseCache.value.get(key) as BrowseState & { hasMore?: boolean; currentOffset?: number }
const offset = state.currentOffset ?? state.entries.length
const data = await api.browseContainer(serverId, containerId, offset)
// Accumuler les nouvelles entrées avec fenêtre glissante
const combined = [...state.entries, ...data.entries]
// Ne garder que les últimos BROWSE_WINDOW_SIZE items
state.entries = combined.slice(-BROWSE_WINDOW_SIZE)
state.total_count = data.total_count
state.currentOffset = (state.currentOffset ?? 0) + data.entries.length
state.hasMore = state.currentOffset < state.total_count
// Forcer la réactivité
browseCache.value.set(key, { ...state })
} catch (e) {
console.error(`[useMediaServers] Erreur load more ${serverId}/${containerId}:`, e)
} finally {
loadingMore.value = false
}
}
// Recherche dans un serveur — retourne l'ID du container virtuel de résultats
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)
// 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, {
container_id: data.container_id,
entries: data.entries,
total_count: data.total_count,
})
return data.container_id
} catch (e) {
error.value = e instanceof Error ? e.message : 'Erreur recherche'
console.error(`[useMediaServers] Erreur search ${serverId}:`, e)
throw e
} finally {
loading.value = false
}
}
// Getters
function getServerById(id: string) {
return serversCache.value.get(id)
}
function getBrowseCached(serverId: string, containerId: string) {
const key = browseCacheKey(serverId, containerId)
return browseCache.value.get(key)
}
function hasMore(serverId: string, containerId: string): boolean {
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return false
return state.entries.length < state.total_count
}
// Breadcrumb path management
function setPath(path: BreadcrumbItem[]) {
currentPath.value = path
}
function clearPath() {
currentPath.value = []
}
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
browseCache.value.delete(browseCacheKey(serverId, containerId))
} else {
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})
keysToDelete.forEach(key => browseCache.value.delete(key))
}
}
return {
// État
loading,
loadingMore,
error,
currentPath,
// Getters
allServers,
onlineServers,
getServerById,
getBrowseCached,
hasMore,
// Actions
fetchServers,
searchServer,
browseContainer,
loadMoreBrowse,
setPath,
clearPath,
invalidateCache
}
}