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.
305 lines
9.0 KiB
TypeScript
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
|
|
}
|
|
}
|