🚀 v0.3.41 — Add NoMedia playback state handling & improve Chromecast logging
- Bump version to v0.3.41 - Add comprehensive debug logging in Chromecast renderer for app/media status detection (app name, player state) - Implement robust `NoMedia` playback handling for auto advancing on track end (especially important Chromecast behavior) - Reorganize imports in `musicrenderer.rs` for clarity - Fix minor import ordering and remove unused blank line
This commit is contained in:
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
272
pmoapp/webapp/src/composables/apiCache.ts
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
/**
|
||||||
|
* Cache API centralisé pour les requêtes HTTP
|
||||||
|
*
|
||||||
|
* Fonctionnalités:
|
||||||
|
* - Cache mémoire avec TTL configurable
|
||||||
|
* - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers)
|
||||||
|
* - Invalidation par pattern (ex: invalidate('renderers/*'))
|
||||||
|
* - Subscribe aux changements de données pour reactivity
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
etag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiCacheOptions {
|
||||||
|
ttl?: number;
|
||||||
|
staleWhileRevalidate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PendingRequest {
|
||||||
|
promise: Promise<unknown>;
|
||||||
|
subscribers: Set<(data: unknown) => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classe principale du cache API
|
||||||
|
*/
|
||||||
|
class ApiCacheService {
|
||||||
|
private cache = new Map<string, CacheEntry<unknown>>();
|
||||||
|
private pendingRequests = new Map<string, PendingRequest>();
|
||||||
|
private subscriptions = new Map<string, Set<(data: unknown) => void>>();
|
||||||
|
|
||||||
|
private options: Required<ApiCacheOptions> = {
|
||||||
|
ttl: 2000,
|
||||||
|
staleWhileRevalidate: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
configure(options: Partial<ApiCacheOptions>) {
|
||||||
|
this.options = { ...this.options, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeKey(endpoint: string, params?: Record<string, string | number | boolean>): string {
|
||||||
|
if (!params) return endpoint;
|
||||||
|
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
|
||||||
|
const query = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||||
|
return `${endpoint}?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFresh(key: string): boolean {
|
||||||
|
const entry = this.cache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
return Date.now() - entry.timestamp < this.options.ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
|
||||||
|
const key = this.makeKey(endpoint, params);
|
||||||
|
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
|
||||||
|
|
||||||
|
if (!entry) return null;
|
||||||
|
if (!this.isFresh(key)) {
|
||||||
|
return this.options.staleWhileRevalidate ? entry.data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
|
||||||
|
const key = this.makeKey(endpoint, params);
|
||||||
|
|
||||||
|
this.cache.set(key, {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
etag,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.notifySubscribers(key, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string | number | boolean> | undefined,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options: { force?: boolean; ttl?: number } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const key = this.makeKey(endpoint, params);
|
||||||
|
const { force = false, ttl } = options;
|
||||||
|
|
||||||
|
if (!force && this.isFresh(key)) {
|
||||||
|
const cached = this.get<T>(endpoint, params);
|
||||||
|
if (cached) return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = this.pendingRequests.get(key);
|
||||||
|
if (existing) {
|
||||||
|
return existing.promise as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolvePromise!: (value: unknown) => void;
|
||||||
|
let rejectPromise!: (reason: unknown) => void;
|
||||||
|
|
||||||
|
const promise = new Promise<unknown>((resolve, reject) => {
|
||||||
|
resolvePromise = resolve;
|
||||||
|
rejectPromise = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pendingRequests.set(key, {
|
||||||
|
promise,
|
||||||
|
subscribers: new Set(),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await fetcher();
|
||||||
|
|
||||||
|
if (ttl) {
|
||||||
|
const originalTtl = this.options.ttl;
|
||||||
|
this.options.ttl = ttl;
|
||||||
|
this.set(endpoint, data, params);
|
||||||
|
this.options.ttl = originalTtl;
|
||||||
|
} else {
|
||||||
|
this.set(endpoint, data, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvePromise(data);
|
||||||
|
|
||||||
|
const pending = this.pendingRequests.get(key);
|
||||||
|
if (pending) {
|
||||||
|
pending.subscribers.forEach(cb => cb(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
rejectPromise(error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.pendingRequests.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(new Error('Unreachable'));
|
||||||
|
}
|
||||||
|
|
||||||
|
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
|
||||||
|
const key = this.makeKey(endpoint, params);
|
||||||
|
|
||||||
|
if (!this.subscriptions.has(key)) {
|
||||||
|
this.subscriptions.set(key, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
this.subscriptions.get(key)!.add(callback as (data: unknown) => void);
|
||||||
|
|
||||||
|
const cached = this.get<T>(endpoint, params);
|
||||||
|
if (cached) {
|
||||||
|
callback(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
const subs = this.subscriptions.get(key);
|
||||||
|
if (subs) {
|
||||||
|
subs.delete(callback as (data: unknown) => void);
|
||||||
|
if (subs.size === 0) {
|
||||||
|
this.subscriptions.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidate(pattern: string): void {
|
||||||
|
const keysToDelete: string[] = [];
|
||||||
|
|
||||||
|
if (pattern.includes('*')) {
|
||||||
|
const prefix = pattern.replace('*', '');
|
||||||
|
this.cache.forEach((_, key) => {
|
||||||
|
if (key.startsWith(prefix)) {
|
||||||
|
keysToDelete.push(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (this.cache.has(pattern)) {
|
||||||
|
keysToDelete.push(pattern);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
keysToDelete.forEach(key => {
|
||||||
|
this.cache.delete(key);
|
||||||
|
this.subscriptions.delete(key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
this.subscriptions.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateAndFetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string | number | boolean>,
|
||||||
|
fetcher: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
this.invalidate(this.makeKey(endpoint, params));
|
||||||
|
return this.fetch(endpoint, params, fetcher, { force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
let fresh = 0;
|
||||||
|
let stale = 0;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
this.cache.forEach((entry) => {
|
||||||
|
if (now - entry.timestamp < this.options.ttl) {
|
||||||
|
fresh++;
|
||||||
|
} else {
|
||||||
|
stale++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: this.cache.size,
|
||||||
|
fresh,
|
||||||
|
stale,
|
||||||
|
pending: this.pendingRequests.size,
|
||||||
|
subscriptions: this.subscriptions.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private notifySubscribers(key: string, data: unknown) {
|
||||||
|
const subs = this.subscriptions.get(key);
|
||||||
|
if (subs) {
|
||||||
|
subs.forEach(cb => {
|
||||||
|
try {
|
||||||
|
cb(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ApiCache] Error in subscriber:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apiCache = new ApiCacheService();
|
||||||
|
|
||||||
|
export function useApiCache() {
|
||||||
|
return {
|
||||||
|
fetch<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string | number | boolean> | undefined,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options?: { force?: boolean; ttl?: number }
|
||||||
|
): Promise<T> {
|
||||||
|
return apiCache.fetch(endpoint, params, fetcher, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe<T>(
|
||||||
|
endpoint: string,
|
||||||
|
params: Record<string, string | number | boolean>,
|
||||||
|
callback: (data: T) => void
|
||||||
|
): () => void {
|
||||||
|
return apiCache.subscribe(endpoint, params, callback);
|
||||||
|
},
|
||||||
|
|
||||||
|
invalidate(pattern: string): void {
|
||||||
|
apiCache.invalidate(pattern);
|
||||||
|
},
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
apiCache.clear();
|
||||||
|
},
|
||||||
|
|
||||||
|
getStats() {
|
||||||
|
return apiCache.getStats();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { api } from '../services/pmocontrol/api'
|
import { api } from '../services/pmocontrol/api'
|
||||||
import { useSSE } from './useSSE'
|
import { useSSE } from './useSSE'
|
||||||
|
import { apiCache } from './apiCache'
|
||||||
import type {
|
import type {
|
||||||
MediaServerSummary,
|
MediaServerSummary,
|
||||||
ContainerEntry,
|
ContainerEntry,
|
||||||
@@ -28,11 +29,6 @@ const currentPath = ref<BreadcrumbItem[]>([])
|
|||||||
const searchResults = ref<BrowseState | null>(null)
|
const searchResults = ref<BrowseState | null>(null)
|
||||||
const searchQuery = ref<string>('')
|
const searchQuery = ref<string>('')
|
||||||
|
|
||||||
// Timestamps
|
|
||||||
const lastFetch = {
|
|
||||||
servers: 0
|
|
||||||
}
|
|
||||||
|
|
||||||
const CACHE_DURATION_MS = 2000
|
const CACHE_DURATION_MS = 2000
|
||||||
|
|
||||||
// Initialiser SSE une seule fois via le composable centralisé
|
// Initialiser SSE une seule fois via le composable centralisé
|
||||||
@@ -122,19 +118,20 @@ export function useMediaServers() {
|
|||||||
|
|
||||||
// Fetch servers list
|
// Fetch servers list
|
||||||
async function fetchServers(force = false) {
|
async function fetchServers(force = false) {
|
||||||
const now = Date.now()
|
|
||||||
if (!force && now - lastFetch.servers < CACHE_DURATION_MS) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
const data = await api.getServers()
|
|
||||||
|
// Utiliser le cache API centralisé
|
||||||
|
const data = await apiCache.fetch(
|
||||||
|
'/servers',
|
||||||
|
undefined,
|
||||||
|
() => api.getServers(),
|
||||||
|
{ force, ttl: CACHE_DURATION_MS }
|
||||||
|
)
|
||||||
|
|
||||||
serversCache.value.clear()
|
serversCache.value.clear()
|
||||||
data.forEach(s => serversCache.value.set(s.id, s))
|
data.forEach(s => serversCache.value.set(s.id, s))
|
||||||
lastFetch.servers = now
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
error.value = e instanceof Error ? e.message : 'Erreur fetch servers'
|
||||||
console.error('[useMediaServers] Erreur fetch:', e)
|
console.error('[useMediaServers] Erreur fetch:', e)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
import { ref, reactive, computed, toRaw, type Ref } from "vue";
|
||||||
import { api } from "../services/pmocontrol/api";
|
import { api } from "../services/pmocontrol/api";
|
||||||
import { useSSE } from "./useSSE";
|
import { useSSE } from "./useSSE";
|
||||||
|
import { apiCache } from "./apiCache";
|
||||||
import type {
|
import type {
|
||||||
RendererSummary,
|
RendererSummary,
|
||||||
RendererState,
|
RendererState,
|
||||||
@@ -26,7 +27,6 @@ interface RendererSnapshotState {
|
|||||||
|
|
||||||
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
|
||||||
const RENDERERS_CACHE_MS = 2000;
|
const RENDERERS_CACHE_MS = 2000;
|
||||||
const lastRenderersFetch = ref(0);
|
|
||||||
|
|
||||||
const snapshotState = reactive<RendererSnapshotState>({
|
const snapshotState = reactive<RendererSnapshotState>({
|
||||||
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
|
||||||
@@ -292,19 +292,21 @@ function selectRenderer(id: string | null) {
|
|||||||
async function fetchRenderers(force = false) {
|
async function fetchRenderers(force = false) {
|
||||||
ensureSSEInitialized();
|
ensureSSEInitialized();
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
if (!force && now - lastRenderersFetch.value < RENDERERS_CACHE_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
const data = await api.getRenderers();
|
|
||||||
|
// Utiliser le cache API centralisé
|
||||||
|
const data = await apiCache.fetch(
|
||||||
|
'/renderers',
|
||||||
|
undefined,
|
||||||
|
() => api.getRenderers(),
|
||||||
|
{ force, ttl: RENDERERS_CACHE_MS }
|
||||||
|
);
|
||||||
|
|
||||||
renderersCache.value = new Map(
|
renderersCache.value = new Map(
|
||||||
data.map((renderer) => [renderer.id, renderer]),
|
data.map((renderer) => [renderer.id, renderer]),
|
||||||
);
|
);
|
||||||
lastRenderersFetch.value = now;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
|
||||||
console.error("[useRenderers] Erreur fetch:", err);
|
console.error("[useRenderers] Erreur fetch:", err);
|
||||||
|
|||||||
Reference in New Issue
Block a user