49 Commits

Author SHA1 Message Date
3401ee839c chore: bump version to 0.3.48
Update package and project versions from v0.3.57 to 0.48 in Cargo.toml and version.txt.
2026-04-10 00:06:20 +02:00
e8e33414f0 (refactor) Replace tokio::spawn_blocking with std thread for metadata preloading
(refactor) Replace tokio::spawn_blocking with std thread for metadata preloading
- Use `std::thread` instead of Tokio's blocking pool to avoid potential thread starvation in async context
- Ensures long-running metadata loading does not block Tokio workers
2026-04-10 00:05:16 +02:00
b0e24c3b3c 🔖 bump version to v0.3.47
- Update Cargo.lock packageversion
- Refactor DB initialization: move PRAGMA settings after table creation for correctness and clarity (WAL, cache size etc.)
- Add foreign key enforcement (`PRAGMAforeign_keys = ON`)
>- Reorder index creation to follow table definition
- Add new indexes: `idx_asset_last_used`, idx`_asset_hits`
>- Introduce indexed lazy_pk support with unique constraint on non-NULL values
> - Improve LRU index to use ASC ordering for efficient oldest-record lookup
2026-04-10 00:03:39 +02:00
b5b6becb25 Merge pull request '⬆️ Bump version to v0.3.47' (#95) from push-nlksvqrutmxv into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m12s
Reviewed-on: #95
2026-04-09 23:34:51 +02:00
353e54af76 ⬆️ Bump version to v0.3.47
- Update package versions in Cargo.toml, lockfile and version.txt to v0.3.47
- Optimize SQLite database initialization with WAL mode, larger cache and critical missing indexes (idx_metadata_key_value, idx_asset_last_used/hits)
- Fix production bug in OpenHome renderer: avoid blocking main thread when loading metadata for large queues; offload preloading of next 10 items to background task with small delays
- Minor formatting cleanup in db.rs
2026-04-09 23:34:32 +02:00
f2be1b9bc1 Merge pull request '⬆️ version bump to v0.3.46' (#94) from push-lprskpllksrm into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m8s
Reviewed-on: #94
2026-04-09 23:04:53 +02:00
6e65558ba9 ⬆️ version bump to v0.3.46
- Update Cargo.toml and version.txt to v0.3.46
- Fix production bug in music renderer autoadvance logic: add fallback timeout (20s) to compensate for missed PLAYING events due DB latency
- Update Cargo.lock accordingly
2026-04-09 23:04:35 +02:00
4c7c99f0a4 Merge pull request 'push-ppmsrvolzpzk' (#93) from push-ppmsrvolzpzk into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m10s
Reviewed-on: #93
2026-04-09 22:37:48 +02:00
5c6e2d43ac 🚀 Correctif bug shuffle et amélioration auto-advance
- Mise à jour de la version : `0.3.x` → `0.45`
- Correction critique du bug d'auto-skip après shuffle ou sélection manuelle : réinitialisation obligatoire de `has_played_flag` + positionnement explicite du playback source avant lancement
- Ajout de logs détaillés pour traçabilité des transitions (index, source)
- Gestion robustesse : retry automatique pour renderers JBL-like
- Meilleure synchronisation entre `play_from_index`, watcher et autoadvance
2026-04-09 22:37:27 +02:00
6fd11ab450 ♻️, feat: add retry logic for transient renderer errors
- Replace play_current_from_queue with new `playCurrentFromQueueWithRetry` method in control_point.rs
- Replace play_next_from_queue with `playNextFromQueueWithRetry` and add panic safety in musicrenderer.rs
- Implement retry logic (3 attempts, 200ms delay) for transient renderer errors like JBL Authentics 300
- Deprecate old methods without retry support
2026-04-09 22:37:16 +02:00
57c2552247 🚀 v0.3.42 → v0.3.43 — Correctif bug shuffle & amélioration auto-advance
- Mise à jour de la version (0.3.42 → 0.3.43) dans Cargo.toml, lock et version.txt
- Correctif critique du bug d’auto-saut après shuffle ou sélection manuelle : réinitialisation obligatoire de `has_played_flag` + positionnement explicite du playback source avant appel backend
- Ajout de logs détaillés pour tracer les appels à `play_from_index`, ainsi que l’état de la queue
- Intégration d’un retry (`play_current_from_queue_with_retry`) pour les renderers JBL-like qui échouent au premier Play
- Correction de race condition potentielle lors du changement d’index ou auto-saut
2026-04-09 22:37:16 +02:00
9935a5e5af 🚀 Version bump to v0.3.42 → v0.3.46 & fix shuffle/auto-advance race condition
- Bump version to v0.3.46 across Cargo.toml, lockfile & version.txt
- 🔧 Fix critical shuffle/auto_advance bug: ensure `has_played_flag` is reset and playback source set *before* backend calls in play_from_index, next/prev & queue init
- 📝 Add detailed logging for playback source transitions and index changes to debug timing issues (especially with JBL-like renderers)
- 🎯 Introduce `play_current_from_queue_with_retry()` for resilient playback start on flaky UPnP devices
2026-04-09 22:37:16 +02:00
3979d346a5 ✏️ amélioration des messages d'erreur avec nom du lecteur
- Ajout de `rendererName` computed property dans plusieurs composants pour afficher le nom convivial du lecteur (ou l'ID en fallback)
- Mise à jour des messages d’erreur dans CurrentTrack, TransportControls et VolumeControl pour inclure le nom du lecteur (ex: « Sonos One » — impossible de…)
- Amélioration du message d’erreur dans useRenderers.ts : « Impossible de rafraîchir la liste des lecteurs audio » + message plus spécifique pour les snapshots
- Ajout de l’import `computed` manquant dans VolumeControl.vue
2026-04-09 18:03:00 +02:00
6240179397 Merge pull request '⬆️ migration mdns →mddns-sd' (#92) from push-kmzstwrlymsr into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m33s
Reviewed-on: #92
2026-04-09 15:50:56 +02:00
934bbaf4b8 ⬆️ migration mdns →mddns-sd
- Remplacer la crate abandonnée `mdns` (3.0, non-UTF8) par activement maintenue et UTF‑compliant `mdns-sd` (0.19)
- Supprimer les dépendances inutiles `async‑std`, `futures-util` (uniquement utilisées dans le thread mDNS)
- Refactoriser `control_point.rs` : passer d’un bloc async bloquant à une boucle synchrone avec `ServiceDaemon.browse()` + récepteur blocant
- Simplifier drastiquement le parsing dans `chromecast_discovery.rs` via l’API haut‑niveau de mdns-sd (`ServiceInfo`, `get_property_val_str`) → ~80 lignes remplacées par 52
- Ajouter index SQLite `idx_metadata_key_value` pour accélérer les requêtes par origin_url
- Nettoyer exports inutiles dans `pmomediaserver::server_ext`
- Supprimer filtre de bruit obsolète `mdns=error` dans les logs
- Met à jour la version en 0.3.42
2026-04-09 15:50:18 +02:00
35e2e037f0 Merge pull request 'push-xvuyuzpqnxzs' (#91) from push-xvuyuzpqnxzs into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 10m40s
Reviewed-on: #91
2026-04-09 11:21:59 +02:00
9dee193947 ⬆️ version to v0.3.41 — async queue sync refactoring
- Add `SyncCancelled` error variant for non-fatal cancellations
- Refactor queue sync to async via `MusicQueue::schedule_sync()`
  - Extract browse+conversion into internal helper
- Update all `QueueBackend::sync_queue()` signatures to accept cancel token and on_ready callback  
- Implement early-start logic (on pivot preservation or first insert)
 - Add `QueueReadyToPlay` and  ‘ QueueSyncCancelled➔ SSE events
- Replace blocking `refresh_attached_queue_for()` with non-blocking async dispatch in control_point.rs  
- Bump version to v0.3.41
2026-04-09 11:21:11 +02:00
c128120697 🚀 Optimisations OpenHome : fast path LCS, connection pooling & metadata cache
- Ajout d’un chemin rapide (fast path) pour détecter les cas simples de sync_queue : append-only ou delete-from-end, évitant ReadList coûteux
- Mise en place d’un cache URI par track_id pour accélérer la comparaison de préfixe
- Intégration d’un agent HTTP statique via OnceLock pour réutiliser les connexions TCP (pooling), éliminant 95% des handshakes
- Mise à jour de Vite (7.3.1 → 7.3.2) dans le webapp
- Refonte de cache_metadata() pour accepter et stocke l’URI en parallèle des métadonnées
- Toutes les opérations d'insert/delete/replace_item exploitent désormais le cache URI pour la détection de pattern
- Respect strict des contraintes architecturales : OpenHome reste source unique, pas de miroir persistant
2026-04-09 08:40:10 +02:00
4b7c482bf3 [webapp] Add debug logging to search functionality
- Bump Cargo.lock version from v0.3.41 to v0.3.52
- Add console.log statements in ServerDrawer.vue and useMediaServers.ts to trace search flow: handleSearch call, server ID/query passed in API request
- Include logging for number of entries returned by search
2026-04-08 09:01:42 +02:00
4c70a939cd Merge pull request 'push-uplrprqrknlm' (#90) from push-uplrprqrknlm into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m33s
Reviewed-on: #90
2026-04-06 18:47:02 +02:00
77cfb7b3e3 ⬆️ version bump to v0.3.40
- Update crate and project versions from `v0.3.39` to "\""
- Reorder imports in media_server.rs for consistency
  – Move `error_codes` import before other soap modules in pmoupnp
  – Reorder crate imports to group external and local deps logically (soap_client, then core types)
- Improve Browse response formatting: wrap long expressions for readability
  – Add deterministic sort by `+dc:title` in Browse requests (alphabetical, ascending)
  – Preserve track ordering by NOT sorting items (track number preserved), only containers
- Add explicit sort of container list in content_handler.rs for deterministic output
2026-04-06 18:16:48 +02:00
abe8c1a4b2 Optimise frontend performance for large playlists (~1000 tracks)
- Virtualize queue list in QueueViewer.vue using RecycleScroller
- Add 300ms debounce on queue_updated refetches to prevent cascading JSON fetch
- Implement client-side pagination (100 items/page) + cache in PlayListManager.vue with sortedTracks memoization
- Limit browse infinite scroll memory usage via sliding window (200 items)
+ Minor CSS fixes for flexbox overflow/min-width to support virtualization
2026-04-06 17:48:41 +02:00
22b3a67417 🚀 Optimise OpenHome playlist sync performance (-75% SOAP calls)
- Augmente le batch ReadList de 64 à 256 (-75% appels SOAP)
- Élimine les doubles appel à queue_snapshot() dans sync_queue()–50% appels SOAP en moins
- Introduit lcs_flags_optimized() pour élaguer préfixe/suffixes communs (LCS O(n) vs quadratique)
- Met en place un polling adaptatif : 500ms actif / 5s veille
- Factorise parse_duration() et invalidation de caches via méthodes utilitaires
2026-04-06 17:12:46 +02:00
cba1c01b1b Merge pull request 'push-nuorysnrrttx' (#89) from push-nuorysnrrttx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m35s
Reviewed-on: #89
2026-04-06 09:53:45 +02:00
8be250b167 [FE] Révise la stabilité, réactivité et performance du frontend
- Corrige fuites mémoire SSE via nettoyage des listeners (P0)
- Remplace shallowRef<Map> par reactive(new Map()) pour réactivité native Vue (P1)
- Ajoute timeout/AbortController aux requêtes fetch dans l'API client (P4)
- Déboucle les watch() de useTabs avec debounce unique et try/finally sur isRestoringFromStorage (P7)
- Limite notifications à 5 + nettoyage des timers dans UI store (P15)
- Protège routes debug avec import dynamique uniquement en DEV + wildcard 403 (P8)
- Déplace SVG par défaut dans assets/default-cover.svg et import ?raw (P10)
- Ajoute @media prefers-reduced-motion aux animations CSS globales
- Factorise styles drawer-btns avec .drawer-icon-classe + ajoute --opacity-disabled (P13)
- Encode les clés de cache browse avec encodeURIComponent + ':' séparateur (P14)
- Implémente pagination infinite scroll dans browseContainer/loadMore
+ Supprime formatMsToShortTime alias (P9)
- Corrige truncate() pour éviter dépassement maxLength si suffix >=maxLength (P10)
- Valide structure des commandes PMOPlayer avant traitement
+ Met à jour version Cargo.toml et lock (0.3.39)
2026-04-06 09:53:10 +02:00
78c37e1732 🔖 move Frontent Review to Done
- Move `Frontend_Review.md` from Todo/Blackboard to Done/
- Reflects task completion status
2026-04-06 08:56:54 +02:00
164e3d9e5f fix(useRenderers): résoudre les problèmes de réactivité
- Supprimer l'import obsolète `toRaw` et simplifier les mises à jour via spread
- Corriger la mutation directe de `state_changed` en créant des objets immutables + appel à trigger
- Ajouter `triggerQueueReactivity()` pour déclencher la réactivité sur les événements queue
- Mettre à jour le doc de revue frontend pour marquer toutes les tâches comme corrigées
2026-04-06 08:56:36 +02:00
ac9cb3ef3f 🔧 Frontend review fixes & quality improvements
- Fixed race condition in apiCache TTL handling by passing ttl per-entry
- Removed double snapshot reassignment bug after switch cases in useRenderers.ts  
- Replaced `as any` cast on transport_state with runtime guard (isTransportState)
- Fixed invalidate() to preserve subscriptions instead of deleting them
- Migrated reactive(Map) → shallowRef + explicit reactivity triggers (triggerSnapshotReactive, triggerLoadingReactice)
- Added onUnmounted cleanup for debounce timer in useRenderer()
- Exposed resetSSE() to allow reinitialization after SSE reconnect
- Split deep watch in useTabs.ts into separate lightweight watches without {deep: true}
- Fixed swipe gesture to capture startX in onSwipeStart instead of using final clientX
- Added minimal JSON validation log for null responses (DEV mode only)
- Implemented ARIA labels on transport controls and volume slider
- Added UI notifications for network errors via uiStore.notifyError()
+ Removed obsolete toRaw() usage and import after shallowRef migration
- Added missing reactivity triggers for state_changed, queue_refreshing/updated cases
2026-04-06 08:51:12 +02:00
66ba31b12f ⬆️ version bump & fix race conditions in API cache and renderers
- Bump Cargo.lock version to `0.3.47`
- Fix race condition in API cache by using unique request keys and proper promise cleanup
  - Avoid duplicate pending requests with `request:${key}` prefix
- Improve renderer snapshot error handling: clear invalid snapshots on failure and always clean up loading flag in `finally`
- Add debounce to renderer refresh logic (500ms) preventing excessive concurrent fetches
  - Use local `currentRendererId` to avoid stale ID usage across async boundaries
2026-04-06 08:06:51 +02:00
bb06fbe30e Merge pull request 'push-osyzsknqnpom' (#88) from push-osyzsknqnpom into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m55s
Reviewed-on: #88
2026-04-06 00:22:38 +02:00
19451068f1 [webapp] Add retry logic and improve renderer registration
- Added automatic retries with exponential backoff to fetchRenderers
- Improved WebRenderer registration: handle stale players, avoid duplicate registrations,
  and force renderers refresh after successful connection
- Bumped version to v0.3.37 (Cargo: 0.3.36 → Cargo.lock mismatch fixed by updating to final version)
2026-04-06 00:22:03 +02:00
23b1ce7661 :recycle!: Normalize UDN comparison and improve WebRenderer detection
- Fix renderer filtering logic to normalize `udn` and id by stripping 'uuid:' prefix
- Replace fragile model_name-based detection with reliable friendly-name check for WebRenderers ('Web Audio – ' prefix)
- Add `manufacturer` field to renderer types and registry
- Update badge count in UnifiedControlView.vue to use filtered renderers
2026-04-06 00:21:37 +02:00
813fc97fa1 ⬆️ version bump to v0.3.6
- Bump crate and app versions from `v0.3.5` to v"\n- Add retry logic with exponential backoff in `fetchRenderers`
- Improve re-registration robustness in `useWebRenderer` (stale player handling, instanceId error recovery)
- Trigger forced renderer refresh after successful registration
2026-04-06 00:21:37 +02:00
1459958978 Merge pull request 'push-nvqvwnmvprww' (#87) from push-nvqvwnmvprww into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m29s
Reviewed-on: #87
2026-04-05 21:48:08 +02:00
e055133e4f ⬆️ version bump to v0.3.35 & refactor MediaRenderer stream URL handling
- Bump version to v0.3.35 in Cargo.toml, lockfile and `version.txt`
- Refactor MediaRenderer to accept dynamic stream URL base instead of hardcoded `/api/webrenderer` path
- Remove `PlayerStateReport`, moving it to pmoweb/renderer as local type for better encapsulation
- Update `register_or_reconnect` and device creation to accept renderer name, stream URL base via closure
- Replace `update_player_state` and `get_pending_command`, delegating state/command access directly via instance lookup
- Simplify device creation by using `Device::new_from_config` and removing manual model_name setting
2026-04-05 21:46:59 +02:00
e375d447d4 ♻️ Reorganize crates architecture (T5) — separate core and browser adapters
- Migrate pmomediarenderer: extract all core logic (adapter, handlers, pipeline etc.) into dedicated modules
- Rename types to MediaRenderer* for clarity (Web → Mediarenderer)
pmowebrenderer becomes a pure browser adapter crate
- Update Cargo.toml dependencies accordingly, add pmoserver feature propagation  
PMOMusic: remove obsolete MEDIA_RENDERER import
- Update all imports across workspace to reflect new crate boundaries
2026-04-05 21:21:45 +02:00
fe2d755b4a webrenderer : câblage adapter & nettoyages terminés
- Phase 1.5/2.6 : adapter BrowserAdapter instancié dans WebRendererInstance et câblé aux handlers (Flush/Stop/Pause via deliver())
- Phase 2.4 : Flush envoyé au device sur TrackEnded (Weak<dyn DeviceAdapter> évite les cycles de référence)
- Phase 1.6 : méthodes browser-spécifiques supprimées de RendererRegistry (set_player_command, has_current_uri…), remplacée par get_instance() + accès direct adapter/pipeline
- handlers.rs : flush/stop et pause livrés via l'adapter dans stop_handler/pause_handle
- register.rs : endpoints /play, pause et set_uri mis à jour pour utiliser l'adapter
- pipeline.rs : adapter exposé dans PipelineHandle et passé au event listener via Weak
2026-04-05 20:37:27 +02:00
c13de9f46b webrenderer architecture evolution (Phases P0–P4)
- Phase 1: Introduce DeviceCommand enum and BrowserAdapter in adapter.rs
- Fix P0 bug (play_handler now checks URI before state change)
- Phase 2: Wire flac_handle.pause/resume into pause_handler/stop/play
- Add VecDeque<DeviceCommand> to RendererState, replace Option<Value>
- Phase 3: Add AudioContext + exponential backoff reconnect in PMOPlayer.ts
- Fix position format (seconds_to_upnp_time) and add /nowplaying, /state endpoints
- Phase 4: Register new HTTP routes in config.rs and implement handlers
2026-04-05 15:02:57 +02:00
32e3a18895 ♻️ refactor(webrenderer): architecture device-agnostic et finition browser
- Introduit DeviceAdapter trait pour isoler la livraison aux devices (browser, Android Auto…)
- Remplace player_command Option<serde_json::Value> par VecDeque typée DeviceCommand
- Corrige P0 (play_handler bloqué sans URI) et résout les bugs de pause/flush non transmis au browser
- Ajoute AudioContext dans PMOPlayer.ts pour réduire le buffer décodé (~5s → ~50ms)
- Implémenter auto-reconnect sur coupure réseau HTTP
- Unifie le format de position en HH:MM:SS (UPnP)
- Ajoute endpoints JSON /nowplaying et /state pour les futurs adaptateurs
— Restructuration modulaire préparée (core/ vs browser/) pour facilité l'ajout de devices
2026-04-05 14:44:42 +02:00
76a1c89137 🔧 Refactor PMOPlayer and WebRenderer command handling
- Extract beforeunload handler in useWebrenderer.ts to ensure consistent unregister() calls
- Remove unused AudioContext from PMOPlayer and clean up destroy()
  - Add guaranteed final report via sendBeacon during destruction
- Simplify playStream() in PMOPlayer by removing URL rewriting logic (now handled server-side)
- Update play_handler to pass instance_id and store stream command atomically
  - Avoid race conditions when setting current_uri before sending Play control event
- Remove obsolete WebSocket message types from messages.rs (now using HTTP polling)
  - Keep only core PlaybackState enum
- Fix registry::get_player_command to avoid nested locks on HashMap + Sharedstate Arcs  
- Add missing PlaybackState::Transitioning in pipeline.rs TrackEnded event
- Refactor renderer service variable registration using helper add_var() function
2026-04-05 14:32:30 +02:00
fe9dd9aba6 ♻️ refactor(pmowebrenderer): factoriser handlers et supprimer duplications
- Remplacer les Arc+Box::pin manuels par la macro action_handler!(captures(...))
- Supprimer helpers locaux dupliqués dans renderer.rs (add_var, add_action)
- Extraire handlers génériques pour GET requests
- Factoriser extraction metadata dans set_uri_handler /set_next_uriHandler  
- Simplifier build_renderingcontrol en retirant pipeline inutile
- Mettre à jour edition Rust de 2021 vers 2024 dans tous les Cargo.toml
- Corriger pattern matching inutile `ref` sur déréférencement dans pmoaudio et pmoflac
2026-04-05 14:09:46 +02:00
512dcd1803 ♻️ refactor(renderer): remplacer macros par fonctions et centraliser la logique
- Remplacer les macro_rules! `add_arg_in` et `add_out`, ainsi que la duplication manuelle d’ajout de variables/actions, par des fonctions explicites (`add_arg_in`, `all arg_out`, etc.)
- Introduire des fonctions utilitaires génériques pour `add_var`, `add_action` afin d’unifier la gestion des erreurs
- Mettre à jour tous les services UPnP (`build_avtransport`, `renderingcontrol_service_builder` et autres) pour utiliser les nouvelles fonctions
- Supprimer le code redondant dans la construction des actions (ex: ~12 lignes dupliquées)
- Ajouter le fichier de plan `.kilo/plans/17xx-kind-falcon.md` décrivant la refonte
- Vérifier que les imports et signatures sont cohérents (ex: `&Arc<StateVariable>`)
2026-04-05 12:05:24 +02:00
623ce44774 (feat) Simplify UPnP argument definitions with macros
Introduce add_arg_in! andadd_outmacros to reduce boilerplate in UPnP action argument setup. Refactor all actions (Play, Stop,...GetPositionInfo) to use macros instead of repetitive add_argument calls. Also remove unused blank line in pipeline.rs.
2026-04-05 11:52:13 +02:00
6b0bfe86e3 🗑️ Remove unused imports, macros and fields
- Drop unnecessary `axum::extract::{Path(State)}` imports in config.rs and registry.rs
- Mark unused field `_position_sec` with `#[allow(dead_code)]`
- Add missing attribute to dead code struct
- Remove unused macros `add_action_arg!`, etc. and related types (`PlayerReport`)
2026-04-05 11:39:48 +02:00
d5b1ed5635 refactor(handlers): clean up UPnP action handlers and pipeline command logic
- Add documentation comments to all AVTransport, RenderingControl & ConnectionManager handlers
- Simplify play_handler: remove redundant state writes and clarify flow with comments  
- Deduplicate pipeline command logic in registry.rs by introducing send_pipeline_command helper
- Replace direct PlayerCommand usage with unified PipelineControl enum in registry and handlers  
- Fix handler signatures: clone pipeline once before Arc closure (next/previous)—avoid redundant clones
- Add helper macros for UPnP service factory to reduce boilerplate (add_action, add_var)
- Minor formatting fixes: sort imports and align handler assignments
2026-04-05 11:37:36 +02:00
546e8a782f 🗑️ Remove unused imports, macros and dead code
- Drop `Path`/``State``` from unused Axum imports in config.rs and registry
- Mark `_position_sec` field as `#[allow(dead_code)]`` in PositionUpdateRequest and PlayerStateReport
- Remove unused macro rules (`add_action_arg!`, `add_action!``, `` add_var!)``
- Delete unused PlayerReport struct and related handler code
2026-04-05 11:37:36 +02:00
340c69cb2b ⬆️ ureq v2→v3, add continuous stream detection
- Updateurequest dependency from v2 to latest major (v3.14)
- Add ureq as optional dependency in pmoaudio-ext
- Introduce is_continuous flag to UriSource and PlayerState for proper StreamType handling (Finite vs Continuous)
- Implement detect_continuous_stream() with URL pattern matching and HTTP HEAD header inspection (ICY, chunked encoding)
- Update send_track_boundary() to accept StreamType parameter
2026-04-04 18:00:47 +02:00
8924552696 Add StreamType for multi-client support and improved UPnP control
- Introduce `Streamtype` enum (Continuous vs Finite) to distinguish radio streams from finite tracks
- Enrich `TrackBoundary` sync marker with stream type for proper pause behavior per mode (silence vs backpressure)
- Update all sources and sinks to pass `StreamType` when creating track boundaries
  - Radio Paradise, HTTP source → Continuous (infinite)
- Improve UPnP control architecture: pause sends silence for radio, blocks pipeline via backpressure for tracks
- Prepare groundwork for multi-client DSP architecture with shared source and per-DSP pipelines
2026-04-04 14:37:32 +02:00
33b279ff1e Merge pull request 'push-kvzwrolnknzx' (#86) from push-kvzwrolnknzx into main
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m5s
Reviewed-on: #86
2026-04-04 12:59:09 +02:00
147 changed files with 10083 additions and 3231 deletions

View File

@@ -0,0 +1,151 @@
# Plan: pmowebrenderer - Améliorations et Multi-client avec DSP
## Objectifs
1. **Améliorer l'intégration UPnP Control** - Meilleur fonctionnement des commandes Play/Pause/Seek
- Contrôle piloté près de la sortie (streaming) plutôt qu'au début du pipeline
- Pour Pause: latence actuelle trop importante
- Play/Pause/Seek doivent fonctionner simultanément sur tous les clients
2. **Améliorer la performance - Latence** - Réduire le délai entre l'envoi et la lecture
3. **Ajouter le support multi-client avec DSP** - Chaque client peut avoir son propre pipeline DSP
## Comportement UPnP Control
### Mode Radio (flux infini)
- Pas de pause possible, hanya next ou stop
- Seek n'a pas de sens
### Architecture
```
PlayerSource → ResamplingNode → ToI24Node
├──→ [DSP Client 1] → StreamingOggFlacSink 1
├──→ [DSP Client 2] → StreamingOggFlacSink 2
└──→ ... (dynamique)
```
Le control point UPnP voit UN seul Media Renderer. Les commandes Play/Pause/Seek affectent TOUTES les sorties client simultanément.
## État Actuel
Le pipeline actuel est linéaire pour un seul client:
```
PlayerSource → ResamplingNode (96kHz) → ToI24Node → StreamingOggFlacSink
```
**Note importante:** Utiliser les crates pmoaudio et pmoaudio-ext existantes. Il est possible d'avoir plusieurs `StreamingOggFlacSink` consommant le même flux. Après ToI24Node, brancher en étoiles les différents DSP pour les différents clients.
## Plan d'Implémentation
### Phase 1: Amélioration UPnP Control
1. **Analyser les handlers existants** dans `handlers.rs`
2. **Identifier les problèmes** avec Play/Pause/Seek:
- Timing des transitions d'état
- Gestion des erreurs
- Synchronisation entre clients HTTP et état UPnP
3. **Améliorer la fiabilité** des commandes
- Piloter le contrôle près de la sortie (streaming)
- Différerencier le comportement radio vs piste finie
### Phase 2: Amélioration Latence
1. **Réduire le buffer** dans `StreamingOggFlacSink`
2. **Optimiser le pacing** (actuellement max 0.5s ahead)
3. **Améliorer la directité** du chemin audio
### Phase 3: Architecture Multi-client avec DSP
1. **Refactorer le pipeline** pour supporter plusieurs clients comme décrit ci-dessus
2. **Créer un système de DSP** dans pmoaudio ou pmoaudio-ext:
- Interface commune pour les effets audio
- Config des DSP via PMOconfig
- Room correction: equalizer, FIR filter, delay, gain
3. **Gérer le cycle de vie**:
- Création du pipeline par client
- Nettoyage lors de la déconnexion
- Partage de la source commune entre clients
## Fichiers à Modifier
- `pipeline.rs` - Refactoring pour multi-client
- `handlers.rs` - Amélioration UPnP control
- `stream.rs` - Gestion multi-client
- `state.rs` - État par client
- pmoaudio ou pmoaudio-ext pour les mécanismes DSP
## Défis Potentiels
- Performance CPU avec plusieurs clients
- Synchronisation des clients avec le même contenu
- Gestion du gapless entre les pistes avec multi-client
### Gestion de la Pause
**Option recommandée: Silence (zéros)**
- Pendant la pause, continuer à envoyer des zéros encodés en FLAC
- Le client HTTP maintient sa connexion TCP alive
- Pas de reconnexion nécessaire quand on reprend la lecture
- Avantage: Seamless pour le client
**Pourquoi pas réduction du sample rate:**
- Le header FLAC définit le sample rate en固定entête
- Changer le sample rate en cours de flux invalidate le flux entier
- Rebuild du flux serait plus complexe que le gain obtenu
- FLAC compresse très bien les zéros de toute façon (beaucoup de répétitions)
**Autre option envisagée mais non recommandée:**
- Suspendre l'envoi: Le client HTTP va timeout et se déconnecter
- Segment OGG avec metadata: Complexe à implémenter, nécessite modification du client
### Phase 0: StreamingOggFlacSink avec contrôle Pause
**Distinction Radio vs Pistes finies:**
| Mode | Comportement pendant Pause |
|------|---------------------------|
| **Radio (flux infini)** | Les chunks qui arrivent sont ignorés/perdus. On envoie du silence. La source continue à produire mais on n'en tient pas compte. |
| **Pistes finies** | On bloque la consommation des chunks. Par backpressure, le pipeline en amont s'arrête (TimerBufferNode arrête d'envoyer). La lecture est truly arrêtée. |
**Architecture actuelle analysée:**
```
AudioSegment → StreamingOggFlacSink → FLAC encoder → OGG wrapper → timed_broadcast → clients
```
**Implémentation suggérée:**
1. **État de lecture distingué:**
- `PlaybackMode::Radio` - ignore les chunks entrants pendant pause
- `PlaybackMode::Track` - bloque la consommation (backpressure)
2. **Dans SharedSinkContext:**
```rust
pub enum PlaybackMode {
Radio, // Flux infini - ignore chunks pendant pause
Track, // Piste finie - block par backpressure
}
pub playback_mode: PlaybackMode,
pub is_paused: Arc<AtomicBool>,
```
3. **Traitement différent selon le mode:**
- **Radio**: Si `is_paused`, envoyer silence (zéros) mais perdre les chunks entrants
- **Track**: Si `is_paused`, ne pas consommer les chunks → backpressure → arrêt du pipeline en amont
4. **Transition automatique:**
- Détecter le type de contenu via les métadonnées du TrackBoundary
- **Enrichir TrackBoundary** avec un champ `stream_type`:
```rust
pub enum StreamType {
Continuous, // Radio/webcast - flux infini
Finite, // Piste/album - flux avec fin définie
}
pub stream_type: StreamType,
```
- Si durée inconnue = Radio (Continuous), si durée connue = Track (Finite)

View File

@@ -0,0 +1,58 @@
# Refonte de `pmowebrenderer` Élimination des redondances
## Objectif
Réduire la duplication de code entre les fonctions de construction de services UPnP (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) et les macros dajout darguments (`add_arg_in!`, `add_arg_out!`).
Cela améliore la maintenabilité, la lisibilité et diminue le risque dincohérences.
## Étapes détaillées
1. **Création dune fonction générique `build_service`**
- Signature proposée:
```rust
fn build_service(
name: &str,
variables: Vec<Arc<Variable>>,
actions: Vec<Action>,
handlers: Vec<Handler>,
) -> Result<Service, FactoryError>
```
- Implémentation unique de lajout de variables, dactions et de handlers.
- Chaque fonction existante (`build_avtransport`, `build_renderingcontrol`, `build_connectionmanager`) appelle `build_service` avec les paramètres spécifiques.
2. **Refactorisation des macros**
- Remplacer `macro_rules! add_arg_in!` et `add_arg_out!` par des fonctions如此一来 :
```rust
fn add_arg_in(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
fn add_arg_out(action: &mut Action, name: &str, var: Arc<Argument>) -> Result<(), FactoryError>
```
- Ces fonctions encapsulent la logique dajout darguments et centralisent la gestion derreur.
3. **Mise à jour des implémentations**
- Modifier `build_avtransport`, `build_renderingcontrol`, `build_connectionmanager` pour déléguer à `build_service` et aux nouvelles fonctions dargument.
- Vérifier que les imports restent cohérents (ajouter `use` nécessaires pour `Variable`, `Handler`, etc.).
4. **Suppression des ancrés macros**
- Retirer les declarations `macro_rules! add_arg_in!` et `macro_rules! add_arg_out!` du fichier `renderer.rs`.
- Adapter le code appelant pour utiliser les fonctions concrètes.
5. **Tests et CI**
- Ajouter des tests unitaires couvrant les nouvelles fonctions `build_service`, `add_arg_in`, `add_arg_out`.
- Configurer le pipeline CI pour exécuter `cargo test` et `cargo clippy` afin de détecter déventuelles regressions.
6. **Documentation**
- Mettre à jour les commentaires pour refléter les nouvelles abstractions.
- Ajouter une section « Refactorisation » dans le `README` décrivant les changements.
## Impact attendu
- **Réduction** : ~12 lignes de code redondantes éliminées.
- **Maintenabilité** : modification centralisée de la logique de construction de services.
- **Robustesse** : baisse du risque dincohérences et de bugs liés à la duplication.
- **Lisibilité** : code plus explicite et plus proche du modèle de domaine.
## Prochaines actions
1. Implémenter les changements proposés dans les fichiers concernés.
2. Exécuter la suite de tests pour valider la refonte.
3. Commiter les modifications après revue.
---
Plan finalisé.

View File

@@ -0,0 +1,108 @@
# Plan: Suppression des duplications dans `@pmowebrenderer`
## Objectif
Éliminer les redondances de code identifiées lors de l'audit.
---
## Duplication 1: Helper functions dupliquées dans `renderer.rs`
**Fichiers affectés**: `src/renderer.rs`
**Problème**:
- Lignes 17-54: `add_arg_in`, `add_arg_out`, `add_var`, `add_action` définies
- Lignes 150-181: Dans `build_avtransport()`, réimplémentation locale avec closures `|svc, var| { ... }`
- Répétition de 20+ appels `add_var(&mut svc, &VAR)?` et `add_action(&mut svc, Arc::new(action))?`
**Solution**:
1. Supprimer les closures locales redéclarées (lignes 150-181)
2. Utiliser directement les fonctions helpers du haut du fichier
3. Créer une macro ou fonction utilitaire pour les appels répétés:
```rust
macro_rules! add_vars {
($svc:expr, $($var:expr),*) => { $({ add_var($svc, &$var)?; })* };
}
```
---
## Duplication 2: Pattern handlers boilerplate dans `handlers.rs`
**Fichiers affectés**: `src/handlers.rs`
**Problème**:
- `play_handler`, `stop_handler`, `pause_handler` (lignes 23-71): structure identique
- `next_handler`, `previous_handler` (lignes 74-95): clones
- Handlers GET (lignes 166-317): pattern `state.clone()` + `Box::pin(async move { ... set!() ... })` dupliqué
**Solution**:
1. Créer un helper générique:
```rust
fn make_state_handler<F>(state: SharedState, f: F) -> ActionHandler
where F: FnOnce(&mut ActionData, &RendererState) -> Result<ActionData, ActionError> + Send + 'static
```
2. Factoriser les closures `let state = state.clone()` dans chaque handler
---
## Duplication 3: Extraction metadata dupliquée
**Fichiers affectés**: `src/handlers.rs`
**Problème**:
- Lignes 118-123: `set_uri_handler` extraction metadata
- Lignes 146-151: `set_next_uri_handler` extraction metadata (identique)
**Solution**:
1. Extraire en fonction utilitaire:
```rust
fn extract_metadata(data: &ActionData, key: &str) -> String { ... }
```
---
## Duplication 4: Méthodes pipeline dans `registry.rs`
**Fichiers affectés**: `src/registry.rs`
**Problème**:
- `send_pipeline_command` (lignes 272-279) appelle `get_pipeline` (lignes 281-283)
- `load_uri` (lignes 286-290), `send_play_command` (lignes 293-296), `send_pause_command` (lignes 299-301) sont des wrappers quasi-identiques
**Solution**:
Consolider en méthodes génériques:
```rust
pub async fn send_command(&self, instance_id: &str, cmd: PipelineControl) {
if let Some(pipeline) = self.get_pipeline(instance_id) {
pipeline.send(cmd).await;
}
}
```
---
## Duplication 5: Feature flags avec code dupliqué
**Fichiers affectés**: `src/registry.rs`
**Problème**:
- Lignes 51-68 et 334-395: double impl de `create_instance` selon feature
**Solution**:
- Extraire la logique commune dans une fonction privée
- Utiliser `#[cfg]` seulement pour les différences (appel à pmoserver)
---
## Ordre de traitement suggéré
1. **Phase 1**: Helpers dans `renderer.rs` (les plus simples)
2. **Phase 2**: Handlers dans `handlers.rs` (plus complexe, nécessite macro)
3. **Phase 3**: Méthodes pipeline dans `registry.rs`
4. **Phase 4**: Feature flags
## Vérification
Après chaque phase, exécuter:
```bash
cargo check --package pmowebrenderer
```

View File

@@ -0,0 +1,225 @@
# Audit PMO Control - Optimisation Playlist OpenHome
## Résumé Exécutif
L'utilisateur rapporte des lenteurs significatives lors de la manipulation de playlists de ~1000 titres avec les renderers OpenHome. Les renderers Chromecast et UPnP (avec queue interne) ne sont pas affectés.
## État des Optimisations Deja Implémentées
Le precedent plan dans `Blackboard/Todo/enorme_playlist.md` a deja été partiellement implémenté:
| optimisation | Statut | Emplacement |
|-------------|-------|------------|
| MAX_BATCH = 256 pour ReadList | ✅ FAIT | `openhome.rs:1015` |
| Élimination double queue_snapshot() | ✅ FAIT | `replace_queue_with_pivot()` et `replace_queue_standard_lcs()` |
| LCS préfixe/suffixe (lcs_flags_optimized) | ✅ FAIT | `openhome.rs:869` |
| Polling adaptatif (is_active) | ✅ FAIT | `musicrenderer.rs:325-374` |
| Consolidation invalidation caches | ✅ FAIT | `openhome.rs:222-235` |
## Contraintes Protocolaires Découvertes
L'action `Insert` **ne supporte PAS l'insertion par lot** - chaque appel prend un seul Uri/Metadata.
## Nouvelles Optimisations (Contre-propositions Utilisateur)
### OPT-1: Fast Path pour 99% des cas de sync_queue
**Observation**: 99% des changements de playlist sont:
- Insertion de nouvelles tracks en **fin de queue**
- Délétion de tracks en **début de queue**
- Rarement des changements nécessitant un vrai alignement LCS
**Solution**: Ajouter une détection de pattern avant d'appeler LCS:
```rust
fn smart_sync(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
let current_ids = self.track_ids()?;
// Cas 1: Append only (insertion en fin)
if items.starts_with(&current_ids) {
// Fast path: juste ajouter les nouveaux items
return self.append_only(items.skip(current_ids.len()));
}
// Cas 2: Delete from beginning
if current_ids.starts_with(&items) {
// Fast path: supprimer de la fin
return self.delete_from_beginning(current_ids.len() - items.len());
}
// Cas 3: Full LCS only for complex reorderings
return self.replace_queue_standard_lcs(items);
}
```
**Impact**: 99% des sync_queue passent de O(N²) à O(N)
---
### OPT-2: Queue FIFO pour Opérations OpenHome (Thread Background)
**Concept**: Une file d'attente FIFO des opérations SOAP exécutée dans un thread dédié.
```rust
pub struct OpenHomeOpQueue {
queue: Arc<Mutex<Vec<OpenHomeOp>>>,
worker_handle: Option<JoinHandle<()>>,
}
pub enum OpenHomeOp {
Insert { uri: String, metadata: String, after_id: u32 },
Delete { track_id: u32 },
DeleteAll,
SeekId { id: u32 },
Play,
Pause,
Stop,
SetVolume { volume: u16 },
// Meta operations
UpdateMetadata { track_id: u32, metadata: String },
}
impl OpenHomeOpQueue {
/// Push operation to the FIFO queue
pub fn push(&self, op: OpenHomeOp) {
self.queue.lock().unwrap().push(op);
}
/// Push with priority (volume, play, stop - need fast response)
pub fn push_first(&self, op: OpenHomeOp) {
self.queue.lock().unwrap().push_front(op);
}
/// Clear all pending operations (client can flush)
pub fn clear(&self) {
self.queue.lock().unwrap().clear();
}
/// Worker thread consumes operations
fn worker_loop(&self) {
loop {
let op = self.queue.lock().unwrap().pop_front();
match op {
Some(op) => self.execute(op),
None => thread::sleep(Duration::from_millis(10)),
}
}
}
}
```
**Benefits**:
- UI non-bloquante (les operations sont lancées et exec en background)
- Batching naturel (plusieurs operations sont executes en sequence)
- Priorité via `push_first()` pour play/stop/volume
- `clear()` permet d'annuler les operations en attente (ex: playlist changée)
**Implémentation suggérée**:
1. Créer `src/queue/openhome_op_queue.rs` avec la structure
2. Intégrer dans `OpenHomeQueue` ou `OpenHomeRenderer`
3. Thread de worker lancé au démarrage du control point
---
### OPT-3: Métadonnées en Tâche de Fond
**Observation**: L'appli utilise-t-elle vraiment les métadonnées de la queue OpenHome, ou un cache local?
Si le control-point maintient son propre cache (plus probable):
- Les mises à jour de métadonnées peuvent être traitées en background
- Pas besoin de sync immédiate des métadonnées
**Solution**: Queue séparée pour les operations de métadonnées:
```rust
// Haute priorité (opérations critiques)
let high_priority_queue: OpenHomeOpQueue;
// Basse priorité (métadonnées)
let metadata_queue: OpenHomeOpQueue;
```
**Implémentation**:
1. Séparer les operations critiques (play/stop/seek/volume) de metadata
2. Metadata update traités en background avec délais
3. Le cache local du control-point est mis à jour indépendamment
---
### OPT-4: Connection Pooling HTTP
Chaque appel SOAP crée une nouvelle connexion. Avec 1000 insertions:
- Overhead TCP: ~10-50ms par appel
- Total: 10-50 secondes overhead réseau
**Solution**: Agent HTTP static avec connection reuse:
```rust
// soap_client.rs
static HTTP_AGENT: Lazy<ureq::Agent> = Lazy::new(|| {
Agent::config_builder()
.timeout_global(Some(Duration::from_secs(30)))
.build()
});
```
---
## Plan d'Implémentation Proposé
### Phase 1: Fast Path LCS (Priorité Haute)
1. Ajouter `detect_sync_pattern()` dans `openhome.rs`
2. Implémenter `append_only()` et `delete_from_beginning()`
3. Tester avec playlists réelles
### Phase 2: Queue FIFO Opérations (Priorité Haute)
1. Créer `src/queue/openhome_op_queue.rs`
2. Implémenter `push()`, `push_first()`, `clear()`
3. Thread worker avec loop de consommation
4. Intégrer dans `OpenHomeRenderer`
### Phase 3: Séparation Métadonnées (Priorité Moyenne)
1. Créer queue séparée pour metadata
2. Implémenter batch processing
### Phase 4: Connection Pooling (Priorité Basse)
1. Modifier `soap_client.rs` pour agent static
---
## Questions pour Clarification
1. **Cache Métadonnées**: Le control-point utilise-t-il vraiment les métadonnées de la queue OpenHome, ou maintient-il son propre cache qui est alimenté indépendamment?
2. **Priorité des Opérations**: Pour `push_first()`, quelles opérations nécessitent une réponse rapide?
- Volume (immédiat)
- Play/Pause/Stop (immédiat)
- Seek (rapide)
- Insert (peut être différé)
3. **Comportement en cas de conflit**: Si le client fait `clear()` et que le worker est en train d'exécuter une opération:
- Annuler l'opération en cours? ( risky - peut laisser le renderer dans un état inconsistent)
- Laisser finir l'opération en cours? (plus sur)
---
## Tests Recommandés
```bash
# Compiler
cargo build -p pmocontrol
# Benchmark LCS fast paths
# - Cas: append 100 tracks to 900 = O(N)
# - Cas: delete 100 from 900 = O(N)
# - Cas: reorder = O(N²) avec LCS
```
---
*Plan mis à jour avec contre-propositions utilisateur*
*Date: 2026-04-08*

View File

@@ -0,0 +1,651 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
## Vue d'ensemble
Le frontend de PMOMusic est une application Vue 3 + TypeScript avec Pinia, organisée autour
de composables réactifs, d'un client SSE centralisé et d'un cache API à plusieurs niveaux.
L'architecture générale est solide : séparation claire composables/services/vues, typage strict
(`strict: true` dans `tsconfig.app.json`), reconnexion SSE avec backoff exponentiel.
Cette revue documente les bugs avérés, les fragilités de conception et les axes d'amélioration
relevés lors d'une lecture complète des fichiers `pmoapp/webapp/src/`.
---
## Bugs
### 1. `apiCache.ts:130-133` — Mutation globale du TTL non réentrante
**Problème** : la méthode `fetch()` accepte un `ttl` optionnel par appel. Pour l'appliquer,
elle modifie `this.options.ttl` globalement avant d'appeler `this.set()`, puis le restaure :
```typescript
// apiCache.ts:130-133
if (ttl) {
const originalTtl = this.options.ttl;
this.options.ttl = ttl; // (A) modification globale
this.set(endpoint, data, params);
this.options.ttl = originalTtl; // (B) restauration
}
```
**Cause** : `fetcher()` est `await`-é (ligne 128) avant ce bloc. Pendant cet await, d'autres
microtasks peuvent s'intercaler et appeler `isFresh()` ou `set()`, qui lisent `this.options.ttl`.
Si deux appels `fetch()` avec des `ttl` différents sont en vol simultanément, la restauration
de (B) peut effacer la valeur posée par le second appel concurrent, ou (A) peut lire un TTL
modifié par un autre appel.
**Solution** : passer le `ttl` directement à `set()` comme paramètre, sans modifier l'état
partagé :
```typescript
// Dans set() : ajouter un paramètre ttl optionnel
set<T>(endpoint: string, data: T, params?: ..., etag?: string, ttl?: number): void {
const key = this.makeKey(endpoint, params);
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl: ttl ?? this.options.ttl, // TTL par entrée, pas global
etag,
});
this.notifySubscribers(key, data);
}
// Dans isFresh() : lire le ttl de l'entrée
private isFresh(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
return Date.now() - entry.timestamp < (entry.ttl ?? this.options.ttl);
}
// Dans fetch() : supprimer le bloc de mutation globale
this.set(endpoint, data, params, undefined, ttl);
```
---
### 2. `useRenderers.ts:151 + 222` — Réassignation post-switch écrase le nouvel objet
**Problème** : le handler `onRendererEvent` termine par une ligne inconditionnelle :
```typescript
// useRenderers.ts:222
snapshotState.snapshots.set(rendererId, snapshot);
```
Cette ligne s'exécute pour **tous** les types d'événements après le `switch`, y compris pour
`position_changed` et `metadata_changed` qui ont déjà créé et stocké un nouvel objet dans le
Map à l'intérieur du switch :
```typescript
// position_changed — ligne 151 : stocke newSnapshot
snapshotState.snapshots.set(rendererId, newSnapshot);
break;
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
// metadata_changed — ligne 176 : stocke un spread
snapshotState.snapshots.set(rendererId, { ...snapshot, state: { ...snapshot.state } });
break;
// → puis ligne 222 écrase avec snapshot (proxy d'origine)
```
**Cause** : `break` sort du `switch` mais pas de la fonction. La ligne 222 est atteinte dans
tous les cas.
**Conséquence** : les objets créés pour forcer la détection de changement par Vue sont
immédiatement écrasés. Le mécanisme de réactivité fonctionne malgré tout (le proxy muté est
re-stocké), mais la logique est trompeuse et fragile : si Vue venait à optimiser la détection
d'identité des objets réactifs, cette redondance deviendrait un bug visible.
**Solution** : supprimer la ligne 222 et s'assurer que chaque branche du switch stocke
explicitement son résultat dans la Map. Les branches `volume_changed`, `mute_changed` et
`binding_changed` qui mutent directement `snapshot` doivent aussi créer un nouvel objet :
```typescript
case "volume_changed":
snapshotState.snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, volume: event.volume },
});
break;
case "mute_changed":
snapshotState.snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, mute: event.mute },
});
break;
// idem pour binding_changed, stream_state_changed
```
Supprimer la ligne 222. Chaque case devient responsable de son stockage, ce qui élimine aussi
le besoin de `toRaw()`.
---
### 3. `useRenderers.ts:122` — `as any` sur `transport_state`
**Problème** :
```typescript
// useRenderers.ts:122
snapshot.state.transport_state = event.state as any;
```
**Cause** : `event.state` est typé `string` (type SSE générique), alors que
`transport_state` est une union littérale (`"PLAYING" | "PAUSED" | "STOPPED" | ...`).
Le cast `as any` contourne la vérification de type.
**Conséquence** : si le backend envoie une valeur non prévue (ex. `"TRANSITIONING"`), elle
sera stockée sans validation. Les composants qui comparent `transport_state === "PLAYING"`
ne matcheront pas et l'UI restera muette.
**Solution** : définir un guard de type ou une assertion dans `types.ts` :
```typescript
// services/pmocontrol/types.ts
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "NO_MEDIA" | "TRANSITIONING";
export function isTransportState(s: string): s is TransportState {
return ["PLAYING", "PAUSED", "STOPPED", "NO_MEDIA", "TRANSITIONING"].includes(s);
}
```
```typescript
// useRenderers.ts — case state_changed
case "state_changed":
if (isTransportState(event.state)) {
snapshot.state.transport_state = event.state;
} else {
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
}
break;
```
---
### 4. `apiCache.ts:181-200` — `invalidate()` supprime silencieusement les subscriptions actives
**Problème** :
```typescript
// apiCache.ts:197-200
keysToDelete.forEach(key => {
this.cache.delete(key);
this.subscriptions.delete(key); // ← subscriptions perdues sans notification
});
```
**Cause** : lors d'une invalidation (ex. après un SSE event), les callbacks enregistrés via
`subscribe()` sont supprimés de la Map. Les composants ne sont pas notifiés de la suppression
et ne reçoivent plus les futures mises à jour même après un refetch.
**Conséquence** : un composant qui a appelé `apiCache.subscribe(...)` et qui survit à une
invalidation devient « sourd » sans le savoir.
**Solution** : conserver les subscriptions lors d'une invalidation — seule la donnée en cache
est périmée, pas les abonnés :
```typescript
invalidate(pattern: string): void {
const keysToDelete: string[] = [];
// ... construction de keysToDelete inchangée ...
keysToDelete.forEach(key => {
this.cache.delete(key);
// NE PAS supprimer this.subscriptions.get(key)
// Les abonnés seront notifiés lors du prochain set()
});
}
```
Si l'on veut notifier les abonnés d'une invalidation (pour qu'ils affichent un état de
chargement), ajouter un callback optionnel `onInvalidate` dans l'interface de subscription.
---
## Fragilités de conception
### 5. `useRenderers.ts:7,143-151` — `toRaw()` comme contournement de réactivité Vue
**Problème** : le code utilise `toRaw()` pour extraire l'objet brut d'un proxy Vue avant de
faire un spread, afin que la copie ne contienne pas de getters réactifs qui pointent vers
l'objet original :
```typescript
// useRenderers.ts:143-151
const rawState = toRaw(snapshot.state);
const newState = { ...rawState };
const newSnapshot = { ...snapshot, state: newState };
snapshotState.snapshots.set(rendererId, newSnapshot);
```
**Cause** : les Maps imbriquées dans un objet `reactive()` ont un comportement de réactivité
peu prévisible dans Vue 3. Vue ne détecte pas les mutations d'éléments d'une Map réactive si
la référence de la Map elle-même ne change pas.
**Solution recommandée** : remplacer `reactive(new Map())` par `shallowRef(new Map())` pour
les Maps qui contiennent des données complexes. La réactivité se déclenche en remplaçant la
Map entière (ou en forçant un `triggerRef`) :
```typescript
// Au lieu de :
const snapshotState = reactive<RendererSnapshotState>({ snapshots: reactive(new Map()), ... });
// Utiliser :
const snapshots = shallowRef(new Map<string, FullRendererSnapshot>());
// Pour déclencher la réactivité après mutation :
snapshots.value = new Map(snapshots.value); // ou triggerRef(snapshots)
```
Cela rend la propagation de réactivité explicite et élimine le besoin de `toRaw()`.
---
### 6. `useRenderers.ts:552-570` — Timer de debounce non nettoyé dans `useRenderer()`
**Problème** : `useRenderer()` crée un timer de debounce local qui n'est jamais nettoyé si
le composant parent est démonté :
```typescript
// useRenderers.ts:553-566
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const REFRESH_DEBOUNCE_MS = 500;
async function refresh(force = true) {
if (refreshDebounceTimer !== null) return;
refreshDebounceTimer = setTimeout(() => {
refreshDebounceTimer = null;
}, REFRESH_DEBOUNCE_MS);
await Promise.all([...]);
}
```
**Cause** : pas d'appel à `clearTimeout` dans un `onUnmounted`. Si le composant est démonté
pendant les 500 ms du debounce, le timer continue de s'exécuter.
**Conséquence** : fuite mémoire potentielle ; dans des cas extrêmes (navigation rapide), le
callback peut tenter de déclencher un fetch sur un composant déjà démonté.
**Solution** :
```typescript
import { onUnmounted } from 'vue';
// Dans useRenderer() :
onUnmounted(() => {
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
refreshDebounceTimer = null;
}
});
```
---
### 7. `useRenderers.ts:45-46` — Singleton SSE initialisé par flag de module non réinitialisable
**Problème** :
```typescript
// useRenderers.ts:45-46
let sseInitialized = false;
function ensureSSEInitialized() {
if (sseInitialized) return;
// ...
sseInitialized = true;
}
```
**Cause** : ce flag de module est persistant pour toute la durée de vie de la page. Si la
connexion SSE est perdue puis rétablie avec un nouvel objet `PMOControlSSE`, le handler
`onRendererEvent` précédent peut ne plus être actif, mais `sseInitialized` empêche sa
re-enregistration.
**Conséquence** : après une déconnexion et reconnexion SSE, les événements renderer peuvent
ne plus être reçus par `useRenderers` jusqu'à un rechargement de page.
**Solution** : exposer une fonction `resetSSE()` qui remet `sseInitialized = false` et la
connecter à l'événement de reconnexion du service SSE. Alternativement, utiliser le pattern
`provide/inject` ou un store Pinia pour gérer le cycle de vie SSE explicitement, en lieu et
place du flag de module.
---
## Qualité du code
### 8. `useTabs.ts` — Deep watch déclenchant une sérialisation localStorage à chaque mutation
**Problème** : le watch qui persiste l'état des onglets utilise `{ deep: true }` sur un
tableau qui peut contenir jusqu'à 12 entrées avec des métadonnées :
```typescript
// useTabs.ts:349-355
watch(
() => [state.tabs, state.activeTabId, state.tabHistory],
() => { saveToLocalStorage(); },
{ deep: true },
);
```
**Cause** : `{ deep: true }` traverse récursivement toutes les propriétés observées.
`saveToLocalStorage()` appelle `JSON.stringify` sur l'ensemble des tabs à chaque mutation,
même mineure (ex. changement de `activeTabId`).
**Solution** : surveiller les propriétés individuellement et sérialiser uniquement ce qui
change, ou utiliser un computed pour construire la clé de changement :
```typescript
// Watch séparés, sans deep
watch(() => state.activeTabId, saveToLocalStorage);
watch(() => state.tabHistory.length, saveToLocalStorage);
watch(
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '')).join('|'),
saveToLocalStorage,
);
```
---
### 9. `UnifiedControlView.vue:40-49` — Swipe : `clientX` final au lieu de la position initiale
**Problème** :
```typescript
// UnifiedControlView.vue:40-49
useSwipe(viewRef, {
threshold: 50,
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
if (swipeDirection === "right" && !drawerOpen.value) {
const touch = _e.changedTouches[0];
if (touch && touch.clientX < 50) { // ← position finale du doigt
drawerOpen.value = true;
}
}
},
});
```
**Cause** : `onSwipeEnd` reçoit l'événement `touchend`. Dans `changedTouches`, `clientX`
est la position **finale** du doigt (après le swipe), pas la position initiale. Un swipe
commençant à `x=30` et terminant à `x=150` a `clientX=150` dans `touchend` — la condition
`< 50` ne sera jamais vraie pour un swipe horizontal significatif.
**Conséquence** : le geste de swipe depuis le bord gauche ne fonctionne probablement pas
sur les appareils tactiles.
**Solution** : capturer la position initiale dans `onSwipeStart` :
```typescript
const swipeStartX = ref(0);
useSwipe(viewRef, {
threshold: 50,
onSwipeStart(e: TouchEvent) {
swipeStartX.value = e.touches[0]?.clientX ?? 0;
},
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
drawerOpen.value = true;
}
},
});
```
---
### 10. `api.ts:50` — Réponse JSON non validée avant le cast TypeScript
**Problème** :
```typescript
// api.ts:50
return response.json(); // retour typé T par inférence, sans validation
```
**Cause** : `response.json()` retourne `Promise<any>`. TypeScript accepte le retour car la
méthode `request<T>` promet `Promise<T>`, mais aucune validation de structure n'est effectuée.
**Conséquence** : si le backend renvoie un schéma légèrement différent (champ renommé, type
changé), le bug se manifestera loin du point d'appel avec un message cryptique. En
développement avec plusieurs instances en parallèle (`udn_prefix` différent), une requête
dirigée vers la mauvaise instance peut retourner un format inattendu.
**Solution pragmatique** : ajouter une validation légère avec un type guard pour les réponses
critiques, ou au minimum loguer la réponse brute en mode développement :
```typescript
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
// ...
const data = await response.json();
if (import.meta.env.DEV && data == null) {
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
}
return data as T;
}
```
Pour les endpoints critiques (`getRendererFullSnapshot`, `getRenderers`), envisager un
schéma de validation Zod ou une assertion runtime minimale.
---
## Accessibilité et feedback utilisateur
### 11. Absence de labels ARIA sur les contrôles transport
Les composants `TransportControls.vue` et `VolumeControl.vue` contiennent des boutons
iconiques (play, pause, stop, volume) sans attributs `aria-label`. Les lecteurs d'écran
ne peuvent pas identifier la fonction de ces contrôles.
**Correction minimale** :
```html
<!-- TransportControls.vue -->
<button @click="play" aria-label="Lecture">
<PlayIcon />
</button>
<button @click="pause" aria-label="Pause">
<PauseIcon />
</button>
```
---
### 12. Erreurs réseau silencieuses sans feedback utilisateur
Plusieurs appels critiques sont lancés en fire-and-forget sans propagation vers l'UI :
```typescript
// useRenderers.ts:86
void fetchRenderers(true); // erreur loggée en console uniquement
// useRenderers.ts:89
void fetchRendererSnapshot(rendererId, { force: true }); // idem
```
Le store `ui.ts` dispose d'un système de notifications toast (`addNotification`). Les erreurs
de réseau devraient y être propagées pour informer l'utilisateur :
```typescript
import { useUIStore } from '@/stores/ui';
const uiStore = useUIStore();
// Dans le handler SSE :
try {
await fetchRenderers(true);
} catch {
uiStore.addNotification({
message: 'Impossible de rafraîchir la liste des renderers',
type: 'error',
});
}
```
---
## Points forts à conserver
Ces patterns sont bien conçus et ne doivent pas être modifiés dans les corrections ci-dessus :
- **Déduplication des requêtes en vol** (`apiCache.ts:100-112`) : évite les appels réseau
redondants quand plusieurs composants demandent la même ressource simultanément.
- **Watch sélectif sur les IDs** (`UnifiedControlView.vue:139-150`) : calcule une clé
synthétique `ids.join(',')` au lieu d'un deep watch sur le tableau de renderers.
- **Backoff exponentiel SSE** (`sse.ts`) : reconnexion progressive 1s→2s→4s→8s→16s→30s
avec cap. Implémentation robuste.
- **Fetch batch contrôlé** (`useRenderers.ts:351-383`) : `fetchBatchSnapshots()` avec
concurrence limitée (défaut 3) et délai inter-batches. Évite de saturer le réseau au
démarrage.
- **`filterRenderers` par UDN** (`UnifiedControlView.vue:107-120`) : filtre les WebRenderers
étrangers en comparant le UDN normalisé. Logique correcte avec gestion du préfixe `uuid:`.
---
## État d'avancement — corrections appliquées (commit 2026-04-06)
Les 12 points de la revue ont été traités. Le tableau ci-dessous récapitule ce qui a été
fait et ce qui reste à finir.
| # | Problème | État |
|---|----------|------|
| 1 | `apiCache` — TTL mutation globale | ✅ Corrigé (`ttl` par entrée dans `CacheEntry`, `isFresh()` lit `entry.ttl`) |
| 2 | `useRenderers` — double réassignation post-switch | ✅ Corrigé (chaque `case` responsable, ligne 222 supprimée) |
| 3 | `useRenderers``as any` sur `transport_state` | ✅ Corrigé (`isTransportState` guard dans `types.ts`) |
| 4 | `apiCache``invalidate()` détruisait les subscriptions | ✅ Corrigé (`this.subscriptions.delete` supprimé) |
| 5 | `useRenderers``toRaw()` / `reactive(Map)` fragile | ✅ Migré vers `shallowRef` + helpers `triggerSnapshotReactivity()` / `triggerLoadingReactivity()` |
| 6 | `useRenderer()` — timer debounce non nettoyé | ✅ Corrigé (`onUnmounted` + `clearTimeout`) |
| 7 | Singleton SSE non réinitialisable | ✅ Corrigé (`resetSSE()` exposé dans le retour de `useRenderers()`) |
| 8 | `useTabs` — deep watch coûteux | ✅ Corrigé (watches séparés sans `deep: true`) |
| 9 | Swipe — `clientX` final au lieu d'initial | ✅ Corrigé (`swipeStartX` capturé dans `onSwipeStart`) |
| 10 | `api.ts` — JSON non validé | ✅ Corrigé (log dev-mode pour réponse nulle) |
| 11 | Absence de labels ARIA | ✅ Corrigé (4 boutons transport + bouton mute + slider volume) |
| 12 | Erreurs réseau silencieuses | ✅ Corrigé (`uiStore.notifyError()` dans `fetchRenderers` et `fetchRendererSnapshot`) |
---
## Tâches restantes — ✅ Toutes corrigées (commit 2026-04-06)
| # | Problème résiduel | État |
|---|-------------------|------|
| A | `state_changed` — mutation directe sans trigger réactivité | ✅ Corrigé (spread + `triggerSnapshotReactivity()`) |
| B | `queue_refreshing`/`queue_updated``queueRefreshingIds` sans trigger | ✅ Corrigé (`triggerQueueReactivity()` ajoutée et appelée) |
| C | `toRaw` import obsolète, `position_changed` simplifié | ✅ Corrigé (`toRaw` supprimé de l'import, spread direct) |
---
### A. `useRenderers.ts` — `state_changed` : mutation directe sans déclenchement de réactivité
**Problème** : avec la migration vers `shallowRef`, les objets dans la Map ne sont plus des
proxies Vue. La mutation directe de `snapshot.state.transport_state` ne déclenche aucune
réactivité — les composants ne se mettront pas à jour quand l'état de transport change :
```typescript
// useRenderers.ts — case state_changed (code actuel)
case "state_changed":
if (isTransportState(event.state)) {
snapshot.state.transport_state = event.state; // ← mutation directe, pas de trigger
}
break;
```
**Solution** : créer un nouvel objet, comme pour `volume_changed` et `mute_changed` :
```typescript
case "state_changed":
if (isTransportState(event.state)) {
snapshots.value.set(rendererId, {
...snapshot,
state: { ...snapshot.state, transport_state: event.state },
});
} else {
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
}
break;
```
---
### B. `useRenderers.ts` — `queue_refreshing` / `queue_updated` : mutations de `queueRefreshingIds` sans trigger
**Problème** : `queueRefreshingIds` est un `shallowRef<Set>`. Les appels `.add()` et
`.delete()` sur `.value` ne déclenchent pas la réactivité de `shallowRef` :
```typescript
// useRenderers.ts — case queue_refreshing (code actuel)
case "queue_refreshing":
queueRefreshingIds.value.add(rendererId); // ← pas de trigger
break;
case "queue_updated":
queueRefreshingIds.value.delete(rendererId); // ← pas de trigger
break;
```
Le composable `isQueueRefreshing(id)` retourne `queueRefreshingIds.value.has(id)`. Sans
trigger, les templates qui dépendent de cette valeur ne se recalculeront pas.
**Solution** : ajouter une fonction `triggerQueueReactivity()` analogue à
`triggerLoadingReactivity()` et l'appeler après chaque mutation :
```typescript
function triggerQueueReactivity() {
queueRefreshingIds.value = new Set(queueRefreshingIds.value);
}
// Dans le switch :
case "queue_refreshing":
queueRefreshingIds.value.add(rendererId);
triggerQueueReactivity();
break;
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.value.delete(rendererId);
triggerQueueReactivity();
void fetchRendererSnapshot(rendererId, { force: true });
break;
```
---
### C. `useRenderers.ts` — import `toRaw` et commentaire obsolètes
**Problème** : avec `shallowRef`, les objets stockés dans `snapshots.value` sont de simples
objets JavaScript (jamais des proxies Vue). L'appel `toRaw(snapshot.state)` dans
`position_changed` est devenu un no-op, et le commentaire qui le justifie est trompeur :
```typescript
// useRenderers.ts:160-163 (commentaire et import obsolètes)
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
const rawState = toRaw(snapshot.state);
const newState = { ...rawState };
```
**Solution** : supprimer le `toRaw()`, simplifier en spread direct, retirer `toRaw` de
l'import ligne 7 :
```typescript
// Remplacer :
import { ref, shallowRef, computed, toRaw, type Ref, onUnmounted } from "vue";
// Par :
import { ref, shallowRef, computed, type Ref, onUnmounted } from "vue";
// Dans position_changed :
const newSnapshot = {
...snapshot,
state: {
...snapshot.state,
position_ms: positionMs ?? 0,
duration_ms: durationMs,
},
};
snapshots.value.set(rendererId, newSnapshot);
```
Cette simplification rend aussi le cas `position_changed` cohérent avec les autres cases
(`volume_changed`, `mute_changed`, etc.) qui construisent directement l'objet final sans
passer par une variable intermédiaire.

View File

@@ -0,0 +1,19 @@
# Rapport : Optimisation performance OpenHome playlist
## Résumé
Les optimizations implementées réduisent significativement le temps de synchronisation des playlists OpenHome de ~1000 titres. Les principales améliorations : passage du batch ReadList de 64 à 256 (75% appels SOAP), elimination des doubles appels queue_snapshot() (50% appels SOAP), et introduction du polling adaptatif avec intervalle long en veille (5s vs 500ms).
## Fichiers modifies
1. `pmocontrol/src/queue/openhome.rs`
- Batch ReadList augmente de 64 a 256
- Signature de replace_queue_with_pivot et replace_queue_standard_lcs modifiee pour accepter snapshot et current_track_ids
- Appel a sync_queue mis a jour pour passer les donnees deja disponibles
-Nouvelle fonction lcs_flags_optimized avec elimination pre/suffixe communs
2. `pmocontrol/src/music_renderer/watcher.rs`
- Ajout du champ is_active dans WatchedState pour le polling adaptatif
3. `pmocontrol/src/music_renderer/musicrenderer.rs`
- Boucle watcher avec intervalle adaptatif (500ms actif, 5000ms veille)
- Marqueurs is_active=true dans play(), stop(), seek_rel_time(), sync_queue()

View File

@@ -1,138 +1,443 @@
Parfait. Voici un **schéma fonctionnel minimal** pour un **MediaRenderer UPnP privé par navigateur** avec **token**. Lidée est de rester fidèle à ton backend Rust existant et à la webapp Vue.js. En s'appuyant sur l'architecture de PMOMusic, j'aimerais que tu proposes un plan détaillé pour implémenter un tel système de Média Renderer.
# Web Media Renderer - Architecture
- L'application web se trouve dans: [@webapp](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmoapp/webapp)
- Tu as un prototype de Média Renderer dans: [@pmomediarenderer](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmomediarenderer)
- Le contrôle point est dans : [@pmocontrol](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/pmocontrol)
- Tu implémenteras ce nouveau système de Média Renderer dans la CRATe pmowebrenderer
## Vision
Tu mettras une version du plan en Markdown dans le répertoire [@Architecture](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Architecture) .
Système de Media Renderer pilotable à distance via UPnP, exposant un flux audio vers différents types de lecteurs physiques.
---
## Architecture globale en 4 parties
## 1. Flow général
```
Browser (Vue.js Control Point)
┌───────────────┐
UI / audio │
WebSocket │
└───────▲───────┘
│ token
Rust backend (UPnP MediaRenderer)
┌───────────────────────────┐
│ Token → Renderer mapping │
│ Device XML / SOAP endpoints│
│ Play/Pause/Stop → WS → Browser │
└───────────────────────────┘
```mermaid
flowchart LR
A[Media Server] -->|flux audio| B[Control Point]
B -->|commandes| C[Web Media Renderer]
C -->|flux + contrôles| D[Device physique]
subgraph Devices physiques
D1[Browser]
D2[Android Auto]
D3[Apple CarPlay]
D4[Sonos multipoint]
D5[Chromecast]
end
D --> D1
D --> D2
D --> D3
D --> D4
D --> D5
```
---
### Rôles
## 2. Étapes détaillées
1. **Media Server** - Source audio (le flux OGG-FLAC existant)
2. **Control Point** - Interface UI qui envoie les commandes (pause, play, seek, next, prev)
3. **Web Media Renderer** - Hub qui expose le flux et traduit les commandes selon le device
4. **Physical Device** - Lecteur final (browser, voiture, Sonos, Chromecast...)
### a) Création du renderer
## Web Media Renderer - Rôle central
1. Le navigateur se connecte via WebSocket ou HTTP.
2. Rust génère un token unique pour ce client :
```rust
use uuid::Uuid;
let token = Uuid::new_v4().to_string();
```
3. Rust crée une instance MediaRenderer **privée**, associée à ce token :
* Device description XML : `/renderer/<token>/desc.xml`
* AVTransport SOAP : `/renderer/<token>/avtransport`
* RenderingControl SOAP : `/renderer/<token>/renderingcontrol`
---
### b) Control Point
* La webapp Vue.js reçoit le token et la “déclare” au Control Point :
```js
const renderer = {
token: "abcd-1234-efgh",
name: "Browser Renderer"
};
// Ajout au control point local
controlPoint.addRenderer(renderer);
```
* Toutes les commandes Play/Pause/Stop incluent ce token :
```js
ws.send(JSON.stringify({
token: renderer.token,
action: "play",
uri: "http://localhost:8080/media.mp3"
}));
```
---
### c) Backend Rust : dispatcher les commandes
* Rust reçoit le JSON avec le token.
* Vérifie que le token correspond à un renderer actif.
* Transmet la commande au navigateur via WebSocket (ou HTTP push) :
```rust
match msg.action.as_str() {
"play" => send_ws_to_browser(&token, format!("play:{}", msg.uri)),
"pause" => send_ws_to_browser(&token, "pause".to_string()),
"stop" => send_ws_to_browser(&token, "stop".to_string()),
_ => (),
```mermaid
blockdiag
{
block = Commandes UPnP
block -> "Web Media Renderer" -> Adaptation selon device
"Web Media Renderer" -> Device-specific protocols
}
```
* Rust met à jour létat du renderer (AVTransport/RenderingControl) pour le Control Point.
### Rôle central: Adaptateur
---
Le Web Media Renderer est un **adaptateur** qui:
- **Reçoit le flux** du Media Server (OGG-FLAC)
- **Reçoit les commandes** du Control Point (UPnP)
- **Les traduit** vers les devices physiques
- **Expose une API de contrôle** commune
### d) Lecture côté navigateur
### Ce qui est COMMUN (factorisé)
* Le navigateur reçoit la commande via WebSocket et pilote `<audio>` :
| Layer | Description |
|-------|-------------|
| **API contrôle** | pause, resume, seek, next, prev, flush, stop |
| **Métadonnées** | /nowplaying, /metadata, /state |
| **Flux audio** | OGG-FLAC (identique pour tous) |
| **StreamType** | Continuous vs Finite |
```js
ws.onmessage = (evt) => {
const msg = evt.data;
if(msg.startsWith("play:")) {
audio.src = msg.split(":")[1];
audio.play();
} else if(msg === "pause") {
audio.pause();
} else if(msg === "stop") {
audio.pause();
audio.currentTime = 0;
}
};
### Ce qui est SPÉCIFIQUE (par device)
| Device | Transport | Buffer Management | Sync |
|--------|-----------|-------------------|------|
| Browser | HTTP/WebSocket | JS flush | N/A |
| Android Auto | AA API | native | varies |
| CarPlay | CP API | native | varies |
| Sonos | UPnP | none | UPnP |
| Chromecast | Cast API | none | Cast |
### Problème du buffer (Browser)
Le browser buffer cause des delais de reaction:
- **Pause**: delai de 5 secondes
- **Seek**: cherche dans le buffer, pas dans le nouveau flux
- **Next/Prev**: changement reporte
**Solutions**:
1. Web Audio API - `audioContext.suspend()/resume()` - plus petit buffer (~50ms)
2. Frontend flush buffer
3. Chaque client manage son propre buffer, Web Media Renderer juste expose API
## Implémentation actuelle
### Faits
- Flux audio OGG-FLAC ✓
- Pause/Resume ✓ (via `OggFlacStreamHandle`)
- TrackBoundary pour OGG segments
- StreamType (Continuous vs Finite)
### À faire
- seek/next/prev API
- WebSocket pour temps réel
- Metadata endpoint (/nowplaying JSON)
- MPV integration (multi-point/multi-room)
## Code actuel - Pause/Resume
```rust
// OggFlacStreamHandle - méthodes de contrôle
pub fn pause(&self) {
self.inner.is_paused.store(true, Ordering::SeqCst);
}
pub fn resume(&self) {
self.inner.is_paused.store(false, Ordering::SeqCst);
}
pub fn is_paused(&self) -> bool {
self.inner.is_paused.load(Ordering::SeqCst)
}
```
---
### Différences Continuous vs Finite
### e) Fermeture / cleanup
- **Continuous** (radio): pause -> sends silence, drops incoming chunks
- **Finite** (tracks): pause -> don't receive chunks (backpressure), loops sending silence
* Quand le navigateur se déconnecte :
## Schéma d'intégration
* Rust supprime le renderer associé au token
* Émet un **byebye virtuel** pour le Control Point (si nécessaire)
* Libère toutes les ressources
```mermaid
sequenceDiagram
participant CP as Control Point
participant WMR as Web Media Renderer
participant FS as Flux Server
participant D as Device
CP->>WMR: pause()
WMR->>FS: commande pause
FS->>FS: pause state change
FS->>WMR: silence (continuous) / blocked (finite)
WMR->>D: flux avec silence
D-->>CP: audio joué (avec delay si buffer)
```
---
## Le Web Media Renderer - Adaptateur
## 3. Points clés
Le rôle central du Web Media Renderer est de **convertir des ordres UPnP en actions spécifiques** selon le device cible:
1. **Token unique** = session privée + sécurité
2. **Pas besoin de SSDP / annonce** : le renderer est dédié à un navigateur connu
3. **Control Point Vue.js** sait exactement quel renderer utiliser
4. **Rust backend** reste seul responsable de limplémentation UPnP
5. **Lecture réelle** = navigateur via `<audio>` ou `<video>`
```
UPnP orders → [Web Media Renderer] → Device-specific actions
```
---
## Browser Player - Composant web invisible
💡 Ce modèle est très proche de ce que font **Mopidy avec Iris**, **Kodi Remote**, ou **Chromecast / local cast** : le device est connu et dédié, pas besoin de découverte réseau.
Pour s'entraîner, on peut se focaliser sur un composant web qui:
- Est **complètement invisible** (pas de UI)
- Est **télécommandable** par le Web Media Renderer
- Joue la musique dans le navigateur
### Specifications
| Requirement | Description |
|-------------|-------------|
| Invisible | Pas de UI, pas de controls, pas de visuel |
| Remote control | Reçoit commandes via WebSocket/HTTP |
| Auto-reconnect | Reconnection si stream coupé |
| Buffer management | Flush commandée |
| Audio format | OGG-FLAC stream |
### Architecture en 2 parties
| Partie | Langage | Rôle |
|--------|---------|------|
| Backend | Rust (pmoaudio-ext) | Contrôle, flux OGG-FLAC |
| Frontend | JavaScript | Player invisible dans le browser |
#### Backend (Rust)
- Expose le flux audio (`/stream`)
- API contrôle (`/pause`, `/resume`, `/seek`, `/flush`, `/stop`)
- WebSocket pour temps réel (`/ws`)
- **Reçoit les rapports de position/state**
```rust
// Endpoints existants
POST /pause
POST /resume
POST /seek?t={timestamp}
POST /flush
POST /stop
// Stream
GET /stream
// WebSocket messages REÇUS du player:
{
"type": "position",
"position_sec": 125.5,
"duration_sec": 240.0,
"state": "playing"
}
{
"type": "track",
"id": "...",
"title": "...",
"artist": "..."
}
{
"type": "ready_state",
"ready_state": "canplay"
}
```
#### Frontend (JavaScript)
Composant minimal (~100 lignes):
```javascript
class RemotePlayer {
constructor(wsUrl) {
this.ws = new WebSocket(wsUrl);
this.audio = new Audio();
this.ac = new AudioContext();
this.ws.onmessage = (e) => this.handle(e.data);
}
handle(msg) {
switch(msg.type) {
case 'stream': this.load(msg.url); break;
case 'play': this.play(); break;
case 'pause': this.pause(); break;
case 'seek': this.seek(msg.timestamp); break;
case 'flush': this.flush(); break;
case 'stop': this.stop(); break;
}
}
load(url) {
this.audio.src = url;
}
play() {
this.audio.play();
}
pause() {
this.audio.pause();
}
seek(ts) {
this.audio.currentTime = ts;
}
flush() {
// Flush buffer immediatement
this.audio.pause();
this.audio.currentTime = 0;
this.audio.src = '';
this.ac.suspend();
}
stop() {
this.flush();
}
}
```
**Usage:**
```html
<script src="pmo-player.js"></script>
<script>
const player = new PMOPlayer('ws://localhost:8080/ws');
</script>
```
### Fichier à créer
`pmoapp/webapp/src/services/PMOPlayer.ts`
### Endpoints HTTP
| Endpoint | Methode | Description |
|----------|---------|-------------|
| `/api/webrenderer/register` | POST | Enregistre instance |
| `/api/webrenderer/{id}/stream` | GET | Flux audio OGG-FLAC |
| `/api/webrenderer/{id}/position` | POST | Rapporte position |
| `/api/webrenderer/{id}/report` | POST | Rapporte etat player |
| `/api/webrenderer/{id}/command` | GET | Recupere commande pending |
| `/api/webrenderer/{id}` | DELETE | Desenregistre |
### Architecture
```
Player (Browser) <--HTTP--> Backend
- Report: position/state via POST /report
- Poll: command via GET /command (500ms)
- Stream: GET /stream
```
### Réactivité
Pour maximiser la réactivité:
| Technique | Impact |
|-----------|--------|
| WebSocket | Temps réel vs HTTP polling |
| AudioContext.suspend() | Buffer ~50ms au lieu de ~5s |
| Flush command | Vide le buffer immediatement |
| Native HTML5 audio | Le plus simple = le plus stable |
### Schéma
```mermaid
sequenceDiagram
participant WMR as Web Media Renderer
participant BP as Browser Player
WMR->>BP: stream(url)
BP->>BP: audio.src = url; play()
WMR->>BP: pause()
BP->>BP: audio.pause()
WMR->>BP: seek(timestamp)
BP->>BP: audio.currentTime = timestamp
WMR->>BP: flush()
BP->>BP: audioContext.suspend()
```
### Mapping orders → actions par device
| UPnP order | Browser | Android Auto | CarPlay | Sonos | Chromecast |
|-----------|---------|-------------|--------|------|-------------|
| Play | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
| Pause | `audio.pause()` | AA pause | CP pause | UPnP Pause | Cast pause |
| Resume | `audio.play()` | AA play | CP play | UPnP Play | Cast play |
| Seek | `audio.currentTime=t` | AA seek | CP seek | UPnP Seek | Cast seek |
| Next | fetch new stream | AA next | CP next | UPnP Next | Cast next |
| Prev | fetch new stream | AA prev | CP prev | UPnP Prev | Cast prev |
| Flush | JS `audioContext.suspend()` | AA flush | CP flush | N/A | Cast load |
| Stop | `audio.stop()` | AA stop | CP stop | UPnP Stop | Cast stop |
### Protocole de contrôle
Le Web Media Renderer expose une API de contrôle uniforme qui est traduite selon le device:
### Commandes
| Commande | Description |
|---------|-------------|
| `play` | Lecture |
| `pause` | Pause (silence ou backpressure) |
| `resume` | Reprise |
| `seek(t)` | Seek vers timestamp t |
| `next` | Track suivante |
| `prev` | Track précédente |
| `flush` | **Flush buffer** - ordre critique pour reponse rapide |
| `stop` | Arrêt total |
### Métadonnées
| Endpoint | Description |
|----------|-------------|
| `/nowplaying` | Track actuelle, timestamp, is_paused |
| `/metadata` | TITLE, ARTIST, ALBUM, COVER |
| `/state` | État complet (position, duration, volume...) |
### Ordres spéciaux pour devices avec buffer
Pour les devices типа Android Auto, CarPlay, Browser:
- `flush` = vide le buffer immédiatement
- `stop` = arrête + flush
- Ces ordres doivent être traités en priorité
```mermaid
sequenceDiagram
participant CP as Control Point
participant WMR as Web Media Renderer
participant D as Device (Android Auto, Browser...)
CP->>WMR: flush()
Note over WMR: Priorité haute - immédiat
WMR->>D: FLUSH order
D-->>WMR: ack
WMR-->>CP: flushed
CP->>WMR: pause()
Note over WMR: Standard
WMR->>D: flux with silence
```
## multipoint/multi-room
```mermaid
flowchart TB
WMR[Web Media Renderer] -->|flux| R1[Renderer 1]
WMR -->|flux| R2[Renderer 2]
WMR -->|flux| R3[Renderer N]
R1 -->|sync| R2
R2 -->|sync| R3
```
Possibilités:
- UPnP pour renderers UPnP
- Cast API pour Chromecast
- Serveur temps réel pour sync
## Notes techniques
### AudioChunk::silence()
```rust
impl AudioChunk {
pub fn silence(frames: usize, sample_rate: u32) -> Self {
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
}
}
impl<T: Sample> AudioChunkData<T> {
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
}
}
```
### StreamType
```rust
pub enum StreamType {
Continuous, // radio - silence pendant pause
Finite, // tracks - backpressure pendant pause
}
```
### TrackBoundary avec StreamType
```rust
SyncMarker::TrackBoundary {
metadata: ...,
stream_type: StreamType
}
```

View File

@@ -0,0 +1,327 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
## Contexte
Ce document est le résultat d'une revue de code complète du frontend Vue.js/TypeScript du Control Point
(`pmoapp/webapp/src/`). L'application est fonctionnelle mais présente plusieurs classes de problèmes
qui peuvent causer des fuites mémoire, des incohérences de réactivité Vue 3, et des difficultés de
maintenance à mesure que l'app grandit.
**Périmètre** : uniquement `pmoapp/webapp/src/` (composants, services, composables, stores, utils, CSS).
---
## Problèmes identifiés
### P0 — Fuites mémoire via listeners SSE jamais nettoyés
**Fichiers** : `src/composables/useSSE.ts`, `src/composables/useRenderers.ts`,
`src/composables/useMediaServers.ts`
Les abonnements aux événements SSE sont créés lors du premier appel de chaque composable, mais
jamais nettoyés si le composable est réutilisé ou le composant détruit. Dans `useSSE.ts`, la
fonction `onRendererEvent()` retourne une fonction de cleanup, mais `useRendererEvents()` ignore
ce retour — l'abonnement reste actif indefiniment.
De plus, dans `imageCache.ts`, un `setInterval` de cleanup s'exécute toutes les 5 minutes sans
jamais être annulé si l'app est détruite.
### P1 — Réactivité Vue incohérente avec `shallowRef` + Maps
**Fichier** : `src/composables/useRenderers.ts` (L23-37)
`snapshots` et `loadingIds` sont déclarés en `shallowRef<Map<...>>()`. Vue ne détecte pas les
mutations d'objets à l'intérieur d'un `shallowRef`. La solution actuelle — `triggerSnapshotReactivity()`
qui crée une nouvelle Map à chaque appel — force une re-render complète de tous les composants
qui dépendent de `snapshots`, même si seul un renderer a changé.
### P2 — Race condition à l'initialisation (main.ts)
**Fichier** : `src/main.ts`
Le UIStore est initialisé après le montage de l'app et l'appel à `sse.connect()`. Des événements
SSE peuvent arriver avant que `useUIStore()` soit appelé dans les composants, et les notifications
correspondantes peuvent être perdues.
### P3 — SSE singleton sans garantie formelle
**Fichiers** : `src/composables/useRenderers.ts`, `src/composables/useMediaServers.ts`
Chaque composable maintient son propre flag `sseInitialized` pour éviter les double-abonnements.
Le mécanisme repose sur une convention implicite fragile : si deux composables s'abonnent au même
type d'événement SSE dans des contextes différents, les callbacks s'accumulent sans être
dédupliqués.
Dans `useSSE.ts`, `setupConnectionListener()` vérifie `connectionCallbacks.size === 0` mais
sans lock — deux appels simultanés peuvent installer deux listeners.
### P4 — Pas de timeout ni retry sur les requêtes `fetch`
**Fichier** : `src/services/pmocontrol/api.ts`
Toutes les requêtes `fetch()` sont émises sans `AbortController`. Si le serveur ne répond pas,
la promesse pend indéfiniment, bloquant potentiellement les composants qui attendent le résultat.
Il n'y a ni timeout configurable ni retry automatique au niveau du service.
### P5 — Validation absente des réponses API
**Fichiers** : `src/services/audioCache.ts` (L76), `src/services/coverCache.ts`,
`src/services/playlists.ts`, `src/services/pmocontrol/api.ts`
Les réponses JSON sont acceptées sans vérification de structure. Une assertion de type comme
`metadata as { origin_url?: unknown }` ne protège pas contre un changement d'API côté Rust. Si
l'API retourne une structure inattendue, le crash survient au runtime, pas à la compilation.
### P6 — Type assertions dangereuses dans PMOPlayer
**Fichier** : `src/services/pmosource.ts` / PMOPlayer (L197-214)
Les messages de commande sont typés `Record<string, unknown>`, puis les propriétés sont castées
directement : `msg.url as string`, `msg.timestamp as number`. Si une propriété est absente ou
d'un type différent, TypeScript ne le détecte pas.
### P7 — `useTabs` : watch multiples sans debounce, flag de restauration non-réinitialisé
**Fichier** : `src/composables/useTabs.ts` (L44, L350-361)
Trois `watch()` séparées écrivent dans `localStorage`. Sans debounce commun, si 3 onglets
changent d'état simultanément, `localStorage` est écrit 3 fois de suite.
Le flag `isRestoringFromStorage` (L44) empêche la boucle de sauvegarde pendant la restauration,
mais sans timeout : si `restoreFromLocalStorage()` lance une exception non-catchée, le flag reste
`true` et toutes les sauvegardes futures sont silencieusement ignorées.
### P8 — Routes de debug exposées en production, pas de lazy loading
**Fichier** : `src/router/index.ts`
Les routes debug (CoversCache, AudioCache, UPnP Explorer, etc.) sont accessibles en production
sans contrôle d'accès. Par ailleurs, tous les composants sont importés statiquement, augmentant
le bundle initial inutilement — les vues debug notamment ne sont jamais utilisées en prod.
### P9 — `formatMsToShortTime` est un alias inutile
**Fichier** : `src/utils/time.ts` (L58-59)
```typescript
// Actuellement
export function formatMsToShortTime(ms: number | null): string {
return formatMsToTime(ms);
}
```
Fonction identique à `formatMsToTime`. Tous les appelants peuvent utiliser directement
`formatMsToTime`.
### P10 — `truncate()` dans `string.ts` peut dépasser `maxLength`
**Fichier** : `src/utils/string.ts` (L46-48)
```typescript
// Actuellement
export function truncate(str: string, maxLength: number, suffix = '…'): string {
return str.length > maxLength ? str.slice(0, maxLength - suffix.length) + suffix : str;
}
```
Si `suffix.length >= maxLength`, `str.slice(0, maxLength - suffix.length)` retourne une chaîne
de longueur négative (comportement silencieux en JS, retourne `''`), et le résultat final est
plus long que `maxLength`.
### P11 — `DEFAULT_COVER_SVG` inline dans coverCache.ts
**Fichier** : `src/services/coverCache.ts` (L206-226)
Un SVG inline de ~20 lignes est inclus dans chaque bundle qui importe `coverCache`. Il devrait
être un fichier `src/assets/default-cover.svg` importé nativement par Vite (ce qui permet le
tree-shaking et le caching HTTP séparé).
### P12 — `animations` CSS sans `prefers-reduced-motion`
**Fichiers** : `src/assets/styles/glass-theme.css` (L384-401),
`src/assets/styles/pmocontrol.css` (L82)
Les animations `glassShimmer` (2s infini) et le `pulse` du badge de statut `Transitioning`
s'exécutent sans tenir compte de `prefers-reduced-motion: reduce`. Sur certains systèmes ou
pour des utilisateurs sensibles au mouvement, ces animations sont gênantes.
### P13 — CSS dupliqué dans drawers.css
**Fichier** : `src/assets/styles/drawers.css` (L105-149)
`drawer-close-btn` et `drawer-back-btn` partagent 90% des styles. Un TODO présent en L167
("remplacer par la classe globale .section-title") confirme cette dette. La variable
`var(--opacity-disabled)` est utilisée mais non définie dans `variables.css`.
### P14 — `browseContainer` : clés de cache fragiles et pas de pagination
**Fichier** : `src/composables/useMediaServers.ts` (L145, L239)
Les clés de cache sont construites comme `${serverId}/${containerId}`. Si un `containerId`
contient un slash (séparateur d'URL), la clé est ambigüe. Par exemple, `server1/a/b` peut
correspondre à serverId=`server1`, containerId=`a/b` ou serverId=`server1/a`, containerId=`b`.
La pagination n'est pas implémentée côté composable : `browseContainer` charge toujours
offset=0, limit=50. Pour les containers avec 500+ items, les items au-delà de 50 ne sont
jamais accessibles.
### P15 — Notifications sans limite de taille dans `ui.ts`
**Fichier** : `src/stores/ui.ts` (L50, L55-57)
Un bug ou une boucle d'erreur peut générer des centaines de notifications. Le tableau
`notifications` n'est pas limité. Chaque notification crée un `setTimeout` individuel, et
si le store est détruit avant l'expiration, ces callbacks persistent (ghosts).
---
## Plan d'exécution
Les corrections sont groupées par effort et impact. Les P0P3 concernent la fiabilité
(fuites mémoire, réactivité), les P4P8 la robustesse et maintenabilité, les P9P15 la
qualité et la dette technique.
### Étape 1 — Corriger les fuites mémoire SSE (P0)
Dans `useSSE.ts`, stocker et appeler les fonctions de cleanup retournées par `onRendererEvent` /
`onMediaServerEvent` :
```typescript
// useSSE.ts useRendererEvents()
onMounted(() => {
const cleanup = onRendererEvent(rendererId(), handler);
onUnmounted(cleanup); // ← actuellement ignoré
});
```
Dans `imageCache.ts`, exporter une fonction `destroyImageCache()` qui appelle `clearInterval`
sur le timer de cleanup, et l'appeler dans le `onUnmounted` de l'app root.
### Étape 2 — Stabiliser la réactivité des snapshots (P1)
Remplacer `shallowRef<Map<...>>` + `triggerSnapshotReactivity` par `reactive(new Map<...>)`.
Vue 3 rend les Maps réactives nativement. Les composants qui lisent `snapshots.get(id)`
seront notifiés uniquement si ce `id` change.
```typescript
// Avant
const snapshots = shallowRef<Map<string, FullRendererSnapshot>>(new Map());
function triggerSnapshotReactivity() {
snapshots.value = new Map(snapshots.value);
}
// Après
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
// Les modifications directes (snapshots.set/delete) déclenchent la réactivité
```
### Étape 3 — Timeout fetch + AbortController (P4)
Ajouter un helper dans `api.ts` :
```typescript
function fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 10_000): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(id));
}
```
Utiliser `fetchWithTimeout` pour toutes les requêtes dans le service API.
### Étape 4 — Corriger `useTabs` watchs et flag de restauration (P7)
Fusionner les trois `watch()` en un seul `watchEffect` avec un debounce unique (100ms).
Encadrer `isRestoringFromStorage` dans un bloc `try/finally` :
```typescript
async function restoreFromLocalStorage() {
isRestoringFromStorage = true;
try {
// ... logique de restauration
} catch (e) {
console.error('Tab restore failed:', e);
} finally {
isRestoringFromStorage = false;
}
}
```
### Étape 5 — Limit de notifications et nettoyage timers (P15)
```typescript
const MAX_NOTIFICATIONS = 5;
function addNotification(notif: Omit<Notification, 'id'>): void {
if (notifications.value.length >= MAX_NOTIFICATIONS) {
notifications.value.shift(); // supprimer la plus ancienne
}
const id = nextId++;
const timer = setTimeout(() => removeNotification(id), notif.duration ?? 5000);
notificationTimers.set(id, timer);
notifications.value.push({ ...notif, id });
}
function $dispose() {
notificationTimers.forEach(clearTimeout);
notificationTimers.clear();
}
```
### Étape 6 — Lazy loading des routes et protection debug (P8)
```typescript
// router/index.ts
const DebugView = () => import('../views/DebugView.vue');
const isDev = import.meta.env.DEV;
const routes = [
// ... routes normales
...(isDev ? [{ path: '/debug', component: DebugView }] : []),
{ path: '/:pathMatch(.*)*', redirect: '/' }, // wildcard 404
];
```
### Étape 7 — Corrections mineures (P9, P10, P11, P12, P13)
- **P9** : Supprimer `formatMsToShortTime`, remplacer tous les appels par `formatMsToTime`
- **P10** : Ajouter un guard dans `truncate` : `if (suffix.length >= maxLength) return str.slice(0, maxLength)`
- **P11** : Déplacer le SVG dans `src/assets/default-cover.svg` et l'importer avec `import defaultCover from '../assets/default-cover.svg?raw'`
- **P12** : Entourer les animations CSS avec `@media (prefers-reduced-motion: no-preference) { ... }`
- **P13** : Factoriser `drawer-close-btn` / `drawer-back-btn` avec une classe `.drawer-icon-btn`. Définir `--opacity-disabled: 0.4` dans `variables.css`
### Étape 8 — Clés de cache et pagination (P14)
Encoder les IDs dans les clés de cache :
```typescript
const cacheKey = `${encodeURIComponent(serverId)}:${encodeURIComponent(containerId)}`;
```
Utiliser `:` comme séparateur (absent de l'encoding) pour éviter toute ambigüité.
Pour la pagination, ajouter une propriété `hasMore: boolean` et `loadMore()` au résultat de
`browseContainer`, incrementant offset à chaque appel.
### Ordre d'exécution
1. Étape 1 — fuites SSE (P0) — fiabilité critique
2. Étape 2 — réactivité Map (P1) — fiabilité
3. Étape 3 — timeout fetch (P4) — robustesse réseau
4. Étape 4 — useTabs (P7) — fiabilité des onglets
5. Étape 5 — notifications (P15) — stabilité UI
6. Étape 6 — router (P8) — sécurité + performance bundle
7. Étape 7 — corrections mineures (P9P13)
8. Étape 8 — cache keys + pagination (P14)
## Règle après ces corrections
**Interdit** : créer un abonnement SSE (`onRendererEvent`, `onMediaServerEvent`) sans stocker
et appeler la fonction de cleanup retournée dans `onUnmounted`.
**Interdit** : utiliser `shallowRef<Map<...>>` avec mutation directe — utiliser `reactive(new Map())`
pour les Maps qui doivent déclencher la réactivité Vue sur leurs entrées.
**Obligatoire** : toute requête `fetch()` dans un service doit utiliser `fetchWithTimeout`
avec un AbortController.

View File

@@ -0,0 +1,426 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
## Contexte et symptôme
Le control point PMOMusic est lent lorsqu'un renderer **OpenHome** manipule des playlists
d'environ 1 000 titres. Les renderers Chromecast et UPnP pur ne sont pas affectés : ils
utilisent une `InternalQueue` entièrement locale, sans appels SOAP. Le problème est
spécifique à `OpenHomeQueue` (`pmocontrol/src/queue/openhome.rs`).
Le code a été généré par IA : il peut contenir des redondances, mais **chaque comportement
est intentionnel**. L'objectif est d'optimiser sans rien supprimer.
## Causes racines identifiées
### P0 — Double appel à `queue_snapshot()` dans `sync_queue()`
**Fichiers** : `pmocontrol/src/queue/openhome.rs`
`sync_queue()` (ligne 1151) appelle `queue_snapshot()` pour obtenir l'état courant.
Puis elle délègue à l'une de ces deux sous-fonctions qui appellent **à nouveau**
`queue_snapshot()` :
- `replace_queue_with_pivot()` (ligne 555) : 2e appel `queue_snapshot()` + 1 appel
`track_ids()` séparé (alors que `queue_snapshot()` appelle déjà `track_ids()` en interne)
- `replace_queue_standard_lcs()` (ligne 647) : 2e appel `queue_snapshot()`
Seule `replace_queue_preserve_current()` n'a pas ce défaut (elle appelle uniquement
`track_ids()`).
**Impact pour 1 000 titres :**
Chaque `queue_snapshot()` exécute :
- 1 appel SOAP `IdArray` (liste des IDs)
- 16 appels SOAP `ReadList` (lots de 64 items)
Soit **34 appels SOAP** pour une seule opération `sync_queue()` au lieu de 17.
Le cache `ReadList` (TTL 500 ms) atténue partiellement mais ne supprime pas le problème
car la durée d'un `sync_queue` sur 1 000 titres peut dépasser 500 ms.
### P1 — Algorithme LCS de complexité quadratique O(m × n)
**Fichier** : `pmocontrol/src/queue/openhome.rs:851`
La fonction `lcs_flags()` alloue une table DP de taille `(m+1) × (n+1)` :
```rust
let mut dp = vec![vec![0u32; n + 1]; m + 1];
```
Pour 1 000 titres en entrée : 1 000 × 1 000 = **1 000 000 entrées** (≈ 4 MB), et
1 000 000 comparaisons. Elle est appelée **jusqu'à 3 fois** dans un seul `sync_queue` :
- 2 fois dans `replace_queue_with_pivot()` (avant et après le pivot, lignes 579 et 582)
- 1 fois dans `replace_queue_standard_lcs()` (ligne 661)
Dans le cas courant (ajout de titres en fin de liste, ou liste déjà synchronisée),
la quasi-totalité de la table DP est inutile : les préfixe et suffixe communs
représentent souvent 90 % ou plus de la liste.
### P2 — Taille de lot `ReadList` = 64
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
```rust
const MAX_BATCH: usize = 64;
```
Pour 1 000 titres : 1 000 ÷ 64 = **16 appels SOAP `ReadList`** par `queue_snapshot()`.
La latence réseau typique par appel SOAP (50200 ms) implique 0,8 à 3,2 secondes
uniquement pour la lecture des métadonnées.
La norme OpenHome Playlist ne fixe pas de limite de payload. La valeur 64 est
conservatrice. Augmenter à 256 réduit à **4 appels** (75 %).
Le mécanisme de fallback one-by-one (lignes 10071019) assure la rétrocompatibilité
avec les devices qui refuseraient un payload plus large.
### P3 — Polling à 500 ms indépendant de l'activité
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
Chaque renderer OpenHome tourne un thread watcher toutes les 500 ms, même en veille.
Avec plusieurs renderers actifs, les appels de polling et les opérations `sync_queue`
se chevauchent sur le même device réseau, créant de la contention.
### P4 — Redondances de code (nettoyage conservatif)
**a. Invalidation des caches dupliquée** (`openhome.rs`)
La séquence d'invalidation apparaît en 3 endroits distincts (lignes 11341136,
454456, 634635) :
```rust
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
// parfois aussi :
self.current_track_id_cache.lock().unwrap().invalidate();
```
**b. Protection durée stream dupliquée** (`openhome.rs` et `interne.rs`)
La logique de protection de durée pour les flux continus (radio) est implémentée :
- Dans `cache_metadata()` de `OpenHomeQueue` (`openhome.rs:250351`)
- Dans `protect_stream_durations()` de `InternalQueue` (`interne.rs:92143`)
- Dans `merge_metadata_protecting_streams()` de `InternalQueue` (`interne.rs:148215`)
**c. `parse_duration()` défini 3 fois**
La conversion `HH:MM:SS` → secondes apparaît dans `openhome.rs`, `interne.rs`,
et dans `time_utils::parse_hhmmss_u32()` (déjà publique).
## Ce qui fonctionne déjà correctement
**Pagination du Browse** : La boucle de pagination est correctement implémentée dans
`control_point.rs:16101653` avec `browse_children()` + offset incrémental.
**Fallback ReadList one-by-one** : Si un batch échoue, le retry unitaire (lignes 10071019)
assure la robustesse sur les devices stricts.
**Protection multi-control-point** : `delete_id_if_exists()` gère proprement le cas où
un autre control point a déjà supprimé un titre.
**Stratégie double-LCS avec pivot** : La logique de `replace_queue_with_pivot()` est
correcte et importante pour ne pas interrompre la lecture en cours.
**Cache métadonnées stream** : La protection de durée décroissante pour les flux radio
est un comportement essentiel à préserver scrupuleusement.
## Plan d'exécution
### Crate concernée : `pmocontrol`
---
### Étape 1 — Augmenter le batch `ReadList` à 256
**Fichier** : `pmocontrol/src/queue/openhome.rs:986`
```rust
// Avant
const MAX_BATCH: usize = 64;
// Après
const MAX_BATCH: usize = 256;
```
Le fallback one-by-one (lignes 10071019) reste intact. Si un renderer refuse
un payload de 256 IDs, il retombe automatiquement sur le mode unitaire.
---
### Étape 2 — Éliminer le double appel à `queue_snapshot()`
**Fichier** : `pmocontrol/src/queue/openhome.rs`
Le snapshot calculé dans `sync_queue()` contient déjà les items **et** leurs IDs
backend (`backend_id: usize`). Il n'est pas nécessaire de le recalculer dans les
sous-fonctions.
#### 2a. Passer le snapshot à `replace_queue_with_pivot()`
Signature actuelle (ligne 548) :
```rust
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
fn replace_queue_with_pivot(
&mut self,
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté (évite aussi le 2e appel track_ids())
) -> Result<(), ControlPointError>
```
À l'intérieur de `replace_queue_with_pivot()`, supprimer :
```rust
// Supprimer ces deux lignes (ligne 555556)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
```
Et utiliser directement les paramètres `snapshot` et `current_track_ids`.
Appel depuis `sync_queue()` (ligne 1221) :
```rust
// Avant
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
// Après — passer le snapshot et les IDs déjà disponibles
let current_ids_for_pivot: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_with_pivot(items, pivot_idx, playing_id, &snapshot, &current_ids_for_pivot)?;
```
**Note importante** : dans `sync_queue()`, le snapshot est pris APRÈS
`ensure_playlist_source_selected()` (ligne 1115) et APRÈS la résolution du `playing_info`.
Cet ordre est correct et doit être conservé.
#### 2b. Passer le snapshot à `replace_queue_standard_lcs()`
Signature actuelle (ligne 641) :
```rust
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
_current_index: Option<usize>,
) -> Result<(), ControlPointError>
```
Nouvelle signature :
```rust
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
snapshot: &QueueSnapshot, // ← ajouté
current_track_ids: &[u32], // ← ajouté
) -> Result<(), ControlPointError>
```
À l'intérieur, supprimer :
```rust
// Supprimer ces deux lignes (lignes 647648)
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
```
Appel depuis `sync_queue()` (ligne 1253) :
```rust
// Avant
self.replace_queue_standard_lcs(items, Some(0))?;
// Après
let current_ids_for_lcs: Vec<u32> = snapshot.items
.iter()
.map(|i| i.backend_id as u32)
.collect();
self.replace_queue_standard_lcs(items, &snapshot, &current_ids_for_lcs)?;
```
**Cas particulier à préserver** (ligne 12371246) : le guard sur `snapshot.items.is_empty()`
dans `sync_queue()` est exécuté **avant** l'appel à `replace_queue_standard_lcs`, donc
le snapshot vide ne peut pas atteindre la sous-fonction — le comportement est préservé.
---
### Étape 3 — Optimiser LCS par élagage du préfixe/suffixe communs
**Fichier** : `pmocontrol/src/queue/openhome.rs`
La fonction `lcs_flags()` (ligne 851) reste inchangée. L'optimisation s'applique
**aux appels** dans `replace_queue_with_pivot()` et `replace_queue_standard_lcs()`.
#### Principe
Avant de calculer le LCS DP, éliminer les éléments identiques en tête et en queue :
```rust
/// Wrapper autour de lcs_flags() qui élimine préfixe et suffixe communs
/// avant d'appeler l'algorithme DP O(m×n).
///
/// Cas optimisés : ajout en fin de liste → O(n), liste déjà synchro → O(n),
/// suppression en fin → O(n). LCS complet uniquement pour les vrais réordonnements.
fn lcs_flags_optimized(
current: &[PlaybackItem],
desired: &[PlaybackItem],
) -> (Vec<bool>, Vec<bool>) {
// Préfixe commun
let leading = current
.iter()
.zip(desired.iter())
.take_while(|(c, d)| items_match(c, d))
.count();
// Suffixe commun (sur les portions restantes uniquement)
let c_tail = &current[leading..];
let d_tail = &desired[leading..];
let trailing = c_tail
.iter()
.rev()
.zip(d_tail.iter().rev())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_mid = &c_tail[..c_tail.len() - trailing];
let d_mid = &d_tail[..d_tail.len() - trailing];
// Si rien à faire (listes identiques ou préfixe/suffixe couvrent tout)
if c_mid.is_empty() && d_mid.is_empty() {
return (vec![true; current.len()], vec![true; desired.len()]);
}
// LCS DP sur le delta central uniquement
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
// Reconstituer les vecteurs complets
let mut keep_current = vec![true; leading];
keep_current.extend(keep_c_mid);
keep_current.extend(vec![true; trailing]);
let mut keep_desired = vec![true; leading];
keep_desired.extend(keep_d_mid);
keep_desired.extend(vec![true; trailing]);
(keep_current, keep_desired)
}
```
Remplacer les 3 appels à `lcs_flags()` (lignes 579, 582, 661) par `lcs_flags_optimized()`.
La fonction `lcs_flags()` originale est **conservée** (utilisée en interne par
`lcs_flags_optimized()`).
---
### Étape 4 — Polling adaptatif selon l'activité
**Fichier** : `pmocontrol/src/music_renderer/watcher.rs`
Ajouter un flag partagé `is_active` dans `MusicRenderer` (ou `WatchedState`) pour
signaler si le renderer est en activité récente.
Le renderer met `is_active = true` lors de chaque opération (play, sync, seek, stop).
Le watcher revient à l'intervalle long (5 000 ms) après 10 s sans activité.
```rust
// Dans la boucle du watcher :
let interval = if is_active.load(Ordering::Relaxed) {
Duration::from_millis(500)
} else {
Duration::from_millis(5_000)
};
thread::sleep(interval);
```
**Fonctionnalités à préserver** :
- Détection de fin de piste (auto-advance) : délai max 5 s en idle — acceptable
- Sleep timer countdown : reste actif au polling suivant
- Synchronisation auto sur mise à jour de playlist : déclenchée par événement externe,
pas par le polling — non affectée
---
### Étape 5 — Consolider les redondances (nettoyage conservatif)
**À réaliser uniquement après validation fonctionnelle des étapes 14.**
#### 5a. Méthode `invalidate_all_caches()` sur `OpenHomeQueue`
```rust
fn invalidate_all_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
}
fn invalidate_track_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
}
```
Remplacer les séquences d'invalidation en 3 endroits (lignes 11341136, 454456, 634635).
Garder les appels sélectifs là où seulement 2 caches sont invalidés.
#### 5b. Factoriser `parse_duration()`
Supprimer les définitions locales de `parse_duration` dans `openhome.rs` et `interne.rs`.
Utiliser `crate::music_renderer::time_utils::parse_hhmmss_u32()` (déjà publique).
La sémantique est identique : conversion `HH:MM:SS` → u64 secondes.
#### 5c. Factoriser la protection durée stream
Extraire la logique commune de protection (« ne jamais diminuer la durée d'un flux
continu pour le même titre/artiste ») dans une fonction privée dans `openhome.rs`,
et y référencer depuis `interne.rs` via le module `queue`.
**Règle absolue** : ne pas modifier la sémantique de détection de stream continu
(`is_continuous_stream_url()`) ni la logique de comparaison titre/artiste. Uniquement
factoriser le code existant.
---
## Ordre d'exécution
1. **Étape 1** — Batch ReadList 256 (changement trivial, gain immédiat 75 % appels)
2. **Étape 2** — Élimination double `queue_snapshot()` (50 % appels SOAP totaux)
3. **Étape 3** — Optimisation LCS préfixe/suffixe (gain CPU, cas courants en O(n))
4. **Étape 4** — Polling adaptatif (réduction contention réseau en veille)
5. **Étape 5** — Consolidation redondances (nettoyage, après validation)
## Périmètre : ce qui ne change pas
- La logique à 3 cas de `sync_queue()` (avec pivot, préserver courant, LCS standard)
- La protection durée décroissante pour les flux radio (cache stream)
- Le mécanisme `delete_id_if_exists()` pour la robustesse multi-control-point
- Le fallback `ReadList` one-by-one en cas d'erreur batch
- La pagination Browse dans `control_point.rs` (déjà correcte)
- Le comportement des queues `InternalQueue` (Chromecast, UPnP) — non affectées
- Les TTL des caches existants (1 s, 500 ms, 250 ms)
- Tous les logs de diagnostic (`tracing::warn!`, `debug!`) — à conserver
## Tests recommandés
Demander à l'humain de compiler et tester :
```
cargo build -p pmocontrol
```
Puis tester avec un renderer OpenHome physique :
- Playlist de 1 000 titres : mesurer le temps de `sync_queue` avant/après
- Ajout de titres en fin de liste : vérifier que LCS optimisé ne fait que des insertions
- Lecture en cours + refresh playlist : vérifier que la piste courante n'est pas interrompue
- Flux radio : vérifier que la durée ne régresse pas pour un même titre/artiste
- Renderer Chromecast : vérifier l'absence de régression (queue interne)

View File

@@ -0,0 +1,235 @@
# Optimisation Playlist OpenHome — Étape 2
**Contexte**: Suite de `enorme_playlist.md`. Les optimisations de base (MAX_BATCH=256, LCS prefixe/suffixe, polling adaptatif, caches TTL) sont faites. Les lenteurs persistent sur les grandes playlists (~1000 titres). Les renderers Chromecast et UPnP sont moins affectés mais bénéficieront également de certaines optimisations.
**Contrainte architecturale fondamentale**: La queue OpenHome est la **source de vérité unique**. Un miroir local persistent a été tenté et abandonné — impossible à maintenir en sync quand d'autres control points (BubbleUPnP, Linn, etc.) modifient la queue. Toute optimisation doit respecter cette contrainte.
**Note sur le LCS**: `lcs_flags_optimized` (openhome.rs:869) gère déjà le cas dominant (préfixe/suffixe communs). Pour un append de 100 tracks à 900 existants, le LCS est O(1) — ce n'est pas le goulot. Le coût réel est les appels SOAP : connexions TCP × (RTT + handshake) et les `ReadList` pour reconstruire le snapshot.
---
## Analyse des Goulots Réels
Pour un `sync_queue` "append 100 tracks à 900 existants" aujourd'hui :
| Étape | Appels SOAP | Coût estimé |
|-------|------------|-------------|
| `id_array()` | 1 | ~5ms |
| `read_list()` — 4 batches × 256 | 4 | ~20ms |
| 100 × `insert()` | 100 | ~100 × (RTT + **TCP handshake**) |
| **Total TCP handshakes** | 105 | **105 × 10-50ms = 1-5 secondes** |
Les deux leviers : (1) éliminer les handshakes TCP, (2) éliminer les `ReadList` quand inutiles.
---
## Plan d'Implémentation
### Phase 1 — Connection Pooling (1-2h, PRIORITÉ MAXIMALE)
**Fichier**: `pmocontrol/src/soap_client.rs`
**Problème**: lignes 65-72 créent un nouvel `ureq::Agent` à chaque appel SOAP = nouvelle connexion TCP à chaque fois.
**Solution**: Agent statique partagé via `OnceLock`.
```rust
use std::sync::OnceLock;
static SOAP_AGENT: OnceLock<ureq::Agent> = OnceLock::new();
fn get_soap_agent() -> &'static ureq::Agent {
SOAP_AGENT.get_or_init(|| {
ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into()
})
}
```
Dans `invoke_upnp_action_with_timeout()`, remplacer la construction de l'agent par :
```rust
// Cas normal : réutiliser l'agent partagé (keep-alive, connection pooling)
// Cas custom timeout : agent dédié (rare — timeout global suffit en pratique)
let agent_owned;
let agent: &ureq::Agent = if timeout.is_some() {
agent_owned = ureq::Agent::config_builder()
.http_status_as_error(false)
.timeout_global(timeout)
.build()
.into();
&agent_owned
} else {
get_soap_agent()
};
```
**Vérifier** que `ureq::Agent` maintient bien un pool de connexions HTTP/1.1 keep-alive entre les appels (comportement documenté de ureq v3 — l'agent est conçu pour être réutilisé).
**Impact**: Élimine les TCP handshakes répétés pour tous les renderers (OpenHome, UPnP, Chromecast). Pour 100 inserts : 99 handshakes économisés × 10-50ms = **1-5 secondes récupérées**.
---
### Phase 2 — Fast Path Session (2-3h, PRIORITÉ HAUTE)
**Concept**: Sans miroir persistant (abandonné), on peut quand même éviter les `ReadList` dans le cas dominant en maintenant un état **éphémère de session** — valable uniquement entre deux `sync_queue` consécutifs, et invalidé dès qu'on détecte une incohérence.
**Principe**: Après chaque `sync_queue`, mémoriser :
- la liste d'IDs résultante (déjà dans `track_ids_cache` TTL 1s)
- le `after_id` du dernier insert (pour pouvoir appender sans `id_array`)
Au prochain `sync_queue`, tenter de détecter le pattern sans `ReadList` :
```rust
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
// Récupérer les IDs actuels (cache ou 1 appel id_array)
let current_ids = self.track_ids()?;
let current_len = current_ids.len();
let new_len = new_items.len();
// Fast path 1: append only
// Condition: new_items a plus d'items, et les current_len premiers de new_items
// ont les mêmes didl_id que les items actuels (vérifiable depuis id_array + metadata_cache local)
if new_len > current_len {
let prefix_matches = self.check_prefix_matches(&current_ids, &new_items[..current_len]);
if prefix_matches {
return FastPathResult::AppendOnly { items: &new_items[current_len..] };
}
}
// Fast path 2: delete from end
if new_len < current_len {
let prefix_matches = self.check_prefix_matches(&current_ids[..new_len], new_items);
if prefix_matches {
let to_delete = &current_ids[new_len..];
return FastPathResult::DeleteFromEnd { ids: to_delete };
}
}
// Cas général: fallback ReadList + LCS
FastPathResult::NeedFullSync
}
```
**`check_prefix_matches`** : compare `current_ids[i]` avec `new_items[i]` en utilisant le `metadata_cache` local (déjà en mémoire) pour résoudre les URIs/didl_ids des IDs connus. Si un ID n'est pas en cache → fast path impossible → fallback.
**Clé**: Cette vérification utilise uniquement le `metadata_cache` local (HashMap en mémoire, nano-secondes) et `id_array()` (déjà caché TTL 1s). Zéro appel `ReadList` dans le cas heureux. Si la vérification échoue (incohérence détectée, cache manquant) → fallback propre vers `ReadList` + LCS, source de vérité OpenHome préservée.
**Fichier**: `pmocontrol/src/queue/openhome.rs`, ajouter `try_fast_path()` et l'intégrer en début de `sync_queue()`.
---
### Phase 3 — Queue FIFO Async (8-12h, PRIORITÉ MOYENNE)
**Prérequis**: Phases 1 et 2 complétées.
**Concept**: Exécuter les opérations SOAP dans un thread dédié pour rendre `sync_queue()` non-bloquant du point de vue de l'appelant.
**Contrainte technique**: Les `insert()` sont chaînés — chaque appel retourne un `new_id` utilisé comme `after_id` du suivant. Le worker doit maintenir cet état interne.
**Fichier à créer**: `pmocontrol/src/queue/openhome_op_queue.rs`
```rust
pub enum OpenHomeOp {
/// Insert séquentiel — after_id géré en interne (last_inserted_id)
InsertAtEnd { uri: String, metadata: String, didl_id: String },
/// Insert après un ID connu (ex: après le pivot)
InsertAfter { after_id: u32, uri: String, metadata: String, didl_id: String },
DeleteId { track_id: u32 },
DeleteAll,
SeekId { id: u32 },
Play,
Pause,
Stop,
SetVolume { volume: u16 },
}
pub struct OpenHomeOpQueue {
sender: mpsc::Sender<OpenHomeOp>,
last_error: Arc<Mutex<Option<ControlPointError>>>,
completion: Arc<(Mutex<bool>, Condvar)>,
}
impl OpenHomeOpQueue {
pub fn push(&self, op: OpenHomeOp) { ... }
/// Opérations critiques passent devant (play/stop/volume)
pub fn push_priority(&self, op: OpenHomeOp) { ... }
/// Vider la file d'attente (ex: nouvelle playlist demandée avant fin de sync)
pub fn clear_pending(&self) { ... }
/// Attendre que toutes les opérations soient exécutées
pub fn wait_completion(&self) { ... }
/// Récupérer la dernière erreur (non-bloquant)
pub fn take_error(&self) -> Option<ControlPointError> { ... }
}
```
**Comportement en cas d'erreur**: vider la file d'attente, signaler l'erreur via `last_error`, invalider les caches OpenHome (forcer re-sync depuis source de vérité au prochain appel).
**Comportement en cas de `clear_pending()` pendant exécution**: laisser l'opération en cours se terminer (plus sûr — évite de laisser le renderer dans un état inconsistant), vider le reste.
**Intégration dans `OpenHomeQueue`**: remplacer les appels directs `playlist_client.insert()` / `playlist_client.delete_id()` par des `op_queue.push()`. Les opérations qui ont besoin d'une réponse synchrone (ex: `current_track()`, `queue_snapshot()`) continuent d'appeler directement le `playlist_client` — mais doivent d'abord attendre la complétion de la file (`wait_completion()`).
---
### Phase 4 — Throttle des replace_item (2-3h, PRIORITÉ BASSE)
**Contexte**: `replace_item()` (openhome.rs:1315) fait `delete_id` + `insert` pour mettre à jour une piste. Sur un stream radio qui change de morceau, la durée est mise à jour fréquemment → paires SOAP inutiles car le `metadata_cache` local est déjà la source pour l'UI.
**Solution**: Ne pas envoyer le `delete_id` + `insert` OpenHome si une mise à jour pour ce `track_id` a déjà été envoyée dans les N dernières secondes. Le `metadata_cache` local est mis à jour immédiatement (pour l'UI), et l'opération OpenHome est différée ou ignorée.
```rust
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
let track_id = self.track_ids()?[index];
// Toujours mettre à jour le cache local immédiatement (pour l'UI)
self.cache_metadata(track_id, item.metadata.clone());
// Throttle: si replace OpenHome récent pour ce track, sauter l'opération SOAP
if self.is_recent_replace(track_id, Duration::from_secs(5)) {
return Ok(());
}
self.mark_replace_time(track_id);
// Opération SOAP (delete + insert)
// ... code existant ...
}
```
Ajouter `last_replace_times: Mutex<HashMap<u32, SystemTime>>` dans `OpenHomeQueue`.
---
## Ordre d'Implémentation
```
Phase 1 → mesurer gain TCP → Phase 2 → mesurer gain ReadList → Phase 3 → Phase 4
```
Tester chaque phase avec des playlists réelles (~1000 tracks) avant de poursuivre. Ne pas combiner les phases pour pouvoir isoler les régressions.
## Tests
```bash
# Phase 1 — vérifier connexions TCP réutilisées
# tcpdump -i lo port 60000 -c 200 (ou port du renderer)
# Avant: SYN à chaque appel SOAP
# Après: SYN unique, flux keep-alive
# Phase 2 — vérifier fast path activé
# RUST_LOG=debug cargo run ... 2>&1 | grep "fast path"
# Cas append 100 → "fast path: AppendOnly, 100 inserts"
# Cas delete 100 → "fast path: DeleteFromEnd, 100 deletes"
# Cas reorder → "fast path: NeedFullSync, falling back to ReadList+LCS"
# Phase 3 — vérifier non-blocage
# sync_queue() doit retourner en <1ms (les 100 inserts continuent en background)
```
---
*Contrainte architecturale intégrée: miroir local abandonné (désynchronisation avec autres control points). Toutes les phases respectent OpenHome comme source de vérité unique.*
*Date: 2026-04-08*

View File

@@ -0,0 +1,642 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
# Async Queue Refresh — Étape 3
**Contexte**: `refresh_attached_queue_for()` dans `control_point.rs` est appelé de 3 endroits
et bloque son thread pendant toute la synchronisation (browse media server + 100+ opérations
SOAP/mémoire). L'objectif est de factoriser le mécanisme async au niveau de la couche queue
(`MusicQueue`), qui est déjà l'abstraction agnostique du backend. `control_point.rs` ne doit
plus connaître les threads ni les tokens d'annulation.
**Principe architectural**: la couche queue sait *comment* syncer (mémoire ou SOAP) et donc
aussi *comment* annuler et *quand* signaler que la lecture peut démarrer. `control_point.rs`
sait seulement *quoi* syncer (browse + conversion PlaybackItem). Les deux responsabilités
restent séparées.
---
## Vue d'ensemble des changements
```
AVANT
control_point.rs
refresh_attached_queue_for()
→ browse()
→ sync_queue(items) ← bloquant 1-5s
APRÈS
control_point.rs
do_queue_refresh_work() ← interne, fait le browse + conversion
MusicQueue (couche queue)
schedule_sync(items, callbacks) ← non-bloquant, retourne immédiatement
→ thread "queue-sync-{renderer_id}"
→ QueueBackend::sync_queue(items, cancel_token, on_ready)
```
---
## Fichiers à modifier / créer
| Fichier | Action |
|---------|--------|
| `pmocontrol/src/errors.rs` | Ajouter variante `SyncCancelled` |
| `pmocontrol/src/queue/backend.rs` | Modifier signature `sync_queue()` |
| `pmocontrol/src/queue/interne.rs` | Adapter signature `sync_queue()` |
| `pmocontrol/src/queue/openhome.rs` | Adapter + points de vérification cancel + on_ready |
| `pmocontrol/src/queue/music_queue.rs` | Ajouter champs async + méthode `schedule_sync()` |
| `pmocontrol/src/queue/mod.rs` | Exporter `SyncScheduleOutcome` |
| `pmocontrol/src/model.rs` | Ajouter événements `QueueReadyToPlay`, `QueueSyncCancelled` |
| `pmocontrol/src/sse.rs` | Sérialiser les deux nouveaux événements |
| `pmocontrol/src/control_point.rs` | Remplacer les 3 call sites bloquants |
---
## Étape 1 — Nouvelle variante d'erreur (`errors.rs`)
**Fichier**: `pmocontrol/src/errors.rs`
Ajouter après la ligne 51 (`ControlPoint`) :
```rust
#[error("Queue sync cancelled (superseded by a newer request)")]
SyncCancelled,
```
Cette variante est retournée par `sync_queue()` quand le `cancel_token` passe à `true`.
Elle est **non-fatale** — le coordinator la traite comme un comportement normal, pas une
erreur à logger en `warn!`.
---
## Étape 2 — Modifier la signature de `sync_queue()` dans le trait (`backend.rs`)
**Fichier**: `pmocontrol/src/queue/backend.rs`, ligne 110
```rust
// AVANT
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
// APRÈS
use std::sync::{Arc, atomic::AtomicBool};
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError>;
```
**Sémantique des paramètres** :
- `cancel_token` : si `true` au moment d'une opération, retourner `Err(SyncCancelled)` immédiatement
- `on_ready` : callback one-shot appelé quand la lecture peut démarrer (voir logique ci-dessous)
---
## Étape 3 — Adapter `InternalQueue::sync_queue()` (`interne.rs`)
**Fichier**: `pmocontrol/src/queue/interne.rs`
Trouver la méthode `sync_queue()` et adapter la signature. Le corps reste identique,
avec deux ajouts :
**1. Vérification du pivot (early start)** : si une piste est en cours de lecture
(un `current_index` est défini dans le snapshot courant), appeler `on_ready` immédiatement
avant toute opération — la piste courante sera préservée.
**2. Si pas de pivot** (queue vide ou aucune piste en cours) : appeler `on_ready` après
avoir inséré le premier item.
**3. Vérification cancel** : après chaque item inséré/supprimé (en pratique `InternalQueue`
est rapide mais le principe doit être cohérent) :
```rust
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
// Early start si pivot présent
let has_current = self.current_index()?.is_some();
if has_current {
if let Some(f) = on_ready.take() { f(); }
}
// ... logique existante de sync_queue() ...
// Dans la boucle d'insertions, après le 1er insert :
if on_ready.is_some() {
if let Some(f) = on_ready.take() { f(); }
}
// Après chaque opération :
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
Ok(())
}
```
---
## Étape 4 — Adapter `OpenHomeQueue::sync_queue()` (`openhome.rs`)
**Fichier**: `pmocontrol/src/queue/openhome.rs`
### 4.1 Signature (ligne ~1277)
```rust
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError>
```
### 4.2 Early start — logique pivot
Au début de `sync_queue()`, **avant** toute opération SOAP, détecter si un pivot est présent :
```rust
// Après la récupération du snapshot (ligne ~1323), avant les branches if/else :
let has_pivot = playing_info.is_some();
if has_pivot {
// Le pivot sera préservé — on peut démarrer la lecture immédiatement
if let Some(f) = on_ready.take() { f(); }
}
```
Si pas de pivot (nouvelle playlist via `delete_all` + inserts depuis 0) : appeler `on_ready`
après le **1er insert réussi** dans `replace_queue()` et dans `replace_queue_standard_lcs()`.
### 4.3 Points de vérification cancel
Ajouter `if cancel_token.load(SeqCst) { return Err(SyncCancelled); }` aux endroits suivants :
- Dans `delete_marked_items()` (ligne ~458) : après chaque `delete_id_if_exists()`
- Dans `rebuild_playlist_section()` (ligne ~494) : après chaque `insert()`
- Dans `replace_queue_preserve_current()` (ligne ~419) : après chaque `delete_id_if_exists()` et `insert()`
- Dans `replace_queue_standard_lcs()` (ligne ~693-745) : après chaque delete et insert
- Dans `replace_queue()` (ligne ~1118) : après chaque insert dans la boucle
- Dans le fast path `AppendOnly` (ligne ~1318) : après chaque insert
- Dans le fast path `DeleteFromEnd` (ligne ~1336) : après chaque delete
### 4.4 Propagation du cancel_token aux helpers
Les méthodes helper privées qui font des boucles doivent recevoir le token :
```rust
fn delete_marked_items(
&mut self,
old_ids: &[u32],
keep_flags: &[bool],
position_label: &str,
cancel_token: &Arc<AtomicBool>,
) -> Result<(), ControlPointError>
fn rebuild_playlist_section(
&mut self,
// ... params existants ...
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<u32, ControlPointError>
```
---
## Étape 5 — Adapter `MusicQueue` (dispatch enum) (`music_queue.rs`)
**Fichier**: `pmocontrol/src/queue/music_queue.rs`
### 5.1 Adapter le dispatch `sync_queue()` (ligne ~98)
```rust
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
MusicQueue::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
}
}
```
### 5.2 Ajouter l'état async dans `MusicQueue`
`MusicQueue` passe de simple enum de dispatch à une struct qui **encapsule** l'enum backend
et l'état de synchronisation async :
```rust
// AVANT
pub enum MusicQueue {
Internal(InternalQueue),
OpenHome(OpenHomeQueue),
}
// APRÈS
pub struct MusicQueue {
backend: MusicQueueBackend,
// État async de synchronisation
sync_in_progress: Arc<AtomicBool>,
sync_pending: Arc<AtomicBool>,
sync_cancel_token: Arc<AtomicBool>,
}
// L'enum devient privée
enum MusicQueueBackend {
Internal(InternalQueue),
OpenHome(OpenHomeQueue),
}
```
**Note**: le changement de `enum` en `struct` implique de mettre à jour toutes les
utilisations de `MusicQueue::Internal(...)` et `MusicQueue::OpenHome(...)` dans le reste
du code (essentiellement `music_queue.rs` lui-même et `mod.rs`). Les callers externes
utilisent `MusicQueue` via `QueueBackend` et `QueueFromRendererInfo` — ils ne sont pas
impactés si l'API publique est préservée.
### 5.3 Enum résultat et méthode `schedule_sync()`
```rust
pub enum SyncScheduleOutcome {
/// Thread spawné, sync en cours.
Scheduled,
/// Sync déjà en cours — annulée et nouvelle sync programmée en pending.
AlreadyRunning,
}
```
```rust
impl MusicQueue {
/// Lance une synchronisation asynchrone de la queue.
///
/// - Si aucune sync n'est en cours : spawne un thread, retourne `Scheduled`.
/// - Si une sync est en cours : l'annule, note une sync pending, retourne `AlreadyRunning`.
/// Le thread en cours finira l'opération courante, détectera le cancel, puis
/// relancera la sync avec les nouveaux items via `pending_items_fn`.
///
/// `pending_items_fn` : closure appelée dans le worker pour re-fetcher les items
/// en cas de pending. Elle doit être Send + 'static car elle s'exécute dans un thread.
///
/// `on_ready` : appelé dès que la lecture peut démarrer (pivot préservé ou 1er insert).
///
/// `renderer_id` : utilisé uniquement pour nommer le thread de travail.
pub fn schedule_sync(
&self, // &self car l'état async est derrière Arc<AtomicBool>
renderer_id: &str,
items: Vec<PlaybackItem>,
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
) -> SyncScheduleOutcome
```
**Problème de `&mut self` vs `&self`** : `sync_queue()` dans le trait prend `&mut self`
car les backends mutent leur état. Mais `schedule_sync()` veut spawner un thread qui détient
le backend. Solution : le backend est déjà derrière `Arc<Mutex<MusicQueue>>` dans
`MusicRenderer` (ligne 124 de musicrenderer.rs — c'est le `queue` field). Le thread worker
clone cet `Arc` et acquiert le lock pour appeler `sync_queue()`.
**Signature révisée** :
```rust
/// Doit être appelé avec un Arc<Mutex<Self>> pour permettre le spawn du thread worker.
pub fn schedule_sync(
queue_arc: &Arc<Mutex<MusicQueue>>,
renderer_id: &str,
items: Vec<PlaybackItem>,
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
) -> SyncScheduleOutcome {
use std::sync::atomic::Ordering::SeqCst;
let (sync_in_progress, sync_pending, sync_cancel_token) = {
let q = queue_arc.lock().unwrap();
(
Arc::clone(&q.sync_in_progress),
Arc::clone(&q.sync_pending),
Arc::clone(&q.sync_cancel_token),
)
};
if sync_in_progress.swap(true, SeqCst) {
// Sync en cours : annuler et noter pending
sync_cancel_token.store(true, SeqCst);
sync_pending.store(true, SeqCst);
return SyncScheduleOutcome::AlreadyRunning;
}
// Pas de sync en cours : initialiser et spawner
sync_cancel_token.store(false, SeqCst);
sync_pending.store(false, SeqCst);
let queue_arc = Arc::clone(queue_arc);
let thread_name = format!("queue-sync-{}", renderer_id);
thread::Builder::new()
.name(thread_name)
.spawn(move || {
// Guard: libère in_progress à la sortie même en cas de panic
struct Guard(Arc<AtomicBool>);
impl Drop for Guard {
fn drop(&mut self) { self.0.store(false, SeqCst); }
}
let _guard = Guard(Arc::clone(&sync_in_progress));
let mut current_items = items;
let mut current_on_ready = Some(on_ready);
loop {
sync_pending.store(false, SeqCst);
sync_cancel_token.store(false, SeqCst);
let result = {
let mut q = queue_arc.lock().unwrap();
q.backend.sync_queue(
current_items,
&sync_cancel_token,
current_on_ready.take().flatten(),
)
};
match result {
Err(ControlPointError::SyncCancelled) => {
// Annulé normalement — vérifier si pending
}
Err(e) => {
warn!("queue-sync error: {}", e);
}
Ok(()) => {}
}
if !sync_pending.load(SeqCst) {
break; // Pas de nouvelle sync en attente → terminer
}
// Nouvelle sync demandée pendant l'exécution → re-fetcher et relancer
match pending_items_fn() {
Ok(new_items) => {
current_items = new_items;
current_on_ready = Some(None); // pas de on_ready pour les re-syncs
}
Err(e) => {
warn!("queue-sync pending re-fetch error: {}", e);
break;
}
}
}
// _guard libère sync_in_progress = false
})
.expect("Failed to spawn queue-sync thread");
SyncScheduleOutcome::Scheduled
}
```
---
## Étape 6 — Nouveaux événements SSE
### 6.1 `model.rs`
Localiser l'enum `RendererEvent` et ajouter :
```rust
/// Émis dès que la queue peut être lue (pivot préservé ou 1er track inséré).
QueueReadyToPlay {
id: DeviceId,
},
/// Émis quand une sync est annulée car une nouvelle a été demandée.
QueueSyncCancelled {
id: DeviceId,
},
```
### 6.2 `sse.rs`
Localiser le match de sérialisation des `RendererEvent` et ajouter les deux variantes.
Suivre le pattern existant de `QueueRefreshing` (ligne ~152) :
```rust
RendererEvent::QueueReadyToPlay { id } => {
// sérialiser avec type = "queue_ready_to_play"
}
RendererEvent::QueueSyncCancelled { id } => {
// sérialiser avec type = "queue_sync_cancelled"
}
```
---
## Étape 7 — Modifier `control_point.rs`
### 7.1 Extraire la logique de browse
Renommer `refresh_attached_queue_for()` en deux fonctions :
**`fetch_queue_items_for()`** (nouvelle, interne) : fait le browse + conversion, retourne
`Vec<PlaybackItem>`. C'est la `pending_items_fn` passée à `schedule_sync()`.
**`schedule_queue_refresh_for()`** (remplace l'ancienne) : appelle `fetch_queue_items_for()`,
puis `MusicQueue::schedule_sync()`.
```rust
fn fetch_queue_items_for(
registry: &Arc<RwLock<DeviceRegistry>>,
renderer_id: &DeviceId,
) -> Result<Vec<PlaybackItem>, ControlPointError> {
// Browse media server + conversion PlaybackItem
// (logique actuellement dans refresh_attached_queue_for() lignes ~1593-1670)
}
fn schedule_queue_refresh_for(
registry: &Arc<RwLock<DeviceRegistry>>,
renderer_id: &DeviceId,
event_bus: &RendererEventBus,
auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>>,
) -> SyncScheduleOutcome {
let items = match fetch_queue_items_for(registry, renderer_id) {
Ok(items) => items,
Err(e) => { warn!(...); return SyncScheduleOutcome::Scheduled; /* ou erreur */ }
};
// on_ready : déclenche auto_play si demandé + émet QueueReadyToPlay SSE
let rid = renderer_id.clone();
let bus = event_bus.clone();
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
if let Some(cb) = auto_play_cb {
if let Err(e) = cb(&rid) {
warn!("auto-play callback failed: {}", e);
}
}
}));
// pending_items_fn : re-fetcher depuis le media server si pending
let registry2 = Arc::clone(registry);
let rid2 = renderer_id.clone();
let pending_fn = Box::new(move || fetch_queue_items_for(&registry2, &rid2));
// Récupérer l'Arc<Mutex<MusicQueue>> du renderer depuis le registry
let queue_arc = {
let reg = registry.read().unwrap();
reg.get_renderer_queue_arc(renderer_id)? // méthode à ajouter dans DeviceRegistry
};
// Émettre QueueRefreshing avant de lancer
event_bus.broadcast(RendererEvent::QueueRefreshing { id: renderer_id.clone() });
let outcome = MusicQueue::schedule_sync(&queue_arc, &renderer_id.0, items, pending_fn, on_ready);
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
event_bus.broadcast(RendererEvent::QueueSyncCancelled { id: renderer_id.clone() });
}
outcome
}
```
### 7.2 Call site 1 — thread "cp-media-server-event-worker" (l.270)
```rust
// AVANT
let _ = refresh_attached_queue_for(&registry, &renderer_id, &event_bus, None);
// APRÈS
schedule_queue_refresh_for(&registry, &renderer_id, &event_bus, None);
```
### 7.3 Call site 2 — thread "cp-playlist-periodic-refresh" (l.340)
```rust
// AVANT
let _ = refresh_attached_queue_for(&registry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
// APRÈS
schedule_queue_refresh_for(&registry_for_periodic, &renderer_id, &event_bus_for_periodic, None);
```
### 7.4 Call site 3 — `attach_queue_to_playlist_async()` (l.1264)
```rust
pub async fn attach_queue_to_playlist_async(
&self, renderer_id: &DeviceId, server_id: &DeviceId, container_id: &str, auto_play: bool,
) -> Result<(), ControlPointError> {
// 1. Enregistrer la liaison (synchrone, ~1ms)
self.registry.write().unwrap()
.set_playlist_binding(renderer_id, server_id, container_id);
// 2. Construire le callback auto-play si demandé
let cp = self.clone();
let rid = renderer_id.clone();
let auto_play_cb: Option<Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>> =
if auto_play {
Some(Box::new(move |id| cp.play_current_from_queue(id)))
} else {
None
};
// 3. Lancer le refresh async — retourne immédiatement
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, auto_play_cb);
Ok(()) // La webapp sera notifiée via SSE (QueueRefreshing → QueueReadyToPlay → QueueUpdated)
}
```
### 7.5 Fin de sync — émettre `QueueUpdated`
L'événement `QueueUpdated` (avec `queue_length`) est actuellement émis à la ligne ~1718
dans `refresh_attached_queue_for()`. Il doit être émis à la fin du worker thread dans
`MusicQueue::schedule_sync()`, après le `sync_queue()` réussi.
Passer un `on_complete` callback à `schedule_sync()` (en plus de `on_ready`) :
```rust
pub fn schedule_sync(
queue_arc: &Arc<Mutex<MusicQueue>>,
renderer_id: &str,
items: Vec<PlaybackItem>,
pending_items_fn: Box<dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static>,
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
on_complete: Box<dyn Fn(usize) + Send + 'static>, // NOUVEAU — reçoit queue_length
) -> SyncScheduleOutcome
```
Dans le worker, après `Ok(())` du `sync_queue()` :
```rust
Ok(()) => {
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
on_complete(queue_len);
}
```
Dans `schedule_queue_refresh_for()` :
```rust
let bus3 = event_bus.clone();
let rid3 = renderer_id.clone();
let on_complete = Box::new(move |queue_len: usize| {
bus3.broadcast(RendererEvent::QueueUpdated {
id: rid3.clone(),
queue_length: queue_len,
});
});
```
---
## Étape 8 — Accès à `Arc<Mutex<MusicQueue>>` depuis le registry
`schedule_queue_refresh_for()` a besoin d'accéder à l'`Arc<Mutex<MusicQueue>>` du renderer.
Localiser dans `DeviceRegistry` comment les renderers et leurs queues sont stockés, et ajouter
une méthode :
```rust
pub fn get_renderer_queue_arc(
&self,
renderer_id: &DeviceId,
) -> Result<Arc<Mutex<MusicQueue>>, ControlPointError>
```
(Ou équivalent selon la structure réelle du registry.)
---
## Ordre d'implémentation
```
Étape 1 (errors.rs) ← 5 min
Étape 2 (backend.rs) ← 10 min, casse la compilation → à faire avant les autres
Étape 3 (interne.rs) ← 30 min
Étape 4 (openhome.rs) ← 1-2h (nombreux points de vérification cancel)
Étape 5 (music_queue.rs) ← 2-3h (changement struct + schedule_sync)
Étape 6 (model.rs + sse.rs) ← 30 min
Étape 7 (control_point.rs) ← 1h
Étape 8 (registry) ← 30 min selon la structure
```
Après l'étape 2, `cargo build` cassera jusqu'à l'étape 5 incluse — c'est attendu.
Faire les étapes 3, 4, 5 dans la même session sans interrompre.
## Tests
```bash
cargo build -p pmocontrol
# Vérifier les scénarios :
# 1. Attach playlist → QueueRefreshing SSE immédiat, QueueReadyToPlay après 1er insert,
# QueueUpdated après fin complète
# 2. Attach 2e playlist pendant sync en cours → QueueSyncCancelled + nouveau refresh repart
# 3. Piste en cours de lecture pendant sync → QueueReadyToPlay immédiat (pivot préservé)
# 4. Refresh périodique (60s) ne bloque plus son thread
RUST_LOG=debug cargo run 2>&1 | grep -E "queue.sync|SyncCancelled|Scheduled|AlreadyRunning|on_ready|on_complete"
```
---
*Date: 2026-04-09*

View File

@@ -0,0 +1,338 @@
** Ce travail devra être réalisé en suivant scrupuleusement les consignes listées dans le fichier [@Rules_optimal.md](file:///Users/coissac/Sync/maison/Petite_maisons/src/pmomusic/Blackboard/Rules_optimal.md) **
## Contexte et symptôme
Le frontend Vue.js du control point PMOMusic (`pmoapp/webapp`) présente des problèmes de
performance significatifs avec des playlists de ~1000 titres. La plupart des lenteurs sont
côté UI, indépendamment des optimisations déjà réalisées côté Rust/backend.
Les trois manifestations observées :
- **Freeze au scroll** dans la file d'attente (QueueViewer) dès ~200 items
- **Blocage UI temporaire** à l'ouverture d'une playlist dans PlayListManager
- **Refetches JSON répétés** déclenchés par les événements SSE `queue_updated`
## Causes racines identifiées
### P0 — Pas de virtualisation dans `QueueViewer.vue`
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:85-93`
```vue
<!-- Tous les items rendus en DOM simultanément -->
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
<QueueItem
v-for="item in queue.items"
:key="item.index"
...
/>
</div>
```
Pour 1 000 titres : 1 000 nœuds DOM permanents, chacun contenant une image, un composable
réactif (`useCoverImage`), et des computed properties. Le scroll devient impossible.
**Ironie** : `vue-virtual-scroller@^2.0.0-beta.8` est présent dans `package.json` mais
**n'est utilisé nulle part** dans la codebase — manifestement prévu puis abandonné.
### P1 — Pas de virtualisation dans `PlayListManager.vue`
**Fichier** : `src/components/PlayListManager.vue:562-724`
```vue
<div class="track-grid" v-if="sortedTracks.length > 0">
<article v-for="track in sortedTracks" :key="..." class="track-card">
<!-- image + title + artist + album + durée + bitrate + samplerate -->
</article>
</div>
```
Même problème : grid CSS avec 1 000 articles et leurs images (`loading="lazy"`).
Aggravé par la computed `sortedTracks` (ligne 883-890) :
```typescript
const sortedTracks = computed(() => {
return [...detail.tracks].sort( // copie complète du tableau
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
);
});
```
Et `lazyTracksCount` (ligne 892-897) qui filtre les 1 000 items à chaque re-render.
### P2 — `queue_updated` force un refetch JSON complet
**Fichier** : `src/composables/useRenderers.ts:200-206`
```typescript
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
void fetchRendererSnapshot(rendererId, { force: true });
break;
```
`fetchRendererSnapshot()` appelle `api.getRendererFullSnapshot(rendererId)` qui retourne
le snapshot complet incluant **tous les items de la queue avec leurs métadonnées**.
Pour 1 000 titres, le payload JSON peut atteindre plusieurs centaines de Ko. Si l'utilisateur
charge une playlist de 1 000 titres depuis un serveur qui émet les items par batch (ex. 64
par 64 côté Rust), l'événement `queue_updated` est émis plusieurs fois de suite, déclenchant
autant de refetches consécutifs du même JSON complet.
**Note** : `loadingIds.has(rendererId)` (ligne 354) déduplique les requêtes simultanées,
mais pas les requêtes consécutives rapprochées.
### P3 — Infinite scroll accumule toutes les pages en mémoire (`MediaBrowser`)
**Fichier** : `src/composables/useMediaServers.ts:200-204`
```typescript
// Accumuler les nouvelles entrées — jamais purgées
state.entries.push(...data.entries)
```
En parcourant un serveur contenant 1 000 titres, toutes les pages de 50 items
s'accumulent dans `browseCache` sans jamais être libérées. Résultat : après un scroll
complet, 1 000 entrées sont en mémoire ET en DOM simultanément.
### P4 — `scrollIntoView` sur 1 000 nœuds DOM non virtualisés
**Fichier** : `src/components/pmocontrol/QueueViewer.vue:27-48`
```typescript
watch(() => queue.value?.current_index, async (currentIndex) => {
await nextTick();
const currentItem = queueContainer.value.querySelector(".queue-item.current");
if (currentItem) {
currentItem.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}, { immediate: true });
```
`querySelector` sur un conteneur de 1 000 nœuds + animation CSS `smooth` provoque un
layout thrashing. Ce watcher est aussi déclenché au montage (`immediate: true`), ce qui
peut provoquer un re-layout au chargement initial de la page.
## Ce qui fonctionne déjà correctement
- **Déduplication des snapshots simultanés** : `loadingIds.has(rendererId)` évite les
requêtes parallèles pour le même renderer — à préserver.
- **`loading="lazy"` sur les images** dans PlayListManager — efficace une fois que le DOM
est virtualisé.
- **Cache du browse** avec invalidation par conteneur SSE — architecture correcte.
- **`useCoverImage`** avec retry exponentiel et cleanup — à conserver tel quel dans
`QueueItem.vue`.
- **Connexion SSE unique** partagée entre tous les composants — bonne architecture.
## Plan d'exécution
### Répertoire concerné : `pmoapp/webapp`
---
### Étape 1 — Virtualiser la file d'attente dans `QueueViewer.vue`
**Fichier** : `src/components/pmocontrol/QueueViewer.vue`
`vue-virtual-scroller` est déjà installé. Remplacer le `v-for` nu par `<RecycleScroller>` :
```vue
<script setup lang="ts">
import { RecycleScroller } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
// ... imports existants inchangés
</script>
<template>
<!-- Remplacer le div.queue-list + v-for par : -->
<RecycleScroller
v-if="queue?.items.length"
class="queue-list"
:items="queue.items"
:item-size="64"
key-field="index"
v-slot="{ item }"
ref="queueContainer"
>
<QueueItem
:item="item"
:is-current="item.index === queue.current_index"
@click="handleItemClick"
/>
</RecycleScroller>
</template>
```
`item-size="64"` correspond à la hauteur CSS actuelle de `.queue-item` (padding +
cover 48px + gap). À ajuster si le CSS change.
**Adapter `scrollIntoView`** : `RecycleScroller` expose une méthode `scrollToItem(index)`.
Remplacer le `querySelector` + `scrollIntoView` par :
```typescript
watch(() => queue.value?.current_index, async (currentIndex) => {
if (currentIndex !== null && currentIndex !== undefined && queueContainer.value) {
await nextTick();
queueContainer.value.scrollToItem(currentIndex);
}
}, { immediate: true });
```
**Fonctionnalité préservée** : `QueueItem.vue` reste inchangé — `RecycleScroller` recycle
les nœuds DOM au lieu de les créer tous, mais les props passées à chaque item sont
identiques.
---
### Étape 2 — Débouncer les refetches `queue_updated`
**Fichier** : `src/composables/useRenderers.ts:200-206`
Le problème : `queue_updated` arrive N fois de suite pendant le chargement d'une grande
playlist, déclenchant N refetches.
Ajouter un debounce par renderer sur l'appel à `fetchRendererSnapshot` :
```typescript
// Map des timers de debounce par renderer (à déclarer en module scope)
const queueUpdateDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const QUEUE_UPDATE_DEBOUNCE_MS = 300;
// Dans le case "queue_updated" :
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
queueRefreshingIds.delete(rendererId);
// Annuler le timer précédent pour ce renderer
const existingTimer = queueUpdateDebounceTimers.get(rendererId);
if (existingTimer) clearTimeout(existingTimer);
// Programmer un seul fetch après stabilisation
queueUpdateDebounceTimers.set(rendererId, setTimeout(() => {
queueUpdateDebounceTimers.delete(rendererId);
void fetchRendererSnapshot(rendererId, { force: true });
}, QUEUE_UPDATE_DEBOUNCE_MS));
break;
```
**Fonctionnalité préservée** : Si un seul `queue_updated` arrive (cas normal), le refetch
est simplement retardé de 300 ms — imperceptible. Si N arrivent en rafale (chargement
d'une grande playlist), un seul refetch est déclenché à la fin.
**Contrainte** : Ne pas dépasser 500 ms de debounce — l'indicateur `queueRefreshing` dans
l'UI doit se désactiver rapidement après la fin du chargement.
---
### Étape 3 — Virtualiser la grille dans `PlayListManager.vue`
**Fichier** : `src/components/PlayListManager.vue`
La grille CSS ne peut pas être virtualisée directement avec `RecycleScroller` (liste 1D).
Remplacer la grid par une liste virtualisée, ou introduire une pagination côté client :
```typescript
const PAGE_SIZE = 100;
const currentPage = ref(0);
const paginatedTracks = computed(() =>
sortedTracks.value.slice(
currentPage.value * PAGE_SIZE,
(currentPage.value + 1) * PAGE_SIZE
)
);
```
Avec des boutons de navigation Précédent / Suivant et un indicateur de page.
**Optimiser `sortedTracks`** : mémoriser le résultat par `playlist.id` pour éviter
la copie+tri à chaque re-render non lié à la playlist :
```typescript
const sortedTracksCache = new Map<string, TrackEntry[]>();
const sortedTracks = computed(() => {
const detail = selectedPlaylist.value;
if (!detail) return [];
const cached = sortedTracksCache.get(detail.id);
if (cached && cached.length === detail.tracks.length) return cached;
const sorted = [...detail.tracks].sort(
(a, b) => new Date(b.added_at).getTime() - new Date(a.added_at).getTime()
);
sortedTracksCache.set(detail.id, sorted);
return sorted;
});
```
**Simplifier `lazyTracksCount`** : le dériver de `sortedTracks` pour ne pas parcourir
le tableau original en parallèle :
```typescript
const lazyTracksCount = computed(() =>
sortedTracks.value.filter(isLazyTrack).length
);
```
---
### Étape 4 — Limiter l'accumulation dans le browse infini (`MediaBrowser`)
**Fichier** : `src/composables/useMediaServers.ts:183-212`
Implémenter une fenêtre glissante dans `browseCache` : conserver seulement les 200
derniers items en mémoire :
```typescript
const BROWSE_WINDOW_SIZE = 200;
async function loadMoreBrowse(serverId: string, containerId: string) {
// ... code existant jusqu'à la récupération de data ...
// Remplacer : state.entries.push(...data.entries)
// Par :
const combined = [...state.entries, ...data.entries];
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;
browseCache.value.set(key, { ...state });
}
```
**Invariant à préserver** : `state.currentOffset` et `state.hasMore` doivent continuer
de refléter la position réelle dans la liste serveur, indépendamment de ce qui est
affiché — leur logique ne change pas.
---
## Ordre d'exécution
1. **Étape 1** — Virtualisation `QueueViewer` (impact le plus visible, composant le plus simple)
2. **Étape 2** — Debounce `queue_updated` (élimine les refetches en cascade, changement minimal)
3. **Étape 3** — Optimisation `PlayListManager` (plus complexe, composant de 2039 lignes)
4. **Étape 4** — Fenêtre glissante `MediaBrowser` (amélioration mémoire, moins critique)
## Périmètre : ce qui ne change pas
- `QueueItem.vue` : aucune modification (recycling géré par le parent)
- `useCoverImage.ts` : aucune modification (lazy loading + retry déjà corrects)
- `useSSE.ts` : aucune modification (connexion unique, bonne architecture)
- `loadingIds` dans `fetchRendererSnapshot` : déduplication conservée
- `api.getRendererFullSnapshot` : le payload reste complet, pas de pagination API
- Tous les événements SSE autres que `queue_updated` : aucune modification
## Tests recommandés
Demander à l'humain de :
```bash
cd pmoapp/webapp
npm run dev
```
Puis tester manuellement :
- Ouvrir la file d'attente d'un renderer OpenHome avec 1 000 titres : scroll fluide ?
- Vérifier que la piste courante est visible au changement de piste (`scrollToItem`)
- Charger une playlist de 1 000 titres via PlayListManager : absence de blocage ?
- Observer les requêtes réseau dans DevTools lors du chargement d'une grande playlist :
un seul `GET /renderers/{id}/full` doit être émis après la fin du chargement

View File

@@ -0,0 +1,306 @@
# Migration `mdns` → `mdns-sd`
## Contexte
La crate `mdns 3.0.0` (dernière version, 2021, abandonnée) s'appuie sur `dns-parser 0.8.0`
qui rejette les labels DNS non-ASCII avec `LabelIsNotAscii`. Les appareils Apple utilisent
U+2019 (RIGHT SINGLE QUOTATION MARK) dans leurs noms (ex : "Sophie's MacBook Air"), ce qui
spamme les logs en WARN à chaque paquet mDNS reçu.
`mdns-sd 0.19` (avril 2026, activement maintenu) gère l'UTF-8 correctement, fournit une API
de plus haut niveau (service pré-assemblé), et ne nécessite pas `async-std`.
## Périmètre
Deux fichiers à modifier, un fichier à nettoyer :
| Fichier | Rôle |
|---|---|
| `pmocontrol/Cargo.toml` | Dépendances |
| `pmocontrol/src/control_point.rs` | Thread de découverte mDNS (lignes 145195) |
| `pmocontrol/src/discovery/chromecast_discovery.rs` | Parsing des réponses mDNS |
| `pmoserver/src/logs/mod.rs` | Filtre de bruit `mdns=error` devenu inutile |
---
## Étape 1 — `pmocontrol/Cargo.toml`
### Supprimer
```toml
mdns = "3.0"
```
Vérifier si `async-std` et `futures-util` sont utilisés **ailleurs** que dans le thread mDNS.
D'après l'analyse :
- `async_std` : uniquement `control_point.rs:161`**supprimer**
- `futures-util` : uniquement `control_point.rs:150,167`**supprimer**
### Ajouter
```toml
mdns-sd = "0.19"
```
---
## Étape 2 — `pmocontrol/src/control_point.rs`
### Code actuel (lignes 145195) à remplacer intégralement
```rust
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
let udn_cache_for_mdns = Arc::clone(&udn_cache);
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
use futures_util::StreamExt;
let mut discovery_manager =
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
debug!("Starting mDNS discovery thread for Chromecast devices");
const SERVICE_NAME: &str = "_googlecast._tcp.local";
async_std::task::block_on(async {
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
Ok(discovery) => {
let stream = discovery.listen();
futures_util::pin_mut!(stream);
debug!("mDNS discovery stream started for Chromecast devices");
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
debug!("Received mDNS response with {} records", response.records().count());
discovery_manager.handle_mdns_response(response);
}
Err(e) => { warn!("mDNS discovery error: {}", e); }
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => { error!("Failed to start mDNS discovery: {}", e); }
}
});
});
```
### Nouveau code
```rust
// Thread de découverte mDNS pour Chromecast
let registry_for_mdns = Arc::clone(&registry);
let udn_cache_for_mdns = Arc::clone(&udn_cache);
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
use mdns_sd::{ServiceDaemon, ServiceEvent};
let mut discovery_manager =
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
debug!("Starting mDNS discovery thread for Chromecast devices");
// Note: mdns-sd requires the trailing dot in the service type
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
error!("Failed to create mDNS daemon: {}", e);
return;
}
};
let receiver = match daemon.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
return;
}
};
debug!("mDNS discovery started for Chromecast devices");
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
debug!(
fullname = info.get_fullname(),
host = info.get_hostname(),
port = info.get_port(),
"mDNS Chromecast service resolved"
);
discovery_manager.handle_service_resolved(&info);
}
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
debug!(fullname = %fullname, "mDNS Chromecast service removed");
// Pas de retrait actif du registre : le timeout habituel s'en charge
}
_ => {}
}
}
warn!("mDNS discovery receiver closed");
});
```
### Points d'attention
- Le point final `.` dans `"_googlecast._tcp.local."` est **obligatoire** pour `mdns-sd`.
- `receiver.recv()` est bloquant synchrone — pas besoin d'async runtime.
- `ServiceDaemon` gère son propre thread interne ; inutile de relancer manuellement.
---
## Étape 3 — `pmocontrol/src/discovery/chromecast_discovery.rs`
### Supprimer l'import `mdns`
```rust
// Supprimer ces uses implicites via le type dans la signature
use std::collections::HashMap; // <- plus nécessaire si on passe par TxtProperties
```
### Ajouter l'import `mdns-sd`
```rust
use mdns_sd::ServiceInfo;
```
### Remplacer `handle_mdns_response` par `handle_service_resolved`
#### Code actuel (lignes 39179) — ~80 lignes de parsing manuel
Toute la logique d'extraction PTR / A / AAAA / SRV / TXT disparaît.
#### Nouveau code
```rust
/// Traite un service Chromecast résolu par mDNS-SD.
///
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
/// des enregistrements PTR / A / SRV / TXT.
pub fn handle_service_resolved(&mut self, info: &ServiceInfo) {
let fullname = info.get_fullname().to_string();
debug!("Processing resolved Chromecast service: {}", fullname);
// Adresse IP : préférer IPv4
let host = match info
.get_addresses()
.iter()
.find(|a| a.is_ipv4())
.or_else(|| info.get_addresses().iter().next())
{
Some(addr) => addr.to_string(),
None => {
warn!("No IP address for Chromecast service: {}", fullname);
return;
}
};
let port = info.get_port();
// TXT records — API directe par clé
let uuid = info
.get_property_val_str("id")
.unwrap_or_default()
.to_string();
let uuid = if uuid.is_empty() {
format!("chromecast-{}-{}", host, port)
} else {
uuid
};
let model = info.get_property_val_str("md").map(|s| s.to_string());
let friendly_name = info
.get_property_val_str("fn")
.map(|s| s.to_string())
.unwrap_or_else(|| {
// Fallback : extraire depuis le fullname, supprimer le suffixe de service
fullname
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.split('-')
.take_while(|part| part.len() != 32)
.collect::<Vec<_>>()
.join("-")
.trim()
.to_string()
});
debug!(
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
friendly_name, host, port, uuid, model
);
let udn = format!("uuid:{}", uuid);
let default_max_age = 1800u64;
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
debug!("Chromecast {} recently seen, skipping", udn);
return;
}
let renderer_info = build_renderer_info(
&uuid,
&friendly_name,
&host,
port,
model.as_deref(),
Some("Google Inc."),
);
self.device_registry
.write()
.expect("DeviceRegistry mutex lock failed")
.push_renderer(&renderer_info, default_max_age as u32);
}
```
### Supprimer
- L'import `use std::collections::HashMap` (plus utilisé)
- Tout le bloc `handle_mdns_response` (lignes 39179)
### Conserver sans modification
- `build_renderer_info` (lignes 182234)
- `extract_host_from_location` / `extract_port_from_location` (lignes 239258)
- Les tests (lignes 260287)
---
## Étape 4 — `pmoserver/src/logs/mod.rs`
Le filtre de bruit `mdns=error` injecté dans `build_filter_with_noise_suppressions` n'est
plus nécessaire. Deux options :
**Option A (recommandée)** — Supprimer l'entrée du tableau :
```rust
const NOISE_FILTERS: &[(&str, &str)] = &[
// ("mdns", "mdns=error"), // supprimé : migration vers mdns-sd
];
```
Ou supprimer `build_filter_with_noise_suppressions` entièrement si aucun autre bruit n'est
à filtrer, et revenir à `EnvFilter::try_new(base)` direct.
**Option B** — Laisser en place. La directive `mdns=error` ne cause aucun dommage si la
crate `mdns` n'est plus dans le build (elle sera simplement ignorée).
---
## Résumé des diffs
| Fichier | Lignes supprimées | Lignes ajoutées |
|---|---|---|
| `Cargo.toml` | `mdns`, `async-std`, `futures-util` | `mdns-sd` |
| `control_point.rs` | ~50 (async block) | ~35 (sync recv loop) |
| `chromecast_discovery.rs` | ~80 (parsing manuel) | ~50 (lecture ServiceInfo) |
| `logs/mod.rs` | ~5 (filtre bruit) | 0 |
## Vérification
Après implémentation :
1. `cargo check -p pmocontrol` sans erreurs ni `use of undeclared crate mdns`
2. `cargo check -p pmoserver` sans erreurs
3. Tester la découverte d'un Chromecast en réseau local
4. Vérifier l'absence de `LabelIsNotAscii` dans les logs avec des appareils Apple présents

File diff suppressed because it is too large Load Diff

495
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "PMOMusic"
version = "0.3.33"
version = "0.3.48"
edition = "2024"
[dependencies]

View File

@@ -1,6 +1,5 @@
use pmoapp::{WebAppExt, Webapp};
use pmocontrol::ControlPointExt;
use pmomediarenderer::MEDIA_RENDERER;
use pmomediaserver::{
MEDIA_SERVER, MediaServerDeviceExt, ParadiseStreamingExt, sources::SourcesExt,
};
@@ -71,17 +70,6 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Enregistrer les devices UPnP (HTTP + SSDP automatique)
info!("📡 Registering UPnP devices...");
let renderer_instance = server
.write()
.await
.register_device(MEDIA_RENDERER.clone(), true)
.await
.expect("Failed to register MediaRenderer");
let base_url = renderer_instance.base_url();
let desc_route = renderer_instance.description_route();
info!("✅ MediaRenderer ready at {}{}", base_url, desc_route);
let server_instance = server
.write()
.await

View File

@@ -0,0 +1,20 @@
# Rapport : Optimisation performances frontend playlists ~1000 titres
## Résumé
Implémentation de quatre optimisations pour traiter les lenteurs UI observées avec de grandes playlists : virtualisation de la file d'attente avec RecycleScroller, debounce des refetches queue_updated, pagination + cache dans PlayListManager, et fenêtre glissante dans MediaBrowser. Ces changements éliminent les freezes au scroll, bloquages UI et refetches JSON répétés.
## Fichiers modifiés
1. `pmoapp/webapp/src/components/pmocontrol/QueueViewer.vue`
- Remplacement du v-for natif par RecycleScroller de vue-virtual-scroller
- Migration de querySelector+scrollIntoView vers scrollToItem() exposé par RecycleScroller
2. `pmoapp/webapp/src/composables/useRenderers.ts`
- Ajout d'un debounce de 300ms sur les refetches queue_updated pour éviter les refetches en cascade
3. `pmoapp/webapp/src/components/PlayListManager.vue`
- Ajout pagination client (100 items/page) avec navigation Précédent/Suivant
-Ajout cache mémorisé pour sortedTracks par playlist ID
- Simplification lazyTracksCount derivé de sortedTracks
4. `pmoapp/webapp/src/composables/useMediaServers.ts`
- Ajout fenêtre glissante limitant le cache browse aux 200 derniers items

View File

@@ -0,0 +1,24 @@
# Rapport : Évolution architecture webrenderer
## Résumé
Implémentation des phases 0-4 du plan d'évolution : correction du bug P0 (play_handler sans URI), introduction du typage fort avec DeviceCommand/VegearAdapter, integration flac_handle pause/resume, ajout AudioContext avec reconnexion automatique dans PMOPlayer.ts, correction du format de position UPnP, et endpoints JSON nowplaying/state.
## Fichiers modifiés
1. `pmowebrenderer/src/handlers.rs` - Fix P0 + pause/resume flac_handle
2. `pmowebrenderer/src/adapter.rs` - Nouveau fichier avec DeviceCommand et DeviceAdapter
3. `pmowebrenderer/src/state.rs` - VecDeque<DeviceCommand> au lieu de Option<Value>
4. `pmowebrenderer/src/pipeline.rs` - Ajout flac_handle dans PipelineHandle
5. `pmowebrenderer/src/registry.rs` - Format position + get_state + get_state_and_udn
6. `pmowebrenderer/src/register.rs` - Handlers nowplaying et state
7. `pmowebrenderer/src/config.rs` - Nouvelles routes
8. `pmowebrenderer/src/lib.rs` - Exports adapter
9. `pmoapp/webapp/src/services/PMOPlayer.ts` - AudioContext + reconnexion
## Modifications sémantiques
- **P0** : play_handler vérifie URI avant de changement d'état (plus de Transitioning bloqué)
- **P1-P2** : DeviceCommand typé compile-time (plus de serde_json Value non typé)
- **P3** : pause_handler/stop_handler/play_handler appellent flac_handle.pause()/resume()
- **P5** : AudioContext avec createMediaElementSource() et suspend() dans flush()
- **P6** : Reconnexion automatique avec backoff exponentiel
- **P7** : Format position统一 en HH:MM:SS (seconds_to_upnp_time)
- **P8** : Endpoints /nowplaying et /state en JSON

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoapp"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
rust-embed = "8.5.0"

View File

@@ -1609,9 +1609,9 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -101,13 +101,15 @@
font-size: var(--text-base);
}
/* Bouton fermer */
.drawer-close-btn {
/* ========================================
ICONS COMMUNS POUR DRAWERS
======================================== */
/* Classe de base pour les boutons icônes des drawers */
.drawer-icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
padding: 0;
background: rgba(255, 255, 255, 0.1);
@@ -118,34 +120,31 @@
color: var(--color-text);
}
.drawer-close-btn:hover {
.drawer-icon-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.1);
}
.drawer-close-btn:active {
.drawer-icon-btn:active {
transform: scale(0.95);
}
/* Bouton fermer */
.drawer-close-btn {
width: 40px;
height: 40px;
}
.drawer-close-btn:hover {
transform: scale(1.1);
}
/* Bouton retour (ServerDrawer navigation) */
.drawer-back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
flex-shrink: 0;
padding: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
cursor: pointer;
transition: all var(--transition-fast) ease;
color: var(--color-text);
}
.drawer-back-btn:hover {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}

View File

@@ -381,24 +381,27 @@
ANIMATIONS
======================================== */
@keyframes glassShimmer {
0% {
background-position: -200% center;
/* Respecte prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
@keyframes glassShimmer {
0% {
background-position: -200% center;
}
100% {
background-position: 200% center;
}
}
100% {
background-position: 200% center;
}
}
.glass-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: glassShimmer 2s ease-in-out infinite;
.glass-shimmer {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.1) 50%,
rgba(255, 255, 255, 0) 100%
);
background-size: 200% 100%;
animation: glassShimmer 2s ease-in-out infinite;
}
}
/* ========================================

View File

@@ -79,7 +79,14 @@ body {
background-color: var(--status-transitioning-bg);
color: var(--status-transitioning);
border: 1px solid var(--status-transitioning);
animation: pulse 2s infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.status-badge.transitioning {
animation: pulse 2s infinite;
}
}
/* ========================================
@@ -226,52 +233,81 @@ body {
/* ========================================
Animations
======================================== */
@keyframes pulse {
0%, 100% {
opacity: 1;
/* Respecte prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
50% {
opacity: 0.7;
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes pulse-opacity {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.status-badge.transitioning {
animation: pulse 2s infinite;
}
.shuffle-button.loading {
animation: pulse 1s infinite;
}
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.fade-in {
animation: fadeIn var(--transition-base);
}
.spin {
animation: spin 1s linear infinite;
/* Default state (animations disabled) */
.status-badge.transitioning,
.shuffle-button.loading,
.event-badge,
.loading-state {
animation: none;
}
/* Reduced motion support */
@@ -546,10 +582,49 @@ input[type="range"]::-moz-range-thumb:hover {
}
/* ========================================
ANIMATIONS
ANIMATIONS (Keyframes for no-preference media query)
======================================== */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes slideInRight {
from {
transform: translateX(20px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.fade-in {
animation: fadeIn var(--transition-base);
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes pulse-opacity {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
}

View File

@@ -89,13 +89,18 @@
--transition-base: 300ms ease-in-out;
--transition-slow: 500ms ease-in-out;
/* ========================================
Z-index layers
======================================== */
/* ========================================
Z-index layers
======================================== */
--z-dropdown: 100;
--z-modal: 200;
--z-toast: 300;
--z-tooltip: 400;
/* ========================================
Opacity states
======================================== */
--opacity-disabled: 0.4;
}
/* Dark mode support (optionnel pour l'avenir) */

View File

@@ -981,7 +981,14 @@ button.active {
padding: 3rem;
color: #569cd6;
font-size: 1.1rem;
animation: pulse 1.5s ease-in-out infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.loading-state {
animation: pulse 1.5s ease-in-out infinite;
}
}
@keyframes pulse {

View File

@@ -559,12 +559,24 @@
>
</span>
</div>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="pagination-controls">
<button @click="prevPage" :disabled="currentPage === 0">
&larr; Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
Next &rarr;
</button>
</div>
<div
class="track-grid"
v-if="sortedTracks.length > 0"
v-if="paginatedTracks.length > 0"
>
<article
v-for="track in sortedTracks"
v-for="track in paginatedTracks"
:key="`${track.cache_pk}-${track.added_at}`"
class="track-card"
:class="{ lazy: isLazyTrack(track) }"
@@ -880,20 +892,51 @@ const sortedPlaylists = computed(() => {
);
});
const PAGE_SIZE = 100;
const currentPage = ref(0);
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
const sortedTracks = computed(() => {
const detail = selectedPlaylist.value;
if (!detail) return [];
return [...detail.tracks].sort(
const playlistId = detail.summary.id;
const cached = sortedTracksCache.get(playlistId);
if (cached && cached.length === detail.tracks.length) return cached;
const sorted = [...detail.tracks].sort(
(a, b) =>
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
);
sortedTracksCache.set(playlistId, sorted);
return sorted;
});
const paginatedTracks = computed(() =>
sortedTracks.value.slice(
currentPage.value * PAGE_SIZE,
(currentPage.value + 1) * PAGE_SIZE
)
);
const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
function nextPage() {
if (currentPage.value < totalPages.value - 1) {
currentPage.value++;
}
}
function prevPage() {
if (currentPage.value > 0) {
currentPage.value--;
}
}
watch(sortedTracks, () => {
currentPage.value = 0;
});
const lazyTracksCount = computed(() =>
selectedPlaylist.value
? selectedPlaylist.value.tracks.filter((track) => isLazyTrack(track))
.length
: 0,
sortedTracks.value.filter((track) => isLazyTrack(track)).length
);
const updateCoverPreview = computed(
@@ -1881,6 +1924,40 @@ button:disabled {
margin-bottom: var(--spacing-sm);
}
.pagination-controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-md);
padding: var(--spacing-md);
background: rgba(255, 255, 255, 0.04);
border-radius: var(--radius-md);
margin-bottom: var(--spacing-md);
}
.pagination-controls button {
padding: var(--spacing-sm) var(--spacing-md);
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
color: var(--color-text);
cursor: pointer;
}
.pagination-controls button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-controls button:hover:not(:disabled) {
background: var(--color-bg-tertiary);
}
.page-info {
font-size: var(--text-sm);
color: var(--color-text-secondary);
}
.track-grid {
display: flex;
flex-direction: column;

View File

@@ -13,6 +13,7 @@ const props = defineProps<{
const { state } = useRenderer(toRef(props, "rendererId"));
const uiStore = useUIStore();
const metadata = computed(() => state.value?.current_track);
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
const isSeeking = ref(false);
const isDragging = ref(false);
const dragProgress = ref(0);
@@ -149,7 +150,7 @@ function handleProgressBarMouseDown(event: MouseEvent) {
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
@@ -188,7 +189,7 @@ async function handleProgressBarClick(event: MouseEvent) {
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
// En cas d'erreur, réinitialiser
seekTargetMs.value = null;
@@ -251,7 +252,7 @@ function handleOverlayProgressBarTouchStart(event: TouchEvent) {
await api.seekTo(props.rendererId, newPositionSeconds);
} catch (error) {
uiStore.notifyError(
`Impossible de seek: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de changer la position: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
seekTargetMs.value = null;
dragProgress.value = 0;

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch, nextTick, toRef } from "vue";
import { RecycleScroller } from "vue-virtual-scroller";
import "vue-virtual-scroller/dist/vue-virtual-scroller.css";
import { useRenderer } from "@/composables/useRenderers";
import QueueItem from "./QueueItem.vue";
import { Link, Radio, RefreshCw } from "lucide-vue-next";
@@ -17,7 +19,7 @@ const { queue, binding, isStream, queueRefreshing } = useRenderer(toRef(props, "
const isAttached = computed(() => !!binding.value);
const queueContainer = ref<HTMLElement | null>(null);
const queueContainer = ref<any>(null);
function handleItemClick(item: QueueItemType) {
emit("clickItem", item);
@@ -33,15 +35,7 @@ watch(
queueContainer.value
) {
await nextTick();
const currentItem = queueContainer.value.querySelector(
".queue-item.current",
);
if (currentItem) {
currentItem.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
queueContainer.value.scrollToItem(currentIndex);
}
},
{ immediate: true },
@@ -81,16 +75,23 @@ watch(
</div>
</div>
<!-- Liste des items -->
<div v-if="queue?.items.length" class="queue-list" ref="queueContainer">
<!-- Liste des items virtualisée -->
<RecycleScroller
v-if="queue?.items.length"
class="queue-list"
:items="queue.items"
:item-size="64"
key-field="index"
v-slot="{ item }"
ref="queueContainer"
:min-item-size="64"
>
<QueueItem
v-for="item in queue.items"
:key="item.index"
:item="item"
:is-current="item.index === queue.current_index"
@click="handleItemClick"
/>
</div>
</RecycleScroller>
<!-- État vide -->
<div v-else class="queue-empty">
@@ -105,6 +106,8 @@ watch(
flex-direction: column;
gap: var(--spacing-md);
height: 100%;
width: 100%;
min-width: 0;
}
.queue-header {
@@ -196,20 +199,11 @@ watch(
}
.queue-list {
width: 100%;
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
padding-right: var(--spacing-xs);
}
/* Ajoute un espace de scroll en bas pour ne pas cacher les derniers items sous la barre */
.queue-list::after {
content: "";
display: block;
height: 80px; /* Espace pour la barre fixe en bas */
flex-shrink: 0;
min-width: 0;
}
/* Scrollbar styling */

View File

@@ -76,7 +76,14 @@ async function handleShuffle() {
}
.shuffle-button.loading {
animation: pulse 1s infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.shuffle-button.loading {
animation: pulse 1s infinite;
}
}
@keyframes pulse {

View File

@@ -20,12 +20,14 @@ const isStopped = computed(
state.value?.transport_state === "NO_MEDIA",
);
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
async function handlePlay() {
try {
await resumeOrPlayFromQueue(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de démarrer la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -35,7 +37,7 @@ async function handlePause() {
await pause(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de mettre en pause: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -45,7 +47,7 @@ async function handleStop() {
await stop(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible d'arrêter la lecture: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -55,7 +57,7 @@ async function handleNext() {
await next(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de passer au morceau suivant: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -68,6 +70,7 @@ async function handleNext() {
:disabled="isPlaying"
@click="handlePlay"
title="Lecture"
aria-label="Lecture"
>
<Play :size="20" />
</button>
@@ -77,6 +80,7 @@ async function handleNext() {
:disabled="isPaused || isStopped"
@click="handlePause"
title="Pause"
aria-label="Pause"
>
<Pause :size="20" />
</button>
@@ -86,6 +90,7 @@ async function handleNext() {
:disabled="isStopped"
@click="handleStop"
title="Stop"
aria-label="Arrêter"
>
<Square :size="20" />
</button>
@@ -95,6 +100,7 @@ async function handleNext() {
:disabled="!state?.queue_len"
@click="handleNext"
title="Suivant"
aria-label="Morceau suivant"
>
<SkipForward :size="20" />
</button>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, toRef } from "vue";
import { ref, watch, computed, toRef } from "vue";
import { useRenderer, useRenderers } from "@/composables/useRenderers";
import { useUIStore } from "@/stores/ui";
import { Volume2, VolumeX } from "lucide-vue-next";
@@ -12,6 +12,7 @@ const { state } = useRenderer(toRef(props, "rendererId"));
const { setVolume, toggleMute } = useRenderers();
const uiStore = useUIStore();
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
const localVolume = ref(state.value?.volume ?? 50);
// Synchroniser localVolume avec le state
@@ -41,7 +42,7 @@ function handleVolumeChange(event: Event) {
await setVolume(props.rendererId, localVolume.value);
} catch (error) {
uiStore.notifyError(
`Impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de régler le volume: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
debounceTimer = null;
@@ -53,7 +54,7 @@ async function handleToggleMute() {
await toggleMute(props.rendererId);
} catch (error) {
uiStore.notifyError(
`Impossible de basculer le mode muet: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
`« ${rendererName.value} » — impossible de couper/rétablir le son: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -65,6 +66,7 @@ async function handleToggleMute() {
class="btn btn-icon"
@click="handleToggleMute"
:title="state?.mute ? 'Réactiver le son' : 'Couper le son'"
:aria-label="state?.mute ? 'Réactiver le son' : 'Couper le son'"
>
<VolumeX v-if="state?.mute" :size="20" />
<Volume2 v-else :size="20" />
@@ -78,6 +80,7 @@ async function handleToggleMute() {
@input="handleVolumeChange"
class="volume-slider"
:disabled="state?.mute ?? false"
aria-label="Volume"
/>
<span class="volume-value">{{ localVolume }}</span>

View File

@@ -11,6 +11,7 @@ import {
ArrowRightLeft,
} from "lucide-vue-next";
import { useRenderers } from "@/composables/useRenderers";
import { useWebRenderer } from "@/composables/useWebRenderer";
import { useRouter } from "vue-router";
import StatusBadge from "@/components/pmocontrol/StatusBadge.vue";
import type { RendererSummary } from "@/services/pmocontrol/types";
@@ -27,8 +28,27 @@ const emit = defineEmits<{
}>();
const { allRenderers, fetchRenderers, getStateById } = useRenderers();
const webRenderer = useWebRenderer();
const router = useRouter();
// Filtre pour n'afficher que notre WebRenderer (pas les autres onglets/navigateurs)
const myUdn = computed(() => webRenderer.rendererUdn.value);
const filteredRenderers = computed(() => {
const udn = myUdn.value;
const normalizedUdn = udn?.replace(/^uuid:/, '') ?? null;
return allRenderers.value.filter((r: RendererSummary) => {
// WebRenderers have "Web Audio " as friendly_name prefix
const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
});
});
// Gestion du menu déroulant
const openMenuId = ref<string | null>(null);
@@ -110,16 +130,16 @@ watch(
);
const onlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => r.online),
filteredRenderers.value.filter((r: RendererSummary) => r.online),
);
const offlineRenderers = computed(() =>
allRenderers.value.filter((r: RendererSummary) => !r.online),
filteredRenderers.value.filter((r: RendererSummary) => !r.online),
);
// Noms de renderers qui apparaissent plus d'une fois (online + offline)
const duplicateNames = computed(() => {
const counts = new Map<string, number>();
for (const r of allRenderers.value) {
for (const r of filteredRenderers.value) {
counts.set(r.friendly_name, (counts.get(r.friendly_name) ?? 0) + 1);
}
return new Set([...counts.entries()].filter(([, n]) => n > 1).map(([name]) => name));

View File

@@ -19,6 +19,7 @@ const { renderer, state, queue, refresh } = useRenderer(
toRef(props, "rendererId"),
);
const uiStore = useUIStore();
const rendererName = computed(() => state.value?.friendly_name ?? props.rendererId);
// Détection mobile portrait pour afficher le drawer au lieu de la colonne
const isMobilePortrait = useMediaQuery(
@@ -59,7 +60,7 @@ async function handleQueueItemClick(item: QueueItem) {
error,
);
uiStore.notifyError(
`Erreur: ${error instanceof Error ? error.message : "Impossible de sauter à cet item"}`,
`« ${rendererName.value} » — impossible de lire ce morceau: ${error instanceof Error ? error.message : "Erreur inconnue"}`,
);
}
}
@@ -142,6 +143,7 @@ async function handleQueueItemClick(item: QueueItem) {
width: 100%;
height: 100%;
overflow: hidden;
min-width: 0;
}
/* Layout principal - 800x600 landscape (2 colonnes) */
@@ -153,6 +155,7 @@ async function handleQueueItemClick(item: QueueItem) {
/* padding-right: 0 pour coller la scrollbar au bord */
flex: 1;
overflow: hidden;
min-width: 0;
}
/* Colonne gauche - Contrôles */
@@ -185,11 +188,14 @@ async function handleQueueItemClick(item: QueueItem) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 80px;
min-width: 0;
}
.queue-viewer {
flex: 1;
overflow-y: auto;
min-width: 0;
}
/* Queue drawer - masqué sur desktop, visible uniquement sur mobile portrait */
@@ -349,8 +355,6 @@ async function handleQueueItemClick(item: QueueItem) {
@media (min-width: 1200px) {
.renderer-layout {
grid-template-columns: 350px 1fr;
max-width: 1400px;
margin: 0 auto;
}
}

View File

@@ -51,6 +51,7 @@ 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());
}

View File

@@ -187,8 +187,6 @@ onMounted(async () => {
/* Large desktop */
@media (min-width: 1200px) {
.browser-section {
max-width: 1400px;
margin: 0 auto;
width: 100%;
}
}

View File

@@ -372,7 +372,14 @@ watch(editingVar, (newVar) => {
.event-badge {
font-size: 1rem;
animation: pulse 2s ease-in-out infinite;
animation: none;
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: no-preference) {
.event-badge {
animation: pulse 2s ease-in-out infinite;
}
}
@keyframes pulse {

View File

@@ -11,6 +11,7 @@
export interface CacheEntry<T> {
data: T;
timestamp: number;
ttl?: number;
etag?: string;
}
@@ -51,7 +52,9 @@ class ApiCacheService {
private isFresh(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
return Date.now() - entry.timestamp < this.options.ttl;
// Lire le TTL de l'entrée, sinon utiliser le TTL global par défaut
const ttl = entry.ttl ?? this.options.ttl;
return Date.now() - entry.timestamp < ttl;
}
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
@@ -66,12 +69,13 @@ class ApiCacheService {
return entry.data;
}
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string, ttl?: number): void {
const key = this.makeKey(endpoint, params);
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
etag,
});
@@ -92,20 +96,34 @@ class ApiCacheService {
if (cached) return cached;
}
const existing = this.pendingRequests.get(key);
if (existing) {
return existing.promise as Promise<T>;
// Utiliser une clé unique pour éviter les problèmes de race condition
// avec les requêtes en cours qui peuvent être supprimées avant résolution
const requestKey = `request:${key}`;
// Récupérer ou créer la requête
let pendingRequest = this.pendingRequests.get(requestKey);
// Si une requête est en cours et sa promesse n'a pas encore été resolved/rejected
// on retourne directement cette promesse
if (pendingRequest) {
try {
// Attendre la résolution pour s'assurer que c'est toujours valide
return await pendingRequest.promise as T;
} catch (e) {
// La requête a échoué, on continue pour faire une nouvelle requête
this.pendingRequests.delete(requestKey);
}
}
let resolvePromise!: (value: unknown) => void;
let resolvePromise!: (value: T) => void;
let rejectPromise!: (reason: unknown) => void;
const promise = new Promise<unknown>((resolve, reject) => {
const promise = new Promise<T>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
this.pendingRequests.set(key, {
this.pendingRequests.set(requestKey, {
promise,
subscribers: new Set(),
});
@@ -113,18 +131,13 @@ class ApiCacheService {
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);
}
// Passer le TTL directement à set() pour éviter les problèmes de race condition
// avec la modification globale de this.options.ttl
this.set(endpoint, data, params, undefined, ttl);
resolvePromise(data);
const pending = this.pendingRequests.get(key);
const pending = this.pendingRequests.get(requestKey);
if (pending) {
pending.subscribers.forEach(cb => cb(data));
}
@@ -133,10 +146,10 @@ class ApiCacheService {
rejectPromise(error);
throw error;
} finally {
this.pendingRequests.delete(key);
this.pendingRequests.delete(requestKey);
}
return Promise.reject(new Error('Unreachable'));
return promise;
}
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
@@ -182,7 +195,8 @@ class ApiCacheService {
keysToDelete.forEach(key => {
this.cache.delete(key);
this.subscriptions.delete(key);
// NE PAS supprimer this.subscriptions.get(key)
// Les abonnés seront notifiés lors du prochain set() après un refetch
});
}

View File

@@ -37,9 +37,20 @@ class ImageCacheService {
};
private readonly CACHE_CLEANUP_MS = 5 * 60 * 1000;
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
constructor() {
setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
this.cleanupIntervalId = setInterval(() => this.cleanup(), this.CACHE_CLEANUP_MS);
}
/**
* Nettoie le timer de cleanup. À appeler lors de la destruction de l'application.
*/
destroy(): void {
if (this.cleanupIntervalId !== null) {
clearInterval(this.cleanupIntervalId);
this.cleanupIntervalId = null;
}
}
configure(options: Partial<ImageCacheOptions>) {

View File

@@ -20,16 +20,23 @@ 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 searchResults = ref<BrowseState | null>(null)
const searchQuery = ref<string>('')
const CACHE_DURATION_MS = 2000
const BROWSE_WINDOW_SIZE = 200
// Initialiser SSE une seule fois via le composable centralisé
let sseInitialized = false
@@ -69,9 +76,10 @@ function ensureSSEInitialized() {
}
// Invalider tout le cache browse de ce serveur
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})
@@ -80,9 +88,10 @@ function ensureSSEInitialized() {
case 'global_updated':
// Invalider tout le cache de ce serveur
const encodedServerIdGlobal = encodeURIComponent(serverId)
const globalKeysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerIdGlobal + ':')) {
globalKeysToDelete.push(key)
}
})
@@ -92,7 +101,7 @@ function ensureSSEInitialized() {
case 'containers_updated':
// Invalider les containers spécifiques
event.container_ids.forEach(containerId => {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
browseCache.value.delete(key)
})
break
@@ -142,7 +151,7 @@ export function useMediaServers() {
// Charge la première page (remplace le cache)
async function browseContainer(serverId: string, containerId: string, useCache = true) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
if (useCache && browseCache.value.has(key)) {
return browseCache.value.get(key)!
@@ -152,12 +161,14 @@ export function useMediaServers() {
loading.value = true
error.value = null
const data = await api.browseContainer(serverId, containerId, 0)
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)!
@@ -172,22 +183,28 @@ export function useMediaServers() {
// Charge la page suivante et accumule (infinite scroll)
async function loadMoreBrowse(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return
if (state.entries.length >= state.total_count) return
if (!('hasMore' in state) || !state.hasMore) return
if (loadingMore.value) return
try {
loadingMore.value = true
const offset = state.entries.length
// 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
state.entries.push(...data.entries)
// 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) {
@@ -199,6 +216,7 @@ export function useMediaServers() {
// Recherche dans un serveur
async function searchServer(serverId: string, query: string) {
console.log(`[useMediaServers] searchServer called: serverId=${serverId}, query=${query}`);
if (!query.trim()) {
searchResults.value = null
searchQuery.value = ''
@@ -209,8 +227,10 @@ export function useMediaServers() {
loading.value = true
error.value = null
searchQuery.value = query
console.log(`[useMediaServers] Calling API searchServer for server ${serverId}`);
const data = await api.searchServer(serverId, query)
console.log(`[useMediaServers] Search returned ${data.entries.length} entries, total=${data.total_count}`);
searchResults.value = {
container_id: 'search',
entries: data.entries,
@@ -236,12 +256,12 @@ export function useMediaServers() {
}
function getBrowseCached(serverId: string, containerId: string) {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
return browseCache.value.get(key)
}
function hasMore(serverId: string, containerId: string): boolean {
const key = `${serverId}/${containerId}`
const key = browseCacheKey(serverId, containerId)
const state = browseCache.value.get(key)
if (!state) return false
return state.entries.length < state.total_count
@@ -259,12 +279,12 @@ export function useMediaServers() {
// Invalidation du cache
function invalidateCache(serverId: string, containerId?: string) {
if (containerId) {
const key = `${serverId}/${containerId}`
browseCache.value.delete(key)
browseCache.value.delete(browseCacheKey(serverId, containerId))
} else {
const encodedServerId = encodeURIComponent(serverId)
const keysToDelete: string[] = []
browseCache.value.forEach((_, key) => {
if (key.startsWith(serverId + '/')) {
if (key.startsWith(encodedServerId + ':')) {
keysToDelete.push(key)
}
})

View File

@@ -4,11 +4,12 @@
* - Les snapshots complets proviennent de /renderers/{id}/full
* - Les événements SSE ne servent qu'à déclencher un refetch.
*/
import { ref, reactive, computed, toRaw, type Ref } from "vue";
import { ref, reactive, computed, type Ref, onUnmounted } from "vue";
import { api } from "../services/pmocontrol/api";
import { useSSE } from "./useSSE";
import { apiCache } from "./apiCache";
import { parseTimeToMs } from "../utils/time";
import { useUIStore } from "@/stores/ui";
import type {
RendererSummary,
RendererState,
@@ -16,33 +17,35 @@ import type {
AttachedPlaylistInfo,
FullRendererSnapshot,
} from "../services/pmocontrol/types";
import { isTransportState } from "../services/pmocontrol/types";
interface RendererSnapshotState {
snapshots: Map<string, FullRendererSnapshot>;
lastSnapshotAt: Map<string, number>;
lastEventAt: Map<string, number>;
loadingIds: Set<string>;
queueRefreshingIds: Set<string>;
selectedRendererId: string | null;
}
// État global des snapshots avec reactive pour une réactivité native Vue sur les Maps
const snapshots = reactive(new Map<string, FullRendererSnapshot>());
const lastSnapshotAt = reactive(new Map<string, number>());
const lastEventAt = reactive(new Map<string, number>());
const loadingIds = reactive(new Set<string>());
const queueRefreshingIds = reactive(new Set<string>());
const selectedRendererId = ref<string | null>(null);
// Cache des renderers (summary)
const renderersCache = ref<Map<string, RendererSummary>>(new Map());
const RENDERERS_CACHE_MS = 2000;
const snapshotState = reactive<RendererSnapshotState>({
snapshots: reactive(new Map<string, FullRendererSnapshot>()),
lastSnapshotAt: reactive(new Map<string, number>()),
lastEventAt: reactive(new Map<string, number>()),
loadingIds: reactive(new Set<string>()),
queueRefreshingIds: reactive(new Set<string>()),
selectedRendererId: null,
});
// Supprimé : les helpers triggerXXX ne sont plus nécessaires avec reactive
const loading = ref(false);
const error = ref<string | null>(null);
// Utiliser le composable SSE centralisé
let sseInitialized = false;
/**
* Réinitialise le flag SSE pour permettre une nouvelle connexion après reconnexion
*/
function resetSSE() {
sseInitialized = false;
}
function ensureSSEInitialized() {
if (sseInitialized) return;
@@ -63,6 +66,7 @@ function ensureSSEInitialized() {
id: rendererId,
friendly_name: event.friendly_name,
model_name: event.model_name,
manufacturer: event.manufacturer,
protocol: "upnp", // Valeur par défaut, sera mise à jour par le fetch
capabilities: {
has_avtransport: false,
@@ -98,16 +102,16 @@ function ensureSSEInitialized() {
}
// Supprimer le snapshot (il n'est plus valide)
snapshotState.snapshots.delete(rendererId);
snapshotState.lastSnapshotAt.delete(rendererId);
snapshotState.lastEventAt.delete(rendererId);
snapshots.delete(rendererId);
lastSnapshotAt.delete(rendererId);
lastEventAt.delete(rendererId);
return;
}
// Pour les autres événements, mettre à jour le snapshot local directement
snapshotState.lastEventAt.set(rendererId, timestamp);
lastEventAt.set(rendererId, timestamp);
const snapshot = snapshotState.snapshots.get(rendererId);
const snapshot = snapshots.get(rendererId);
// Si pas de snapshot, on doit fetch
if (!snapshot) {
@@ -118,7 +122,16 @@ function ensureSSEInitialized() {
// Sinon, mettre à jour le snapshot localement selon le type d'événement
switch (event.type) {
case "state_changed":
snapshot.state.transport_state = event.state as any;
// Créer un nouvel objet pour déclencher la réactivité
if (isTransportState(event.state)) {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, transport_state: event.state },
});
} else {
console.warn(`[useRenderers] transport_state inconnu: ${event.state}`);
}
break;
case "position_changed":
@@ -127,35 +140,36 @@ function ensureSSEInitialized() {
// Convertir rel_time (HH:MM:SS) en millisecondes
const positionMs = parseTimeToMs(event.rel_time ?? null);
snapshot.state.position_ms = positionMs ?? 0;
// Convertir track_duration (HH:MM:SS) en millisecondes
const durationMs = parseTimeToMs(event.track_duration ?? null);
// Si track_duration est null/undefined (flux continu sans durée),
// mettre duration_ms à null pour afficher "--:--"
snapshot.state.duration_ms = durationMs;
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
// Le shallow copy ne suffit pas car snapshot.state est partagé entre renderers
// Il faut copier state aussi pour éviter que les modifications d'un renderer
// n'affectent les autres renderers
// IMPORTANT: Utiliser toRaw() pour obtenir l'objet brut non-réactif avant de copier
// sinon Vue copie les getters réactifs qui continuent à pointer vers l'objet d'origine
const rawState = toRaw(snapshot.state);
const newState = { ...rawState };
const newSnapshot = {
// Créer un nouvel objet pour déclencher la réactivité
snapshots.set(rendererId, {
...snapshot,
state: newState,
};
snapshotState.snapshots.set(rendererId, newSnapshot);
state: {
...snapshot.state,
position_ms: positionMs ?? 0,
duration_ms: durationMs,
},
});
break;
case "volume_changed":
snapshot.state.volume = event.volume;
// Créer un nouvel objet pour déclencher la réactivité
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, volume: event.volume },
});
break;
case "mute_changed":
snapshot.state.mute = event.mute;
// Créer un nouvel objet pour déclencher la réactivité
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state, mute: event.mute },
});
break;
case "metadata_changed":
@@ -172,20 +186,24 @@ function ensureSSEInitialized() {
snapshot.state.current_track.album = event.album;
snapshot.state.current_track.album_art_uri = event.album_art_uri;
// Important: Trigger reactivity en réassignant l'objet complet avec deep copy
snapshotState.snapshots.set(rendererId, {
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state },
});
break;
case "queue_refreshing":
snapshotState.queueRefreshingIds.add(rendererId);
queueRefreshingIds.add(rendererId);
break;
case "queue_updated":
snapshot.state.queue_len = event.queue_length;
snapshotState.queueRefreshingIds.delete(rendererId);
// Pour la queue complète, on doit refetch
queueRefreshingIds.delete(rendererId);
// Fetch queue items immediately when we get queue_updated
// The debounce was causing race conditions where queue_len was updated
// but items weren't fetched yet when user clicked play
void fetchRendererSnapshot(rendererId, { force: true });
break;
@@ -201,10 +219,17 @@ function ensureSSEInitialized() {
snapshot.binding = null;
snapshot.state.attached_playlist = null;
}
// Créer un nouvel objet pour déclencher la réactivité
snapshots.set(rendererId, {
...snapshot,
state: { ...snapshot.state },
});
break;
case "stream_state_changed":
snapshot.is_stream = event.is_stream;
// Créer un nouvel objet pour déclencher la réactivité
snapshots.set(rendererId, { ...snapshot });
break;
case "timer_started":
@@ -217,8 +242,8 @@ function ensureSSEInitialized() {
break;
}
// Trigger reactivity
snapshotState.snapshots.set(rendererId, snapshot);
// Note: chaque case est maintenant responsable de stocker le snapshot dans la Map
// Plus de réassignation finale après le switch
});
sseInitialized = true;
@@ -229,7 +254,7 @@ const onlineRenderers = computed(() =>
allRenderers.value.filter((r) => r.online),
);
const allSnapshots = computed(() =>
Array.from(snapshotState.snapshots.values()),
Array.from(snapshots.values()),
);
const playingRenderers = computed(() =>
allSnapshots.value
@@ -242,57 +267,73 @@ function getRendererById(id: string) {
}
function getSnapshotById(id: string) {
return snapshotState.snapshots.get(id) ?? null;
return snapshots.get(id) ?? null;
}
function getStateById(id: string): RendererState | null {
return snapshotState.snapshots.get(id)?.state ?? null;
return snapshots.get(id)?.state ?? null;
}
function getQueueById(id: string): QueueSnapshot | null {
return snapshotState.snapshots.get(id)?.queue ?? null;
return snapshots.get(id)?.queue ?? null;
}
function getBindingById(id: string): AttachedPlaylistInfo | null {
return snapshotState.snapshots.get(id)?.binding ?? null;
return snapshots.get(id)?.binding ?? null;
}
function isSnapshotLoading(id: string) {
return snapshotState.loadingIds.has(id);
return loadingIds.has(id);
}
function isQueueRefreshing(id: string) {
return snapshotState.queueRefreshingIds.has(id);
return queueRefreshingIds.has(id);
}
function selectRenderer(id: string | null) {
snapshotState.selectedRendererId = id;
selectedRendererId.value = id;
}
async function fetchRenderers(force = false) {
async function fetchRenderers(force = false, retries = 2) {
ensureSSEInitialized();
// Créer le store UI pour les notifications (lazy import pour éviter les effets de bord)
const uiStore = useUIStore();
try {
loading.value = true;
error.value = null;
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
loading.value = true;
error.value = null;
// Utiliser le cache API centralisé
const data = await apiCache.fetch(
'/renderers',
undefined,
() => api.getRenderers(),
{ force, ttl: RENDERERS_CACHE_MS }
);
renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]),
);
} catch (err) {
error.value = err instanceof Error ? err.message : "Erreur fetch renderers";
console.error("[useRenderers] Erreur fetch:", err);
} finally {
loading.value = false;
// Utiliser le cache API centralisé
const data = await apiCache.fetch(
'/renderers',
undefined,
() => api.getRenderers(),
{ force, ttl: RENDERERS_CACHE_MS }
);
renderersCache.value = new Map(
data.map((renderer) => [renderer.id, renderer]),
);
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error("Erreur fetch renderers");
console.error("[useRenderers] Erreur fetch (attempt " + (attempt + 1) + "):", lastError);
if (attempt < retries) {
await new Promise(r => setTimeout(r, 500 * (attempt + 1)));
}
} finally {
loading.value = false;
}
}
error.value = lastError?.message ?? "Erreur fetch renderers";
// Notifier l'utilisateur en cas d'erreur finale
uiStore.notifyError("Impossible de rafraîchir la liste des lecteurs audio");
}
async function fetchRendererSnapshot(
@@ -301,29 +342,44 @@ async function fetchRendererSnapshot(
) {
ensureSSEInitialized();
const force = opts?.force ?? false;
const hasSnapshot = snapshotState.snapshots.has(rendererId);
const hasSnapshot = snapshots.has(rendererId);
if (!force && hasSnapshot) {
const lastSnapshot = snapshotState.lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = snapshotState.lastEventAt.get(rendererId) ?? 0;
const lastSnapshot = lastSnapshotAt.get(rendererId) ?? 0;
const lastEvent = lastEventAt.get(rendererId) ?? 0;
if (lastEvent <= lastSnapshot) {
return;
}
}
if (snapshotState.loadingIds.has(rendererId)) {
// Éviter les requêtes multiples simultanées pour le même renderer
if (loadingIds.has(rendererId)) {
return;
}
snapshotState.loadingIds.add(rendererId);
loadingIds.add(rendererId);
// Lazy load UI store pour les notifications
const uiStore = useUIStore();
try {
const snapshot = await api.getRendererFullSnapshot(rendererId);
snapshotState.snapshots.set(rendererId, snapshot);
snapshotState.lastSnapshotAt.set(rendererId, Date.now());
snapshots.set(rendererId, snapshot);
lastSnapshotAt.set(rendererId, Date.now());
} catch (err) {
console.error(`[useRenderers] Erreur snapshot ${rendererId}:`, err);
// En cas d'erreur, on supprime le snapshot pour permettre une nouvelle tentative
snapshots.delete(rendererId);
// Notifier l'utilisateur
const name = renderersCache.value.get(rendererId)?.friendly_name ?? rendererId;
uiStore.notifyError(`Impossible de récupérer l'état de « ${name} »`);
} finally {
snapshotState.loadingIds.delete(rendererId);
// Toujours nettoyer le flag de chargement
loadingIds.delete(rendererId);
}
}
@@ -373,7 +429,7 @@ async function play(id: string) {
}
async function resumeOrPlayFromQueue(id: string) {
const snapshot = snapshotState.snapshots.get(id);
const snapshot = snapshots.get(id);
if (!snapshot) {
throw new Error(`Renderer ${id} non trouvé`);
}
@@ -383,6 +439,7 @@ async function resumeOrPlayFromQueue(id: string) {
return play(id);
}
// Check if queue has content
if (
["STOPPED", "NO_MEDIA"].includes(state.transport_state) &&
snapshot.queue.items.length > 0
@@ -495,7 +552,6 @@ export function useRenderers() {
isSnapshotLoading,
isQueueRefreshing,
selectRenderer,
snapshotState,
// Fetchers
fetchRenderers,
fetchRendererSnapshot,
@@ -519,6 +575,8 @@ export function useRenderers() {
playContent,
addToQueue,
addAfterCurrent,
// SSE
resetSSE,
};
}
@@ -534,10 +592,33 @@ export function useRenderer(rendererId: Ref<string>) {
const isStream = computed(() => snapshot.value?.is_stream ?? false);
const queueRefreshing = computed(() => isQueueRefreshing(rendererId.value));
// Debounce pour éviter les refreshs multiples trop fréquents
let refreshDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const REFRESH_DEBOUNCE_MS = 500;
// Nettoyer le timer debounce si le composant est démonté
onUnmounted(() => {
if (refreshDebounceTimer !== null) {
clearTimeout(refreshDebounceTimer);
refreshDebounceTimer = null;
}
});
async function refresh(force = true) {
const currentRendererId = rendererId.value;
// Debounce: ignorer si un refresh est en cours pour ce renderer
if (refreshDebounceTimer !== null) {
return;
}
refreshDebounceTimer = setTimeout(() => {
refreshDebounceTimer = null;
}, REFRESH_DEBOUNCE_MS);
await Promise.all([
fetchRenderers(force),
fetchRendererSnapshot(rendererId.value, { force: true }),
fetchRendererSnapshot(currentRendererId, { force: true }),
]);
}

View File

@@ -17,13 +17,17 @@ import type {
} from '../services/pmocontrol/types'
// État global partagé
const connected = ref(sse.isConnectedState())
const connectionCallbacks: Set<(connected: boolean) => void> = new Set()
const connected = ref(sse.isConnectedState());
const connectionCallbacks: Set<(connected: boolean) => void> = new Set();
// Flag pour éviter les double-connexions SSE avec lock
let connectionLock = false;
// Abonnement à l'état de connexion global
function setupConnectionListener() {
// S'assurer qu'on ne s'abonne qu'une seule fois
if (connectionCallbacks.size === 0) {
// Vérifier avec lock pour éviter les conditions de course
if (connectionCallbacks.size === 0 && !connectionLock) {
connectionLock = true;
sse.onConnectionChange((isConnected) => {
connected.value = isConnected
connectionCallbacks.forEach(cb => cb(isConnected))

View File

@@ -43,6 +43,10 @@ const state = reactive<TabsState>({
// Flag pour éviter les boucles de sauvegarde
let isRestoringFromStorage = false;
// Debounce timer pour la sauvegarde localStorage
let saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const SAVE_DEBOUNCE_MS = 100;
/**
* Retourne le titre complet sans troncature
* Note: On laisse le CSS gérer l'overflow avec ellipsis pour un affichage stable
@@ -53,29 +57,38 @@ function truncateTitle(title: string): string {
}
/**
* Sauvegarde l'état dans localStorage
* Sauvegarde l'état dans localStorage (avec debounce)
* Note: On ne sauvegarde que les onglets server (les renderer tabs sont auto-générés)
*/
function saveToLocalStorage() {
if (isRestoringFromStorage) return;
try {
const stateToSave = {
// Sauvegarder uniquement les onglets server (fermables manuellement)
tabs: state.tabs
.filter((tab) => tab.type === "server")
.map((tab) => ({
...tab,
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
icon: undefined,
})),
activeTabId: state.activeTabId,
tabHistory: state.tabHistory,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
// Annuler le timer précédent
if (saveDebounceTimer !== null) {
clearTimeout(saveDebounceTimer);
}
// Débouncer pour éviter les écritures multiples
saveDebounceTimer = setTimeout(() => {
try {
const stateToSave = {
// Sauvegarder uniquement les onglets server (fermables manuellement)
tabs: state.tabs
.filter((tab) => tab.type === "server")
.map((tab) => ({
...tab,
// On ne peut pas sauvegarder les composants Vue, on sauve juste le type
icon: undefined,
})),
activeTabId: state.activeTabId,
tabHistory: state.tabHistory,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
} catch (error) {
console.error("[useTabs] Erreur sauvegarde localStorage:", error);
}
saveDebounceTimer = null;
}, SAVE_DEBOUNCE_MS);
}
/**
@@ -83,11 +96,11 @@ function saveToLocalStorage() {
* Note: Restaure uniquement les onglets server (les renderer tabs seront auto-générés)
*/
function restoreFromLocalStorage() {
isRestoringFromStorage = true;
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return;
isRestoringFromStorage = true;
const savedState = JSON.parse(saved);
// Reconstituer uniquement les tabs server avec les bonnes icônes
@@ -110,10 +123,9 @@ function restoreFromLocalStorage() {
if (!state.tabs.find((t) => t.id === state.activeTabId)) {
state.activeTabId = "";
}
isRestoringFromStorage = false;
} catch (error) {
console.error("[useTabs] Erreur restauration localStorage:", error);
} finally {
isRestoringFromStorage = false;
}
}
@@ -346,12 +358,18 @@ function openServer(server: MediaServerSummary | undefined) {
*/
export function useTabs() {
// Watch pour sauvegarde automatique (uniquement les server tabs)
// Watch séparés sans deep pour éviter la sérialisation complète à chaque mutation
watch(() => state.activeTabId, () => {
saveToLocalStorage();
});
watch(() => state.tabHistory.length, () => {
saveToLocalStorage();
});
watch(
() => [state.tabs, state.activeTabId, state.tabHistory],
() => state.tabs.map(t => t.id + t.type + (t.metadata?.rendererId ?? '') + (t.metadata?.serverId ?? '')).join('|'),
() => {
saveToLocalStorage();
},
{ deep: true },
);
// Restaurer au montage (uniquement les server tabs)

View File

@@ -1,24 +1,13 @@
/**
* Composable pour gérer le WebRenderer navigateur.
*
* S'enregistre via POST /api/webrenderer/register au montage
* et diffuse l'audio via un élément <audio> pointant sur
* /api/webrenderer/{id}/stream (flux FLAC encodé côté serveur).
*
* Le navigateur est vu comme un renderer UPnP par le ControlPoint.
* Le gapless est géré côté serveur : le navigateur lit un flux continu.
*
* Cycle de vie du flux audio :
* - PLAYING/TRANSITIONING → src = stream_url + play() (nouvelle connexion HTTP)
* - PAUSED/STOPPED → pause() + src = "" (déconnexion HTTP)
* Utilise PMOPlayer pour le controle remote.
* S'enregistre via POST /api/webrenderer/register
* Utilise polling HTTP pour les commands
*/
import { ref, onMounted, onUnmounted, readonly } from "vue";
import { useSSE } from "./useSSE";
// ─── Identifiant stable de l'instance navigateur ─────────────────────────────
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
import { PMOPlayer } from "@/services/PMOPlayer";
function generateUUID(): string {
if (typeof crypto.randomUUID === "function") {
@@ -32,6 +21,36 @@ function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
const INSTANCE_ID_KEY = "pmomusic_webrenderer_instance_id";
const RENDERER_UDN_KEY = "pmomusic_webrenderer_udn";
// Load persisted UDN on module load
function loadPersistedUdn(): string | null {
try {
return sessionStorage.getItem(RENDERER_UDN_KEY);
} catch {
return null;
}
}
function savePersistedUdn(udn: string) {
try {
sessionStorage.setItem(RENDERER_UDN_KEY, udn);
} catch {
// ignore storage errors
}
}
// Module-level singleton for PMOPlayer to prevent duplicate instances
let globalPlayer: PMOPlayer | null = null;
let globalInstanceId: string | null = null;
let registering = false;
// Module-level reactive state shared across all composable invocations
const sharedConnected = ref(false);
const sharedStreamUrl = ref<string | null>(null);
const sharedRendererUdn = ref<string | null>(loadPersistedUdn());
function getOrCreateInstanceId(): string {
try {
let id = sessionStorage.getItem(INSTANCE_ID_KEY);
@@ -45,146 +64,60 @@ function getOrCreateInstanceId(): string {
}
}
// ─── Types ────────────────────────────────────────────────────────────────────
interface RegisterRequest {
instance_id: string;
user_agent: string;
}
interface RegisterResponse {
stream_url: string;
udn: string;
}
// ─── Composable ───────────────────────────────────────────────────────────────
export function useWebRenderer() {
const connected = ref(false);
const streamUrl = ref<string | null>(null);
/** UDN du device UPnP créé côté serveur pour ce navigateur */
const rendererUdn = ref<string | null>(null);
const connected = sharedConnected;
const streamUrl = sharedStreamUrl;
const rendererUdn = sharedRendererUdn;
let audioEl: HTMLAudioElement | null = null;
let instanceId: string | null = null;
let currentStreamUrl: string | null = null;
let player: PMOPlayer | null = null;
let onConnectedCallback: (() => void) | null = null;
let sseUnsubscribe: (() => void) | null = null;
let pendingCanPlay: (() => void) | null = null;
let positionInterval: ReturnType<typeof setInterval> | null = null;
// ── Reporting de position ─────────────────────────────────────────────────
function startPositionReporting(): void {
stopPositionReporting();
positionInterval = setInterval(() => {
if (!audioEl || !instanceId) return;
const pos = audioEl.currentTime;
const dur = isFinite(audioEl.duration) ? audioEl.duration : null;
void fetch(`/api/webrenderer/${instanceId}/position`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ position_sec: pos, duration_sec: dur }),
});
}, 1000);
}
function stopPositionReporting(): void {
if (positionInterval !== null) {
clearInterval(positionInterval);
positionInterval = null;
}
}
// ── Connexion / déconnexion du flux audio ─────────────────────────────────
function startStream(): void {
console.log("[WebRenderer] startStream called, audioEl=", !!audioEl, "currentStreamUrl=", currentStreamUrl);
if (!audioEl || !currentStreamUrl) return;
const el = audioEl;
// Si le stream est en erreur (networkState=3), réinitialiser avant de réessayer
if (el.networkState === 3 /* NETWORK_NO_SOURCE */ && !pendingCanPlay) {
console.log("[WebRenderer] startStream: networkState=3, resetting before retry");
el.removeAttribute("src");
el.load();
}
// Si le stream est déjà chargé/en cours, ne pas réouvrir la connexion HTTP.
// (évite que PLAYING après TRANSITIONING ne crée un nouveau pipe)
if (el.hasAttribute("src") && (el.readyState > 0 || pendingCanPlay)) {
console.log("[WebRenderer] startStream: stream already open, ignoring (readyState=", el.readyState, ")");
return;
}
// Sur Safari, play() échoue avec NotSupportedError si appelé avant que
// l'élément audio ait reçu assez de données (readyState < HAVE_FUTURE_DATA).
// On attend canplay avant d'appeler play().
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
}
el.src = currentStreamUrl;
console.log("[WebRenderer] src set, readyState=", el.readyState, "networkState=", el.networkState);
el.addEventListener("error", () => {
console.error("[WebRenderer] event:error code=", el.error?.code, el.error?.message,
"readyState=", el.readyState, "networkState=", el.networkState);
// Nettoyer pendingCanPlay pour permettre un retry au prochain PLAYING/TRANSITIONING
if (pendingCanPlay) {
el.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
}, { once: true });
el.addEventListener("loadstart", () => console.debug("[WebRenderer] event:loadstart readyState=", el.readyState), { once: true });
el.addEventListener("loadedmetadata", () => console.debug("[WebRenderer] event:loadedmetadata readyState=", el.readyState), { once: true });
el.addEventListener("loadeddata", () => console.debug("[WebRenderer] event:loadeddata readyState=", el.readyState), { once: true });
el.addEventListener("progress", () => console.debug("[WebRenderer] event:progress readyState=", el.readyState), { once: true });
el.addEventListener("stalled", () => console.warn("[WebRenderer] event:stalled readyState=", el.readyState, "networkState=", el.networkState));
el.addEventListener("waiting", () => console.warn("[WebRenderer] event:waiting readyState=", el.readyState));
el.addEventListener("suspend", () => console.debug("[WebRenderer] event:suspend readyState=", el.readyState, "networkState=", el.networkState), { once: true });
el.addEventListener("abort", () => console.warn("[WebRenderer] event:abort"), { once: true });
el.addEventListener("emptied", () => console.warn("[WebRenderer] event:emptied"), { once: true });
const onCanPlay = () => {
console.log("[WebRenderer] event:canplay readyState=", el.readyState, "calling play()");
pendingCanPlay = null;
el.removeEventListener("canplay", onCanPlay);
el.play().then(() => {
console.log("[WebRenderer] play() resolved OK");
startPositionReporting();
}).catch((e: unknown) => {
console.warn("[WebRenderer] play() rejected:", e);
});
};
pendingCanPlay = onCanPlay;
el.addEventListener("canplay", onCanPlay);
}
function stopStream(): void {
console.log("[WebRenderer] stopStream called, readyState=", audioEl?.readyState);
stopPositionReporting();
if (!audioEl) return;
if (pendingCanPlay) {
audioEl.removeEventListener("canplay", pendingCanPlay);
pendingCanPlay = null;
}
audioEl.pause();
audioEl.removeAttribute("src"); // ferme la connexion HTTP (src="" résolu comme URL de page sur Safari)
audioEl.load(); // force le reset de l'état réseau
}
// ── Enregistrement ────────────────────────────────────────────────────────
async function register(): Promise<void> {
instanceId = getOrCreateInstanceId();
// Allow re-registration if we have a stale player but no connection
if (globalPlayer && !connected.value) {
console.log('[WebRenderer] stale globalPlayer, clearing');
globalPlayer = null;
}
if (globalPlayer) {
player = globalPlayer;
connected.value = true;
console.log('[WebRenderer] reusing existing player');
return;
}
const body: RegisterRequest = {
instance_id: instanceId,
user_agent: navigator.userAgent,
};
if (registering) {
console.log('[WebRenderer] already registering, waiting...');
// Wait a bit and try once
await new Promise(r => setTimeout(r, 1000));
if (globalPlayer) {
player = globalPlayer;
connected.value = true;
return;
}
}
registering = true;
let instanceId;
try {
instanceId = getOrCreateInstanceId();
} catch (e) {
console.error('[WebRenderer] failed to get instanceId:', e);
registering = false;
return;
}
console.log('[WebRenderer] registering with instanceId:', instanceId);
try {
const resp = await fetch("/api/webrenderer/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
body: JSON.stringify({
instance_id: instanceId,
user_agent: navigator.userAgent,
}),
});
if (!resp.ok) {
@@ -192,98 +125,90 @@ export function useWebRenderer() {
return;
}
const data = (await resp.json()) as RegisterResponse;
const data = await resp.json();
streamUrl.value = data.stream_url;
currentStreamUrl = data.stream_url;
rendererUdn.value = data.udn;
savePersistedUdn(data.udn);
player = new PMOPlayer(instanceId);
globalPlayer = player;
globalInstanceId = instanceId;
player.setDebug(true);
player.on('play', () => console.log('[WebRenderer] playing'));
player.on('pause', () => console.log('[WebRenderer] paused'));
// Si le backend est déjà en lecture (reconnexion après reload), démarrer immédiatement
if (data.should_play && data.stream_url) {
console.log('[WebRenderer] backend already playing, starting stream');
player.playStream(data.stream_url);
}
connected.value = true;
onConnectedCallback?.();
// S'abonner aux événements SSE du renderer pour piloter la lecture
const { connect, onRendererEvent } = useSSE();
connect();
const udn = data.udn;
sseUnsubscribe?.();
sseUnsubscribe = onRendererEvent((event) => {
if (event.renderer_id !== udn) return;
if (event.type !== "state_changed") return;
const state = event.state;
console.log("[WebRenderer] SSE state_changed →", state, "| event.renderer_id=", event.renderer_id, "udn=", udn, "| audioEl.src=", audioEl?.src, "readyState=", audioEl?.readyState, "networkState=", audioEl?.networkState, "pendingCanPlay=", !!pendingCanPlay);
if (state === "PLAYING" || state === "TRANSITIONING") {
startStream();
} else if (state === "PAUSED" || state === "STOPPED") {
stopStream();
}
});
// Force refresh renderers list after registration
// The SSE might miss the initial event
setTimeout(() => {
console.log('[WebRenderer] forcing renderers refresh');
window.dispatchEvent(new CustomEvent('webrenderer-registered'));
}, 500);
} catch (e) {
console.error("[WebRenderer] register error:", e);
} finally {
registering = false;
}
}
// ── Désenregistrement ─────────────────────────────────────────────────────
async function unregister(): Promise<void> {
if (!instanceId) return;
// Only unregister if this is the global player - prevent duplicate unregister calls
if (player !== globalPlayer || !player) {
return;
}
const instanceId = globalInstanceId || getOrCreateInstanceId();
// Clear global first to prevent other components from using it
globalPlayer = null;
globalInstanceId = null;
try {
await fetch(`/api/webrenderer/${instanceId}`, { method: "DELETE" });
} catch {
// Ignoré lors du déchargement de page
// Ignored
}
instanceId = null;
player?.destroy();
player = null;
}
// ── Volume / Mute ─────────────────────────────────────────────────────────
function setVolume(v: number): void {
if (audioEl) audioEl.volume = v;
function setVolume(_v: number): void {
// PMOPlayer doesn't control volume directly - handled by backend
}
function setMute(m: boolean): void {
if (audioEl) audioEl.muted = m;
function setMute(_m: boolean): void {
// PMOPlayer doesn't control mute directly - handled by backend
}
// ── Cycle de vie ──────────────────────────────────────────────────────────
const beforeUnloadHandler = () => void unregister();
onMounted(() => {
audioEl = document.createElement("audio");
audioEl.preload = "auto";
document.body.appendChild(audioEl);
void register();
window.addEventListener("beforeunload", () => void unregister());
window.addEventListener("beforeunload", beforeUnloadHandler);
});
onUnmounted(() => {
sseUnsubscribe?.();
sseUnsubscribe = null;
stopPositionReporting();
stopStream();
void unregister();
if (audioEl) {
audioEl.remove();
audioEl = null;
}
connected.value = false;
streamUrl.value = null;
rendererUdn.value = null;
window.removeEventListener("beforeunload", () => void unregister());
window.removeEventListener("beforeunload", beforeUnloadHandler);
});
// ── API publique ──────────────────────────────────────────────────────────
return {
/** true quand l'instance est enregistrée sur le serveur */
connected: readonly(connected),
/** URL du flux FLAC servi par le serveur */
streamUrl: readonly(streamUrl),
/** UDN du device UPnP créé pour ce navigateur (null avant enregistrement) */
rendererUdn: readonly(rendererUdn),
/** Callback appelé quand l'enregistrement est confirmé */
onConnected(fn: () => void) {
onConnectedCallback = fn;
},
setVolume,
setMute,
};
}
}

View File

@@ -9,6 +9,9 @@ import { sse } from "./services/pmocontrol/sse";
// Store UI (garde UIStore pour les notifications et état UI global)
import { useUIStore } from "./stores/ui";
// Image cache (pour cleanup)
import { imageCache } from "./composables/imageCache";
// Styles
import "./style.css";
import "./assets/styles/variables.css";
@@ -24,12 +27,13 @@ const pinia = createPinia();
app.use(pinia);
app.use(router);
// Initialiser UIStore AVANT le montage pour éviter la race condition (P2)
const uiStore = useUIStore();
// Monter l'application
app.mount("#app");
// Après montage, initialiser SSE
const uiStore = useUIStore();
// Les composables se connectent automatiquement à SSE
// Ils gèrent eux-mêmes le re-fetch lors des événements
@@ -39,3 +43,8 @@ sse.onConnectionChange((connected) => {
// Démarrer la connexion SSE
sse.connect();
// Cleanup global lors du unload de la page
window.addEventListener('beforeunload', () => {
imageCache.destroy();
});

View File

@@ -1,4 +1,5 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
// PMOControl Unified View (nouvelle interface unifiée)
import UnifiedControlView from "../views/UnifiedControlView.vue";
@@ -8,18 +9,10 @@ import DashboardView from "../views/DashboardView.vue";
import RendererView from "../views/RendererView.vue";
import MediaServerView from "../views/MediaServerView.vue";
// Debug Components (anciennes routes)
import GenericMusicPlayer from "../components/GenericMusicPlayer.vue";
import LogView from "../components/LogView.vue";
import CoverCacheManager from "../components/CoverCacheManager.vue";
import AudioCacheManager from "../components/AudioCacheManager.vue";
import PlayListManager from "../components/PlayListManager.vue";
import UpnpExplorer from "../components/UpnpExplorer.vue";
import APIDashboard from "../components/APIDashboard.vue";
import RadioParadiseExplorer from "../components/RadioParadiseExplorer.vue";
import DebugView from "../views/DebugView.vue";
// Debug Components - lazy loaded uniquement en mode développement (P8)
const isDev = import.meta.env.DEV;
const routes = [
const routes: RouteRecordRaw[] = [
// PMOControl Unified Interface (nouvelle interface unifiée avec onglets)
{
path: "/",
@@ -43,57 +36,65 @@ const routes = [
name: "MediaServer",
component: MediaServerView,
},
// Debug hub
{
path: "/debug",
name: "Debug",
component: DebugView,
},
// Debug menu (anciennes routes déplacées sous /debug)
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: GenericMusicPlayer,
},
{
path: "/debug/logs",
name: "Logs",
component: LogView,
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: CoverCacheManager,
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: AudioCacheManager,
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: PlayListManager,
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: UpnpExplorer,
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: APIDashboard,
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: RadioParadiseExplorer,
},
];
// Ajouter les routes de debug uniquement en développement
if (isDev) {
routes.push(
{
path: "/debug",
name: "Debug",
component: () => import("../views/DebugView.vue"),
},
{
path: "/debug/generic-player",
name: "GenericPlayer",
component: () => import("../components/GenericMusicPlayer.vue"),
},
{
path: "/debug/logs",
name: "Logs",
component: () => import("../components/LogView.vue"),
},
{
path: "/debug/covers-cache",
name: "CoversCache",
component: () => import("../components/CoverCacheManager.vue"),
},
{
path: "/debug/audio-cache",
name: "AudioCache",
component: () => import("../components/AudioCacheManager.vue"),
},
{
path: "/debug/playlists",
name: "PlaylistsManager",
component: () => import("../components/PlayListManager.vue"),
},
{
path: "/debug/upnp",
name: "UpnpExplorer",
component: () => import("../components/UpnpExplorer.vue"),
},
{
path: "/debug/api-dashboard",
name: "APIDashboard",
component: () => import("../components/APIDashboard.vue"),
},
{
path: "/debug/radio-paradise",
name: "RadioParadise",
component: () => import("../components/RadioParadiseExplorer.vue"),
}
);
}
// Wildcard redirect pour les routes inconnues
routes.push({
path: "/:pathMatch(.*)*",
redirect: "/",
});
const router = createRouter({
// history avec base /app
history: createWebHistory("/app"),

View File

@@ -0,0 +1,481 @@
/**
* PMOPlayer - Invisible remote-controlled audio player for browser
*
* Receives commands from Web Media Renderer (backend)
* Reports position/state back to backend via HTTP
*
* Usage:
* const player = new PMOPlayer('my-instance-id');
* player.on('play', () => console.log('playing'));
*/
export type PlayerState = 'playing' | 'paused' | 'stopped' | 'buffering' | 'error';
export type ReadyState = 'have_nothing' | 'have_metadata' | 'have_current_data' | 'have_future_data' | 'can_play' | 'can_play_through';
export interface PlayerEvents {
play: () => void;
pause: () => void;
stop: () => void;
flush: () => void;
positionchange: (position_sec: number) => void;
durationchange: (duration_sec: number) => void;
statechange: (state: PlayerState) => void;
trackchange: (track: TrackInfo) => void;
readychange: (ready: ReadyState) => void;
error: (error: string) => void;
}
export interface TrackInfo {
id: string;
title: string;
artist: string;
album?: string;
cover?: string;
}
// Types stricts pour les commandes reçues du backend (P6)
interface StreamCommand {
type: 'stream';
url: string;
}
interface PlayCommand {
type: 'play';
}
interface PauseCommand {
type: 'pause';
}
interface SeekCommand {
type: 'seek';
timestamp: number;
}
interface FlushCommand {
type: 'flush';
}
interface StopCommand {
type: 'stop';
}
type CommandMessage = StreamCommand | PlayCommand | PauseCommand | SeekCommand | FlushCommand | StopCommand;
function isValidCommand(msg: Record<string, unknown> | unknown): msg is CommandMessage {
if (!msg || typeof msg !== 'object') return false;
if (!('type' in msg)) return false;
const type = (msg as Record<string, unknown>).type;
if (typeof type !== 'string') return false;
// Valider les champs selon le type
switch (type) {
case 'stream':
return 'url' in msg && typeof (msg as StreamCommand).url === 'string';
case 'seek':
return 'timestamp' in msg && typeof (msg as SeekCommand).timestamp === 'number';
case 'play':
case 'pause':
case 'flush':
case 'stop':
return true;
default:
return false;
}
}
export class PMOPlayer {
private audio: HTMLAudioElement;
private instanceId: string;
private ac: AudioContext | null = null;
private state: PlayerState = 'stopped';
private positionInterval: number | null = null;
private commandInterval: number | null = null;
private listeners: Partial<PlayerEvents> = {};
private debug: boolean = false;
private pendingPlay = false;
private unlockListener: (() => void) | null = null;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 5;
private reconnectTimeout: number | null = null;
constructor(instanceId: string) {
this.instanceId = instanceId;
console.log('[PMOPlayer] constructor called for:', instanceId);
this.audio = new Audio();
this.audio.preload = 'auto';
this.audio.style.display = 'none';
this.audio.style.visibility = 'hidden';
this.audio.style.position = 'absolute';
this.audio.style.width = '0';
this.audio.style.height = '0';
this.audio.style.overflow = 'hidden';
document.body.appendChild(this.audio);
this.setupAudioListeners();
this.startCommandPolling();
}
private ensureAudioContext(): AudioContext {
if (!this.ac) {
this.ac = new AudioContext();
const source = this.ac.createMediaElementSource(this.audio);
source.connect(this.ac.destination);
}
return this.ac;
}
private scheduleReconnect() {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
this.log('max reconnect attempts reached, giving up');
this.setState('error');
return;
}
const delayMs = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 16000);
this.reconnectAttempts++;
this.log(`reconnect attempt ${this.reconnectAttempts} in ${delayMs}ms`);
this.reconnectTimeout = window.setTimeout(() => {
const url = this.audio.getAttribute('data-stream-url');
if (url) {
this.stream(url);
this.play();
}
}, delayMs);
}
private log(...args: unknown[]) {
if (this.debug) {
console.log('[PMOPlayer]', ...args);
}
}
private async fetchCommand() {
try {
const resp = await fetch(`/api/webrenderer/${this.instanceId}/command`);
if (resp.status === 204) {
// No command pending
return;
}
if (resp.ok) {
const text = await resp.text();
if (text) {
const cmd = JSON.parse(text);
if (cmd && cmd.type) {
this.log('fetched command', cmd);
this.handleCommand(cmd);
}
}
}
} catch (err) {
this.log('fetch command error', err);
}
}
private startCommandPolling() {
this.commandInterval = window.setInterval(() => {
// ATTENTION : ne pas conditionner ce poll à l'état courant.
// L'état initial est 'stopped', et c'est dans cet état que le backend
// envoie la première commande 'stream' pour démarrer la lecture.
// Filtrer sur state !== 'stopped' casse le chemin nominal de démarrage.
this.fetchCommand();
}, 500);
}
private setupAudioListeners() {
this.audio.addEventListener('play', () => {
this.log('play event');
this.setState('playing');
this.startPositionReporting();
});
this.audio.addEventListener('pause', () => {
this.log('pause event');
this.setState('paused');
});
this.audio.addEventListener('ended', () => {
this.log('ended event');
this.setState('stopped');
this.stopPositionReporting();
});
this.audio.addEventListener('error', () => {
if (!this.audio.getAttribute('src')) return;
const code = this.audio.error?.code;
const isNetworkError = code === MediaError.MEDIA_ERR_NETWORK
|| code === MediaError.MEDIA_ERR_DECODE;
if (isNetworkError && this.state !== 'stopped') {
this.log('network error, scheduling reconnect');
this.scheduleReconnect();
} else {
this.log('error event', this.audio.error);
this.setState('error');
this.listeners.error?.(this.audio.error?.message || 'unknown error');
}
});
this.audio.addEventListener('waiting', () => {
this.log('waiting event');
this.setState('buffering');
});
this.audio.addEventListener('canplay', () => {
this.log('canplay event');
this.reportReadyState('can_play');
});
this.audio.addEventListener('durationchange', () => {
const dur = this.audio.duration;
if (isFinite(dur)) {
this.log('durationchange', dur);
this.reportDuration(dur);
}
});
this.audio.addEventListener('loadedmetadata', () => {
this.log('loadedmetadata', this.audio.duration);
this.reportReadyState('have_metadata');
});
this.audio.addEventListener('loadeddata', () => {
this.log('loadeddata');
this.reportReadyState('have_current_data');
});
}
private handleCommand(msg: Record<string, unknown>) {
// Validate command structure before processing (P6)
if (!isValidCommand(msg)) {
console.warn('[PMOPlayer] Invalid command received:', msg);
return;
}
const type = msg.type;
switch (type) {
case 'stream': {
this.playStream(msg.url);
break;
}
case 'play': {
const streamUrl = `/api/webrenderer/${this.instanceId}/stream`;
this.playStream(streamUrl);
break;
}
case 'pause':
this.pause();
break;
case 'seek':
this.seek(msg.timestamp);
break;
case 'flush':
this.flush();
break;
case 'stop':
this.stop();
break;
}
}
// ─── Commands from backend ─────────────────────────────────────────────
stream(url: string) {
this.log('stream:', url);
this.audio.setAttribute('data-stream-url', url);
this.reconnectAttempts = 0;
this.audio.src = url;
this.audio.load();
}
playStream(url: string) {
this.stream(url);
this.play();
}
play() {
this.log('play()');
const ac = this.ensureAudioContext();
if (ac.state === 'suspended') {
ac.resume().catch(err => this.log('AudioContext resume error', err));
}
this.audio.play().catch(err => {
if ((err as DOMException).name === 'NotAllowedError') {
this.log('autoplay blocked, will retry on user interaction');
this.pendingPlay = true;
this.setupAutoplayUnlock();
} else {
this.log('play error', err);
}
});
}
private setupAutoplayUnlock() {
if (this.unlockListener) return;
this.unlockListener = () => {
if (this.pendingPlay) {
this.pendingPlay = false;
this.log('retrying play after user interaction');
this.audio.play().catch(err => this.log('play retry error', err));
}
document.removeEventListener('click', this.unlockListener!);
this.unlockListener = null;
};
document.addEventListener('click', this.unlockListener);
}
pause() {
this.log('pause()');
this.audio.pause();
}
seek(timestamp: number) {
this.log('seek:', timestamp);
this.audio.currentTime = timestamp;
}
flush() {
this.log('flush()');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.ac?.suspend();
this.listeners.flush?.();
}
stop() {
this.log('stop()');
this.flush();
this.setState('stopped');
}
// ─── Reports to backend ──────────────────────────────────────────
private setState(state: PlayerState) {
if (this.state !== state) {
this.state = state;
this.log('state:', state);
this.listeners.statechange?.(state);
this.httpReport('report', {
state: state,
});
}
}
private reportPosition() {
const pos = this.audio.currentTime;
const dur = isFinite(this.audio.duration) ? this.audio.duration : null;
this.listeners.positionchange?.(pos);
this.httpReport('report', {
position_sec: pos,
duration_sec: dur,
state: this.state,
});
}
private reportDuration(dur: number) {
this.listeners.durationchange?.(dur);
}
private reportReadyState(ready: ReadyState) {
this.log('ready_state:', ready);
this.listeners.readychange?.(ready);
this.httpReport('report', {
ready_state: ready,
});
}
private async httpReport(endpoint: string, data: Record<string, unknown>) {
try {
await fetch(`/api/webrenderer/${this.instanceId}/${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
} catch (err) {
this.log('http report error', err);
}
}
private startPositionReporting() {
this.stopPositionReporting();
this.positionInterval = window.setInterval(() => {
if (this.state === 'playing') {
this.reportPosition();
}
}, 1000);
}
private stopPositionReporting() {
if (this.positionInterval !== null) {
clearInterval(this.positionInterval);
this.positionInterval = null;
}
}
// ─── Public API ───────────────────────────────────────────────────────────
on<K extends keyof PlayerEvents>(event: K, fn: PlayerEvents[K]) {
this.listeners[event] = fn;
}
off<K extends keyof PlayerEvents>(event: K) {
delete this.listeners[event];
}
getState(): PlayerState {
return this.state;
}
getPosition(): number {
return this.audio.currentTime;
}
getDuration(): number | null {
const dur = this.audio.duration;
return isFinite(dur) ? dur : null;
}
// Track info from backend - not implemented yet
getTrack(): null {
return null;
}
setDebug(enabled: boolean) {
this.debug = enabled;
}
destroy() {
this.log('destroy()');
this.stopPositionReporting();
if (this.commandInterval !== null) {
clearInterval(this.commandInterval);
this.commandInterval = null;
}
if (this.unlockListener) {
document.removeEventListener('click', this.unlockListener);
this.unlockListener = null;
}
if (this.reconnectTimeout !== null) {
clearTimeout(this.reconnectTimeout);
this.reconnectTimeout = null;
}
this.ac?.close();
this.ac = null;
// Rapport final garanti via sendBeacon (fonctionne pendant beforeunload)
navigator.sendBeacon(
`/api/webrenderer/${this.instanceId}/report`,
JSON.stringify({ state: 'stopped' })
);
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.audio.remove();
}
}

View File

@@ -200,34 +200,11 @@ export function getJpegUrl(pk: string, size?: number): string {
return `/covers/jpeg/${pk}`;
}
/**
* SVG par défaut pour les images qui ne se chargent pas
*/
const DEFAULT_COVER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="400" height="400" fill="url(#bgGrad)"/>
<g transform="translate(200, 200)">
<rect x="-60" y="-80" width="120" height="100" rx="8" fill="white" opacity="0.9"/>
<circle cx="0" cy="-30" r="18" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-40" y="10" width="80" height="8" rx="4" fill="rgba(102, 126, 234, 0.3)"/>
<rect x="-30" y="30" width="60" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
<rect x="-35" y="50" width="70" height="6" rx="3" fill="rgba(102, 126, 234, 0.2)"/>
</g>
<text x="200" y="360" text-anchor="middle"
font-family="system-ui, -apple-system, sans-serif"
font-size="20" fill="white" opacity="0.6">
No Image Available
</text>
</svg>`;
import defaultCoverSvg from '../assets/default-cover.svg?raw';
/**
* Retourne l'URL de l'image par défaut comme data URL
*/
export function getDefaultImageUrl(): string {
return `data:image/svg+xml;utf8,${encodeURIComponent(DEFAULT_COVER_SVG)}`;
return `data:image/svg+xml;utf8,${encodeURIComponent(defaultCoverSvg)}`;
}

View File

@@ -17,12 +17,45 @@ import type {
ErrorResponse,
} from "./types";
/**
* Fetch avec timeout et AbortController
*/
function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeoutMs = 10_000,
): Promise<Response> {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
return fetch(url, { ...options, signal: controller.signal }).finally(
() => clearTimeout(id),
);
}
/**
* Client API REST pour le Control Point PMOMusic
*/
class PMOControlAPI {
private readonly baseURL = "/api/control";
/**
* Valide la structure de base d'une réponse
* Jette une erreur si la réponse est invalide
*/
private validateResponse<T>(data: unknown, path: string): T {
// Vérification basique : null ou undefined
if (data == null) {
throw new Error(`[PMOControlAPI] Réponse nulle pour ${path}`);
}
// Vérification que c'est un objet
if (typeof data !== 'object') {
throw new Error(`[PMOControlAPI] Réponse invalide pour ${path}: attendu un objet`);
}
return data as T;
}
/**
* Effectue une requête HTTP générique
*/
@@ -32,7 +65,7 @@ class PMOControlAPI {
): Promise<T> {
const url = `${this.baseURL}${path}`;
const response = await fetch(url, {
const response = await fetchWithTimeout(url, {
...options,
headers: {
"Content-Type": "application/json",
@@ -47,7 +80,15 @@ class PMOControlAPI {
throw new Error(error.error);
}
return response.json();
const data = await response.json();
// Validation de la réponse (P5)
const validated = this.validateResponse<T>(data, path);
if (import.meta.env.DEV && data == null) {
console.warn(`[PMOControlAPI] Réponse vide pour ${path}`);
}
return validated;
}
// ============================================================================

View File

@@ -29,6 +29,7 @@ export interface RendererSummary {
id: string;
friendly_name: string;
model_name: string;
manufacturer?: string;
protocol: RendererProtocolSummary;
capabilities: RendererCapabilitiesSummary;
online: boolean;
@@ -38,13 +39,7 @@ export interface RendererSummary {
export interface RendererState {
id: string;
friendly_name: string;
transport_state:
| "PLAYING"
| "PAUSED"
| "STOPPED"
| "TRANSITIONING"
| "NO_MEDIA"
| "UNKNOWN";
transport_state: TransportState;
position_ms: number | null;
duration_ms: number | null;
volume: number | null; // 0-100
@@ -54,6 +49,15 @@ export interface RendererState {
current_track: CurrentTrackMetadata | null;
}
export type TransportState = "PLAYING" | "PAUSED" | "STOPPED" | "TRANSITIONING" | "NO_MEDIA" | "UNKNOWN";
/**
* Guard de type pour valider que string est un TransportState valide
*/
export function isTransportState(s: string): s is TransportState {
return ["PLAYING", "PAUSED", "STOPPED", "TRANSITIONING", "NO_MEDIA", "UNKNOWN"].includes(s);
}
export interface CurrentTrackMetadata {
title: string | null;
artist: string | null;
@@ -122,6 +126,7 @@ export interface BrowseResponse {
entries: ContainerEntry[];
total_count: number;
offset: number;
hasMore?: boolean; // Client-side flag pour infinite scroll
}
// ============================================================================

View File

@@ -9,6 +9,8 @@ export interface Notification {
duration?: number // ms, undefined = permanent
}
const MAX_NOTIFICATIONS = 5
export const useUIStore = defineStore('ui', () => {
// État
const selectedRendererId = ref<string | null>(null)
@@ -17,6 +19,9 @@ export const useUIStore = defineStore('ui', () => {
const sseConnected = ref(false)
const notifications = ref<Notification[]>([])
// Map pour suivre les timers et permettre le cleanup
const notificationTimers = new Map<string, ReturnType<typeof setTimeout>>()
// Actions
function selectRenderer(id: string | null) {
selectedRendererId.value = id
@@ -39,6 +44,18 @@ export const useUIStore = defineStore('ui', () => {
message: string,
duration?: number
) {
// Limiter le nombre de notifications (P15)
if (notifications.value.length >= MAX_NOTIFICATIONS) {
const oldest = notifications.value.shift()
if (oldest) {
const timer = notificationTimers.get(oldest.id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(oldest.id)
}
}
}
const id = `notif-${Date.now()}-${Math.random()}`
const notification: Notification = {
id,
@@ -49,12 +66,13 @@ export const useUIStore = defineStore('ui', () => {
notifications.value.push(notification)
// Auto-remove après duration (défaut: 5s)
// Auto-remove après duration (défaut: 5s) - avec tracking pour cleanup
const timeout = duration !== undefined ? duration : 5000
if (timeout > 0) {
setTimeout(() => {
const timer = setTimeout(() => {
removeNotification(id)
}, timeout)
notificationTimers.set(id, timer)
}
return id
@@ -65,12 +83,27 @@ export const useUIStore = defineStore('ui', () => {
if (index !== -1) {
notifications.value.splice(index, 1)
}
// Nettoyer le timer associated
const timer = notificationTimers.get(id)
if (timer) {
clearTimeout(timer)
notificationTimers.delete(id)
}
}
function clearNotifications() {
// Nettoyer tous les timers
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
notifications.value = []
}
// Cleanup function pour appeler lors du unmount de l'app
function $dispose() {
notificationTimers.forEach(timer => clearTimeout(timer))
notificationTimers.clear()
}
// Raccourcis pour les types de notifications
function notifySuccess(message: string, duration?: number) {
return addNotification('success', message, duration)
@@ -107,5 +140,6 @@ export const useUIStore = defineStore('ui', () => {
notifyError,
notifyWarning,
notifyInfo,
$dispose,
}
})

View File

@@ -0,0 +1,24 @@
declare module 'vue-virtual-scroller' {
import { DefineComponent } from 'vue';
export interface RecycleScrollerProps {
items?: any[];
itemSize?: number;
keyField?: string;
direction?: 'vertical' | 'horizontal';
minItemSize?: number;
sizeField?: string;
typeField?: string;
buffer?: number;
pageMode?: boolean;
prerender?: number;
}
export interface RecycleScrollerRef {
scrollToItem(index: number): void;
scrollToOffset(offset: number): void;
$el: HTMLElement;
}
export const RecycleScroller: DefineComponent<RecycleScrollerProps>;
}

View File

@@ -45,5 +45,9 @@ export function normalizeUrl(url: string): string {
*/
export function truncate(str: string, maxLength: number, suffix = '...'): string {
if (str.length <= maxLength) return str;
// Guard: si suffix est plus long que maxLength, retourner juste le suffixe
if (suffix.length >= maxLength) {
return str.slice(0, maxLength);
}
return str.slice(0, maxLength - suffix.length) + suffix;
}

View File

@@ -49,12 +49,3 @@ export function formatMsToTime(ms: number | null): string {
return `${h}${m}${s}`;
}
/**
* Convertit des millisecondes en format court (pour l'affichage progress)
* @param ms - Durée en millisecondes
* @returns Durée au format "X:XX" ou "X:XX:XX"
*/
export function formatMsToShortTime(ms: number | null): string {
return formatMsToTime(ms);
}

View File

@@ -34,17 +34,19 @@ const rendererDrawerOpen = ref(false);
// Ref pour le swipe edge detection
const viewRef = ref<HTMLElement | null>(null);
// Position initiale du swipe pour détecter un swipe depuis le bord gauche
const swipeStartX = ref(0);
// Swipe depuis le bord gauche pour ouvrir le drawer
useSwipe(viewRef, {
threshold: 50,
onSwipeStart(e: TouchEvent) {
swipeStartX.value = e.touches[0]?.clientX ?? 0;
},
onSwipeEnd(_e: TouchEvent, swipeDirection: string) {
// Swipe right depuis le bord gauche → ouvrir drawer
if (swipeDirection === "right" && !drawerOpen.value) {
const touch = _e.changedTouches[0];
// Vérifier que le swipe commence depuis le bord gauche (< 50px)
if (touch && touch.clientX < 50) {
drawerOpen.value = true;
}
if (swipeDirection === "right" && !drawerOpen.value && swipeStartX.value < 50) {
drawerOpen.value = true;
}
},
});
@@ -77,9 +79,9 @@ const onlineServersCount = computed(
() => allServers.value.filter((s) => s.online).length,
);
// Nombre de renderers online pour afficher dans le badge
// Nombre de renderers online (filtrés) pour afficher dans le badge
const onlineRenderersCount = computed(
() => allRenderers.value.filter((r) => r.online).length,
() => filterRenderers(allRenderers.value).filter((r) => r.online).length,
);
// Gestion de l'ouverture du drawer depuis le bouton
@@ -103,14 +105,19 @@ function handleRendererSelect(rendererId: string) {
}
}
// Filtre la liste des renderers pour exclure les WebRenderers d'autres navigateurs.
// Seul le WebRenderer créé par ce navigateur (identifié par son UDN) reste visible.
// Filter to show only our own WebRenderer based on UDN match
function filterRenderers(renderers: typeof allRenderers.value) {
const myUdn = webRenderer.rendererUdn.value;
const normalizedUdn = myUdn?.replace(/^uuid:/, '') ?? null;
return renderers.filter((r) => {
if (r.model_name !== "WebRenderer") return true; // renderer classique : toujours visible
if (myUdn === null) return false; // pas encore de session : masquer tous les WebRenderers
return r.id === myUdn; // ne garder que le nôtre
// WebRenderers have "Web Audio " as friendly_name prefix
const isWebRenderer = r.friendly_name?.startsWith("Web Audio ") ?? false;
if (isWebRenderer) {
if (normalizedUdn === null) return false;
const normalizedId = r.id.replace(/^uuid:/, '');
return normalizedId === normalizedUdn;
}
return true;
});
}
@@ -130,12 +137,18 @@ onMounted(async () => {
});
// Watch renderers pour sync automatique des tabs
// Note: on watch la taille du tableau + les IDs pour éviter un deep watch coûteux
watch(
() => allRenderers.value,
(newRenderers) => {
syncWithRenderers(filterRenderers(newRenderers));
() => ({
length: allRenderers.value.length,
ids: allRenderers.value.map(r => r.id).join(','),
}),
(newVal, oldVal) => {
// Re-sync uniquement si le nombre ou les IDs ont changé
if (newVal.length !== oldVal?.length || newVal.ids !== oldVal?.ids) {
syncWithRenderers(filterRenderers(allRenderers.value));
}
},
{ deep: true },
);
// Watch l'UDN du WebRenderer local : quand il s'établit, resync pour faire apparaître notre onglet
@@ -257,6 +270,7 @@ const currentTabProps = computed(() => {
height: 100vh;
overflow: hidden;
background: var(--color-bg);
min-width: 0;
}
.content-area {
@@ -265,6 +279,7 @@ const currentTabProps = computed(() => {
overflow-x: hidden;
padding: 0;
position: relative;
min-width: 0;
}
/* Placeholder temporaire */

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudio-ext"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Core audio types
@@ -32,10 +32,11 @@ serde = { workspace = true, optional = true }
serde_json = { workspace = true, optional = true }
reqwest = { workspace = true, features = ["stream"], optional = true }
futures = { version = "0.3", optional = true }
ureq = { version = "2", optional = true }
[features]
default = []
cache-sink = ["dep:pmoaudiocache", "dep:pmoflac", "dep:pmometadata", "dep:serde_json"]
playlist = ["cache-sink", "dep:pmoplaylist", "dep:pmocache"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures"]
http-stream = ["dep:pmoflac", "dep:pmometadata", "dep:bytes", "dep:serde", "dep:reqwest", "dep:futures", "dep:ureq"]
all = ["cache-sink", "playlist", "http-stream"]

View File

@@ -233,7 +233,7 @@ impl NodeLogic for FlacCacheSinkLogic {
}
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// TrackBoundary pendant le prebuffer - track courte (< 512KB)
tracing::warn!(
"FlacCacheSink: TrackBoundary received before prebuffer complete - track shorter than 512KB, closing pump and waiting for ingestion"
@@ -589,7 +589,7 @@ impl NodeLogic for FlacCacheSinkLogic {
// Si pump_closed, ignorer silencieusement le chunk
}
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// Nouveau morceau - fermer le pump si pas déjà fermé
tracing::debug!("FlacCacheSink: TrackBoundary received, closing pump and storing metadata for next track");
// Stocker les métadonnées pour la prochaine track

View File

@@ -307,7 +307,7 @@ impl NodeLogic for StreamingFlacSinkLogic {
_AudioSegment::Sync(marker) => {
match marker.as_ref() {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, .. } => {
// Prepare encoder options (metadata + duration) for the upcoming track.
if let Err(e) =
self.ctx.prepare_encoder_options_for_track(metadata).await
@@ -533,6 +533,9 @@ impl StreamingFlacSink {
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
last_track_metadata: Arc::new(RwLock::new(None)),
},
};

View File

@@ -88,8 +88,17 @@ impl IcyClientStream {
// Add cover URL if we have a cover_pk
if let Some(pk) = &meta.cover_pk {
#[cfg(feature = "playlist")]
let cover_url = pmocache::covers_absolute_url_for_upnp(pk, None);
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
#[cfg(feature = "playlist")]
{
metadata_str.push_str(&format!("StreamUrl='{}';", cover_url));
}
#[cfg(not(feature = "playlist"))]
{
// When playlist feature is not enabled, use relative URL
metadata_str.push_str(&format!("StreamUrl='/covers/image/{}/256';", pk));
}
} else if let Some(url) = &meta.cover_url {
// Fallback to external cover URL if no local pk
metadata_str.push_str(&format!("StreamUrl='{}';", url));

View File

@@ -61,7 +61,7 @@ use async_trait::async_trait;
use bytes::Bytes;
use pmoaudio::{
pipeline::{AudioPipelineNode, Node, NodeLogic, PipelineHandle, StopReason},
AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
AudioChunk, AudioError, AudioSegment, SyncMarker, TypeRequirement, TypedAudioNode, _AudioSegment,
};
use pmoflac::{EncoderOptions, FlacEncodedStream};
use tokio::io::{AsyncRead, AsyncReadExt, ReadBuf};
@@ -109,6 +109,23 @@ impl OggFlacStreamHandle {
pub fn set_auto_stop(&self, enabled: bool) {
self.inner.auto_stop.store(enabled, Ordering::SeqCst);
}
/// Pause playback - sends silence (zeros) to maintain client connections
pub fn pause(&self) {
self.inner.is_paused.store(true, Ordering::SeqCst);
debug!("StreamingOggFlacSink paused");
}
/// Resume playback - continues sending actual audio
pub fn resume(&self) {
self.inner.is_paused.store(false, Ordering::SeqCst);
debug!("StreamingOggFlacSink resumed");
}
/// Check if currently paused
pub fn is_paused(&self) -> bool {
self.inner.is_paused.load(Ordering::SeqCst)
}
}
/// OGG-FLAC client stream (implements AsyncRead).
@@ -163,16 +180,98 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
debug!("StreamingOggFlacSink started");
// TODO: Implement OGG-FLAC encoding logic
// For now, just process segments without encoding
let mut was_paused = false;
let mut silence_timestamp: f64 = 0.0;
loop {
// Check pause state BEFORE select to avoid receiving chunks when Finite + paused
{
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
let stream_type = *self.ctx.stream_type.read().await;
// For Finite + paused: loop WITHOUT receiving chunks
if is_paused && stream_type == pmoaudio::StreamType::Finite {
if self.ctx.sample_rate.is_none() {
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
continue;
}
let sample_rate = self.ctx.sample_rate.unwrap();
let frames_per_50ms = (sample_rate as f64 * 0.05) as usize;
// Create proper silence chunk using AudioChunk::silence
let silence_chunk = AudioChunk::silence(frames_per_50ms, sample_rate);
let pcm_bytes = chunk_to_pcm_bytes(&silence_chunk, self.ctx.bits_per_sample)?;
trace!("Sending silence during pause (finite) - no recv");
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: silence_timestamp,
duration_sec: 0.05,
};
if let Some(tx) = &self.ctx.pcm_tx {
let _ = tx.send(pcm_chunk).await;
}
silence_timestamp += 0.05;
continue; // Loop again WITHOUT going to select
}
}
tokio::select! {
_ = stop_token.cancelled() => {
debug!("StreamingOggFlacSink stopped by cancellation");
break;
}
// Check for pause state changes periodically
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {
let is_paused = self.ctx.is_paused.load(Ordering::SeqCst);
if is_paused != was_paused {
if is_paused {
// Entering pause - restart encoder to close OGG segment
debug!("StreamingOggFlacSink: entering pause - restarting encoder");
let metadata = self.ctx.last_track_metadata.read().await.clone();
if let Some(ref meta) = metadata {
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
error!("Failed to prepare encoder options for pause: {}", e);
}
}
if self.ctx.encoder_state.is_some() {
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
},
).await {
warn!("Failed to restart encoder on pause: {}", e);
}
}
} else {
// Exiting pause - restart encoder to open new OGG segment
debug!("StreamingOggFlacSink: exiting pause - restarting encoder");
let metadata = self.ctx.last_track_metadata.read().await.clone();
if let Some(ref meta) = metadata {
if let Err(e) = self.ctx.prepare_encoder_options_for_track(meta).await {
error!("Failed to prepare encoder options for resume: {}", e);
}
}
if self.ctx.encoder_state.is_some() {
if let Err(e) = self.ctx.restart_encoder_for_new_track(
|flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, _sample_rate, timestamp_offset_sec| {
broadcast_ogg_flac_stream(flac_stream, broadcast, header, current_timestamp, current_duration, max_lead, timestamp_offset_sec)
},
).await {
warn!("Failed to restart encoder on resume: {}", e);
}
}
}
was_paused = is_paused;
}
continue; // Skip processing - this is just a check
}
segment = input.recv() => {
match segment {
Some(seg) => {
@@ -241,7 +340,33 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
duration_sec
);
// Send to FLAC encoder with timestamp and duration
// Check pause state
let was_paused = self.ctx.is_paused.load(Ordering::SeqCst);
// Get stream type
let stream_type = *self.ctx.stream_type.read().await;
// Handle pause:
// - Always send silence to hear something
// - Continuous: receive chunks but drop them
// - Finite: don't receive chunks (backpressure)
if was_paused {
trace!("StreamingOggFlacSink: sending silence during pause ({})",
if stream_type == pmoaudio::StreamType::Continuous { "continuous" } else { "finite" });
let silence_bytes = vec![0u8; pcm_bytes.len()];
let silence_chunk = PcmChunk {
bytes: silence_bytes,
timestamp_sec: seg.timestamp_sec,
duration_sec,
};
if let Some(tx) = &self.ctx.pcm_tx {
let _ = tx.send(silence_chunk).await;
}
// Always continue (skip sending real audio)
continue;
}
// Normal case: send PCM to encoder
let pcm_chunk = PcmChunk {
bytes: pcm_bytes,
timestamp_sec: seg.timestamp_sec,
@@ -265,7 +390,13 @@ impl NodeLogic for StreamingOggFlacSinkLogic {
_AudioSegment::Sync(marker) => {
match marker.as_ref() {
SyncMarker::TrackBoundary { metadata } => {
SyncMarker::TrackBoundary { metadata, stream_type } => {
// Store the last TrackBoundary metadata for pause/resume
*self.ctx.last_track_metadata.write().await = Some(metadata.clone());
// Update stream type
*self.ctx.stream_type.write().await = *stream_type;
// Inject per-track metadata and duration into the next FLAC header.
if let Err(e) =
self.ctx.prepare_encoder_options_for_track(metadata).await
@@ -442,30 +573,33 @@ impl StreamingOggFlacSink {
let logic = StreamingOggFlacSinkLogic {
ctx: SharedSinkContext {
encoder_options,
bits_per_sample,
enable_total_samples: options.enable_total_samples,
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
default_title: options.default_title.clone(),
default_artist: options.default_artist.clone(),
use_only_default_metadata: options.use_only_default_metadata,
pcm_tx: Some(pcm_tx),
pcm_rx: Some(pcm_rx),
metadata,
broadcast,
header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
first_chunk_timestamp_checked: false,
timestamp_offset_sec: 0.0,
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
},
encoder_options,
bits_per_sample,
enable_total_samples: options.enable_total_samples,
restart_encoder_on_track_boundary: options.restart_encoder_on_track_boundary,
default_title: options.default_title.clone(),
default_artist: options.default_artist.clone(),
use_only_default_metadata: options.use_only_default_metadata,
pcm_tx: Some(pcm_tx),
pcm_rx: Some(pcm_rx),
metadata,
broadcast,
header,
encoder_state: None,
sample_rate: None,
broadcast_max_lead_time: broadcast_max_lead_time.max(0.0),
first_chunk_timestamp_checked: false,
timestamp_offset_sec: 0.0,
current_timestamp: Arc::new(RwLock::new(0.0)),
pending_track_duration: None,
pending_total_samples: None,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
last_track_metadata: Arc::new(RwLock::new(None)),
},
};
let sink = Self {
let sink = StreamingOggFlacSink {
inner: Node::new_with_input(logic, 16),
};

View File

@@ -118,6 +118,10 @@ pub struct SharedStreamHandleInner {
pub stop_token: CancellationToken,
pub header: Arc<RwLock<Option<Bytes>>>,
pub auto_stop: Arc<AtomicBool>,
/// Pause state - true if paused, false if playing
pub is_paused: Arc<AtomicBool>,
/// Stream type (Continuous for radio, Finite for tracks)
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
}
impl SharedStreamHandleInner {
@@ -135,6 +139,8 @@ impl SharedStreamHandleInner {
stop_token,
header,
auto_stop,
is_paused: Arc::new(AtomicBool::new(false)),
stream_type: Arc::new(RwLock::new(pmoaudio::StreamType::Finite)),
}
}
@@ -294,6 +300,12 @@ pub struct SharedSinkContext {
pub current_timestamp: Arc<RwLock<f64>>,
pub pending_track_duration: Option<Duration>,
pub pending_total_samples: Option<u64>,
/// Pause state - true if paused, false if playing
pub is_paused: Arc<AtomicBool>,
/// Stream type (Continuous for radio, Finite for tracks)
pub stream_type: Arc<RwLock<pmoaudio::StreamType>>,
/// Last TrackBoundary metadata received (for pause/resume)
pub last_track_metadata: Arc<RwLock<Option<Arc<RwLock<dyn TrackMetadata>>>>>,
}
impl SharedSinkContext {

View File

@@ -35,6 +35,7 @@ use pmoaudio::{
AudioSegment,
nodes::AudioError,
pipeline::{AudioPipelineNode, Node, NodeLogic, send_to_children},
StreamType,
};
use tokio::sync::{broadcast, mpsc};
use tokio_util::sync::CancellationToken;
@@ -164,6 +165,7 @@ impl NodeLogic for PlayerSourceLogic {
let mut current_uri: Option<String> = None;
let mut next_uri: Option<String> = None;
let mut paused_at_sec: f64 = 0.0;
let mut is_continuous: bool = false;
info!("PlayerSource: started");
@@ -181,7 +183,7 @@ impl NodeLogic for PlayerSourceLogic {
None => break,
Some(cmd) => {
self.handle_command(
cmd, &mut state, &mut current_uri,
cmd, is_continuous, &mut state, &mut current_uri,
&mut next_uri, &mut paused_at_sec,
&output, &stop_token,
).await?;
@@ -212,15 +214,17 @@ impl NodeLogic for PlayerSourceLogic {
};
let duration_sec = source.duration_sec();
let is_continuous = source.is_continuous();
let _ = self.event_tx.send(PlayerEvent::Playing {
uri: uri.clone(),
duration_sec,
});
info!("PlayerSource: playing {:?} from {:.1}s", uri, paused_at_sec);
info!("PlayerSource: playing {:?} from {:.1}s continuous={}", uri, paused_at_sec, is_continuous);
// Pompe audio — s'arrête sur EOF, Pause, Stop, ou commande
let result = self.pump(
source,
is_continuous,
&mut state,
&mut current_uri,
&mut next_uri,
@@ -255,6 +259,7 @@ impl PlayerSourceLogic {
async fn handle_command(
&mut self,
cmd: PlayerCommand,
is_continuous: bool,
state: &mut TransportState,
current_uri: &mut Option<String>,
next_uri: &mut Option<String>,
@@ -281,14 +286,16 @@ impl PlayerSourceLogic {
TransportState::Paused => {
info!("PlayerSource: Play (resume from {:.1}s)", paused_at_sec);
// Injecter un TrackBoundary pour EOS + nouveau BOS OGG propre
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, *paused_at_sec, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
TransportState::Loaded => {
info!("PlayerSource: Play (start)");
*paused_at_sec = 0.0;
// TrackBoundary initial pour le premier BOS OGG
send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
TransportState::Idle => {
@@ -320,7 +327,8 @@ impl PlayerSourceLogic {
if current_uri.is_some() {
info!("PlayerSource: Seek to {:.1}s", pos);
*paused_at_sec = pos;
send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await?;
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await?;
*state = TransportState::Playing;
}
}
@@ -335,6 +343,7 @@ impl PlayerSourceLogic {
async fn pump(
&mut self,
source: UriSource,
is_continuous: bool,
state: &mut TransportState,
current_uri: &mut Option<String>,
next_uri: &mut Option<String>,
@@ -394,7 +403,8 @@ impl PlayerSourceLogic {
*next_uri = None;
*paused_at_sec = 0.0;
// TrackBoundary pour clore le bitstream OGG proprement
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
result = Err(e);
}
*state = TransportState::Playing;
@@ -408,7 +418,8 @@ impl PlayerSourceLogic {
info!("PlayerSource: Seek to {:.1}s", pos);
source_stop.cancel();
*paused_at_sec = pos;
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, pos, stream_type, stop_token).await {
result = Err(e);
break;
}
@@ -434,7 +445,8 @@ impl PlayerSourceLogic {
info!("PlayerSource: gapless transition to {:?}", next);
*current_uri = Some(next);
*paused_at_sec = 0.0;
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stop_token).await {
let stream_type = if is_continuous { StreamType::Continuous } else { StreamType::Finite };
if let Err(e) = send_track_boundary(current_uri.as_deref(), output, 0.0, stream_type, stop_token).await {
result = Err(e);
}
*state = TransportState::Playing;
@@ -494,6 +506,7 @@ async fn send_track_boundary(
uri: Option<&str>,
output: &[mpsc::Sender<Arc<AudioSegment>>],
timestamp_sec: f64,
stream_type: StreamType,
stop_token: &CancellationToken,
) -> Result<(), AudioError> {
if output.is_empty() || stop_token.is_cancelled() {
@@ -505,7 +518,7 @@ async fn send_track_boundary(
let _ = meta.set_title(Some(u.to_string())).await;
}
let meta_arc = Arc::new(tokio::sync::RwLock::new(meta));
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc);
let boundary = AudioSegment::new_track_boundary(0, timestamp_sec, meta_arc, stream_type);
send_to_children("PlayerSource", output, boundary).await
}

View File

@@ -113,7 +113,7 @@ use pmoaudio::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioSegment,
AudioSegment, StreamType,
};
use pmoaudiocache::Cache as AudioCache;
use pmoflac::decode_audio_stream;
@@ -279,7 +279,7 @@ impl NodeLogic for PlaylistSourceLogic {
let track_start = std::time::Instant::now();
tracing::debug!("PlaylistSourceLogic: emitting TrackBoundary");
let metadata_for_boundary = metadata.clone();
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary);
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata_for_boundary, StreamType::Finite);
send_to_children(node_name, &output, boundary).await?;
// Obtenir le chemin du fichier

View File

@@ -49,6 +49,8 @@ pub struct UriSource {
reader: Box<dyn tokio::io::AsyncRead + Send + Unpin>,
stream_info: StreamInfo,
frames_to_skip: u64,
/// true si c'est un flux continu (radio, stream) sans durée définie
pub is_continuous: bool,
}
impl UriSource {
@@ -88,6 +90,11 @@ impl UriSource {
})
}
/// Retourne true si c'est un flux continu (radio, stream) sans durée définie.
pub fn is_continuous(&self) -> bool {
self.is_continuous
}
/// Émet les chunks audio vers `tx`.
///
/// Retourne `Ok(true)` si EOF naturel, `Ok(false)` si annulé ou receiver fermé.
@@ -201,7 +208,7 @@ impl UriSource {
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip, is_continuous: false })
}
async fn open_http(
@@ -209,6 +216,9 @@ impl UriSource {
seek_sec: f64,
stop_token: &CancellationToken,
) -> Result<Self, AudioError> {
// Détecter si c'est un flux continu (radio, stream) basé sur l'URL
let is_continuous = detect_continuous_stream(url);
let response = tokio::select! {
_ = stop_token.cancelled() => {
return Err(AudioError::IoError("Cancelled before HTTP connect".into()));
@@ -244,11 +254,89 @@ impl UriSource {
let frames_to_skip = (seek_sec * stream_info.sample_rate as f64) as u64;
info!(
"UriSource: opened HTTP {} Hz {} ch {} bps",
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample,
"UriSource: opened HTTP {} Hz {} ch {} bps continuous={}",
stream_info.sample_rate, stream_info.channels, stream_info.bits_per_sample, is_continuous,
);
let (_, reader) = stream.into_reader();
Ok(Self { reader: Box::new(reader), stream_info, frames_to_skip })
Ok(Self {
reader: Box::new(reader),
stream_info,
frames_to_skip,
is_continuous,
})
}
}
/// Détecte si une URL correspond à un flux continu (radio, stream) sans durée définie.
///
/// Cette fonction:
/// 1. Vérifie les patterns d'URL connus (stream, live, radio, etc.)
/// 2. Fait une requête HTTP HEAD pour vérifier les headers (Content-Length, ICY, etc.)
fn detect_continuous_stream(url: &str) -> bool {
let url_lower = url.to_lowercase();
// 1. Quick check sur les patterns d'URL très explicites
// Ces patterns indiquent clairement un stream live
if url_lower.contains("/live")
|| url_lower.contains("/radiolar")
|| url_lower.contains(".pls")
|| url_lower.contains(".m3u")
|| url_lower.contains("icy")
{
return true;
}
// 2. Vérification HTTP headers (le plus fiable)
if url.starts_with("http://") || url.starts_with("https://") {
if let Ok(is_stream) = check_http_stream_headers(url) {
if is_stream {
return true;
}
}
}
false
}
/// Vérifie les headers HTTP pour déterminer si c'est un stream
fn check_http_stream_headers(url: &str) -> Result<bool, String> {
use std::time::Duration;
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(3))
.build();
let response = agent
.head(url)
.call()
.map_err(|e| format!("HTTP HEAD failed: {}", e))?;
// Headers ICY (Icecast/Shoutcast) = toujours un stream
if response.header("icy-name").is_some()
|| response.header("icy-metaint").is_some()
{
return Ok(true);
}
// Pas de Content-Length = stream potentiel
let has_content_length = response.header("content-length").is_some();
// Transfer-Encoding: chunked = stream potentiel
let is_chunked = response
.header("transfer-encoding")
.map(|v| v.to_lowercase().contains("chunked"))
.unwrap_or(false);
// Decision: pas de Content-Length + (chunked ou content-type streaming)
let content_type = response
.header("content-type")
.unwrap_or("")
.to_lowercase();
let is_streaming_mime = content_type.contains("audio/mpeg")
|| content_type.contains("audio/aac")
|| content_type.contains("application/ogg");
Ok(!has_content_length && (is_streaming_mime || is_chunked))
}

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudio"
version = "0.1.0"
edition = "2021"
edition = "2024"
[features]
default = []

View File

@@ -91,6 +91,25 @@ impl<T: Sample> AudioChunkData<T> {
})
}
/// Crée un chunk de silence
///
/// # Arguments
///
/// * `frames` - Nombre de frames (échantillons par canal)
/// * `sample_rate` - Taux d'échantillonnage en Hz
///
/// # Exemples
///
/// ```
/// use pmoaudio::AudioChunkData;
///
/// let silence = AudioChunkData::<i32>::silence(48000, 48000);
/// assert_eq!(silence.len(), 48000);
/// ```
pub fn silence(frames: usize, sample_rate: u32) -> Arc<Self> {
Self::new(vec![[T::ZERO; 2]; frames], sample_rate, 0.0)
}
/// Retourne le nombre d'échantillons par canal (frames)
#[inline]
pub fn len(&self) -> usize {
@@ -317,6 +336,11 @@ pub enum AudioChunk {
}
impl AudioChunk {
/// Crée un chunk de silence (I32 par défaut)
pub fn silence(frames: usize, sample_rate: u32) -> Self {
AudioChunk::I32(AudioChunkData::<i32>::silence(frames, sample_rate))
}
/// Retourne le nombre de frames du chunk
pub fn len(&self) -> usize {
match self {

View File

@@ -3,7 +3,7 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, SyncMarker};
use crate::{gain_db_from_linear, AudioChunk, AudioChunkData, BitDepth, StreamType, SyncMarker};
pub enum _AudioSegment {
Chunk(Arc<AudioChunk>),
@@ -160,9 +160,11 @@ impl AudioSegment {
order: u64,
timestamp_sec: f64,
metadata: Arc<RwLock<dyn TrackMetadata>>,
stream_type: StreamType,
) -> Arc<Self> {
let marker = Arc::new(SyncMarker::TrackBoundary {
metadata: Arc::clone(&metadata),
stream_type,
});
Arc::new(Self {
order,
@@ -303,7 +305,18 @@ impl AudioSegment {
pub fn as_track_metadata(&self) -> Option<&Arc<RwLock<dyn TrackMetadata>>> {
match &self.segment {
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { metadata } => Some(metadata),
SyncMarker::TrackBoundary { metadata, .. } => Some(metadata),
_ => None,
},
_ => None,
}
}
/// Récupère le stream_type si c'est un TrackBoundary
pub fn stream_type(&self) -> Option<StreamType> {
match &self.segment {
_AudioSegment::Sync(marker) => match &**marker {
SyncMarker::TrackBoundary { stream_type, .. } => Some(*stream_type),
_ => None,
},
_ => None,
@@ -409,7 +422,7 @@ impl<'a> TryInto<&'a Arc<AudioChunk>> for &'a AudioSegment {
fn try_into(self) -> Result<&'a Arc<AudioChunk>, Self::Error> {
match &self.segment {
_AudioSegment::Chunk(ref chunk) => Ok(chunk),
_AudioSegment::Chunk(chunk) => Ok(chunk),
_ => Err(()),
}
}
@@ -420,7 +433,7 @@ impl<'a> TryInto<&'a Arc<SyncMarker>> for &'a AudioSegment {
fn try_into(self) -> Result<&'a Arc<SyncMarker>, Self::Error> {
match &self.segment {
_AudioSegment::Sync(ref marker) => Ok(marker),
_AudioSegment::Sync(marker) => Ok(marker),
_ => Err(()),
}
}

View File

@@ -95,7 +95,7 @@ pub mod bit_depth;
pub mod dsp;
pub use audio_segment::{AudioSegment, _AudioSegment};
pub use sync_marker::SyncMarker;
pub use sync_marker::{StreamType, SyncMarker};
pub use audio_chunk::{
gain_db_from_linear, gain_linear_from_db, AudioChunk, AudioChunkData, AudioFloatChunk,

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, I24,
AudioChunk, AudioChunkData, AudioSegment, I24, StreamType,
};
use pmoflac::{decode_audio_stream, AudioFileMetadata, StreamInfo};
use pmometadata::{MemoryTrackMetadata, TrackMetadata};
@@ -98,6 +98,7 @@ impl NodeLogic for FileSourceLogic {
0,
0.0,
Arc::new(tokio::sync::RwLock::new(metadata)),
StreamType::Finite,
);
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
}

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHANNEL_SIZE},
pipeline::{Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker,
AudioChunk, AudioPipelineNode, AudioSegment, SyncMarker, StreamType,
};
use pmoflac::{encode_flac_stream, EncoderOptions, PcmFormat};
use std::{
@@ -822,6 +822,7 @@ mod tests {
0,
0.0,
std::sync::Arc::new(tokio::sync::RwLock::new(metadata)),
crate::StreamType::Finite,
);
tx.send(track_boundary).await.unwrap();

View File

@@ -2,7 +2,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode, DEFAULT_CHUNK_DURATION_MS},
pipeline::{send_to_children, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24,
AudioChunk, AudioChunkData, AudioPipelineNode, AudioSegment, I24, StreamType,
};
use futures_util::StreamExt;
use pmoflac::{decode_audio_stream, StreamInfo};
@@ -178,7 +178,7 @@ impl NodeLogic for HttpSourceLogic {
// Émettre TrackBoundary avec les métadonnées HTTP
let track_boundary =
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)));
AudioSegment::new_track_boundary(0, 0.0, Arc::new(tokio::sync::RwLock::new(metadata)), StreamType::Finite);
send_to_children(std::any::type_name::<Self>(), &output, track_boundary).await?;
// Préparer la lecture des chunks audio

View File

@@ -33,7 +33,7 @@ use crate::{
nodes::{AudioError, TypedAudioNode},
pipeline::{send_to_children, AudioPipelineNode, Node, NodeLogic},
type_constraints::TypeRequirement,
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24,
AudioChunk, AudioChunkData, AudioSegment, BitDepth, I24, StreamType,
};
use std::sync::Arc;
use tokio::sync::mpsc;
@@ -495,7 +495,7 @@ mod tests {
let metadata = Arc::new(tokio::sync::RwLock::new(
pmometadata::MemoryTrackMetadata::new(),
));
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata);
let boundary = AudioSegment::new_track_boundary(0, 0.0, metadata, StreamType::Finite);
// Envoyer le boundary
input_tx.send(boundary.clone()).await.unwrap();

View File

@@ -3,9 +3,16 @@ use tokio::sync::RwLock;
use pmometadata::TrackMetadata;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
Continuous,
Finite,
}
pub enum SyncMarker {
TrackBoundary {
metadata: Arc<RwLock<dyn TrackMetadata>>,
stream_type: StreamType,
},
StreamMetadata {
key: String,
@@ -15,5 +22,4 @@ pub enum SyncMarker {
Heartbeat,
EndOfStream,
Error(String),
// autres cas à venir…
}

View File

@@ -1,7 +1,7 @@
[package]
name = "pmoaudiocache"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Cache générique

View File

@@ -1,7 +1,7 @@
[package]
name = "pmocache"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
# Base de données

View File

@@ -155,6 +155,7 @@ impl DB {
let conn = Connection::open(path)?;
conn.execute("PRAGMA foreign_keys = ON", [])?;
// 1. CRÉATION DES TABLES EN PREMIER
conn.execute(
"CREATE TABLE IF NOT EXISTS asset (
pk TEXT PRIMARY KEY,
@@ -180,21 +181,30 @@ impl DB {
[],
)?;
// Créer un index sur la collection pour les requêtes rapides
// 2. Optimisations SQLite pour production
conn.execute_batch(
"
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -32768;
PRAGMA temp_store = MEMORY;
PRAGMA mmap_size = 268435456;
",
)?;
// 3. CRÉATION DES INDEX APRÈS LES TABLES
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_collection
ON ASSET (collection)",
[],
)?;
// Créer un index composite pour optimiser la politique LRU (get_oldest)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_lru
ON asset (last_used ASC, hits ASC)",
[],
)?;
// Crée un index composite pour rendre unique les ids si défini dans une collection
conn.execute(
"CREATE UNIQUE INDEX
IF NOT EXISTS asset_collection_id_unique
@@ -203,12 +213,32 @@ impl DB {
[],
)?;
// LAZY PK SUPPORT: Index sur lazy_pk pour lookups rapides (lazy_pk → real pk)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_metadata_key_value ON metadata (key, value)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_last_used ON asset (last_used DESC)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_hits ON asset (hits DESC)",
[],
)?;
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_asset_lazy_pk ON asset (lazy_pk)",
[],
)?;
conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_lazy_pk_unique
ON asset (lazy_pk) WHERE lazy_pk IS NOT NULL",
[],
)?;
// Index unique sur lazy_pk (non-NULL) pour éviter les doublons
// Un lazy_pk ne peut pointer que vers un seul entry
conn.execute(
@@ -220,7 +250,12 @@ impl DB {
// Inscrire la version du schéma
conn.execute_batch(&format!("PRAGMA user_version = {}", SCHEMA_VERSION))?;
Ok((Self { conn: Mutex::new(conn) }, was_reset))
Ok((
Self {
conn: Mutex::new(conn),
},
was_reset,
))
}
/// Ajoute ou met à jour une entrée dans la base de données
@@ -1150,7 +1185,9 @@ impl DB {
tracing::debug!(
"update_lazy_to_downloaded: {} → {} ({} metadata rows still under lazy_pk)",
lazy_pk, real_pk, meta_under_lazy
lazy_pk,
real_pk,
meta_under_lazy
);
Ok(())

View File

@@ -2,7 +2,7 @@
[package]
name = "pmoconfig"
version = "0.1.0"
edition = "2021"
edition = "2024"
[dependencies]
pmoutils = { version = "0.1.2", registry = "pmo" }

View File

@@ -20,9 +20,7 @@ ratatui = { version = "0.26", default-features = false, features = ["crossterm"]
crossterm = "0.27"
rust_cast = "0.19"
rustls = { version = "0.23", features = ["aws-lc-rs"] }
mdns = "3.0"
async-std = "1.12"
futures-util = "0.3"
mdns-sd = "0.19"
smol = "2.0"
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }

View File

@@ -13,7 +13,7 @@ use tracing::{debug, error, info, warn};
use crate::discovery::manager::UDNRegistry;
use crate::errors::ControlPointError;
use crate::events::{MediaServerEventBus, RendererEventBus};
use crate::media_server::{MediaBrowser, MusicServer, playback_item_from_entry};
use crate::media_server::{playback_item_from_entry, MediaBrowser, MusicServer};
use crate::media_server_events::spawn_media_server_event_runtime;
use crate::model::{MediaServerEvent, RendererEvent};
use crate::model::{PlaybackState, TrackMetadata};
@@ -26,7 +26,7 @@ use crate::openapi::{
CurrentTrackMetadata, FullRendererSnapshot, QueueItem, QueueSnapshotView, RendererBindingView,
RendererStateView,
};
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot};
use crate::queue::{EnqueueMode, PlaybackItem, QueueSnapshot, SyncScheduleOutcome};
use crate::registry::DeviceRegistry;
/// Control point minimal :
@@ -147,51 +147,52 @@ impl ControlPoint {
let udn_cache_for_mdns = Arc::clone(&udn_cache);
thread::spawn(move || {
use crate::discovery::ChromecastDiscoveryManager;
use futures_util::StreamExt;
use mdns_sd::{ServiceDaemon, ServiceEvent};
// Créer le gestionnaire de découverte UPNP
let mut discovery_manager =
ChromecastDiscoveryManager::new(registry_for_mdns, udn_cache_for_mdns);
debug!("Starting mDNS discovery thread for Chromecast devices");
const SERVICE_NAME: &str = "_googlecast._tcp.local";
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
// Run async discovery in a blocking task
async_std::task::block_on(async {
// Create mDNS discovery stream with 15 second query interval
// (shorter interval for faster initial discovery)
match mdns::discover::all(SERVICE_NAME, Duration::from_secs(15)) {
Ok(discovery) => {
let stream = discovery.listen();
futures_util::pin_mut!(stream);
debug!("mDNS discovery stream started for Chromecast devices");
// Listen to mDNS responses
while let Some(result) = stream.next().await {
match result {
Ok(response) => {
debug!(
"Received mDNS response with {} records",
response.records().count()
);
discovery_manager.handle_mdns_response(response);
}
Err(e) => {
warn!("mDNS discovery error: {}", e);
}
}
}
warn!("mDNS discovery stream ended unexpectedly");
}
Err(e) => {
error!("Failed to start mDNS discovery: {}", e);
}
let daemon = match ServiceDaemon::new() {
Ok(d) => d,
Err(e) => {
error!("Failed to create mDNS daemon: {}", e);
return;
}
});
};
let receiver = match daemon.browse(SERVICE_TYPE) {
Ok(r) => r,
Err(e) => {
error!("Failed to start mDNS browse for {}: {}", SERVICE_TYPE, e);
return;
}
};
debug!("mDNS discovery started for Chromecast devices");
while let Ok(event) = receiver.recv() {
match event {
ServiceEvent::ServiceResolved(info) => {
debug!(
fullname = info.get_fullname(),
host = info.get_hostname(),
port = info.get_port(),
"mDNS Chromecast service resolved"
);
discovery_manager.handle_service_resolved(&info);
}
ServiceEvent::ServiceRemoved(_service_type, fullname) => {
debug!(fullname = %fullname, "mDNS Chromecast service removed");
}
_ => {}
}
}
warn!("mDNS discovery receiver closed");
});
// Note: Renderer polling is now handled by each MusicRenderer's own watcher thread.
@@ -267,19 +268,12 @@ impl ControlPoint {
"Triggering queue refresh for bound playlist"
);
if let Err(err) = refresh_attached_queue_for(
let _ = schedule_queue_refresh_for(
&registry_for_media_worker,
&renderer_id,
&event_bus_for_media_worker,
None,
) {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
error = %err,
"Failed to refresh queue from playlist container"
);
}
);
}
}
MediaServerEvent::Online { server_id, info } => {
@@ -337,18 +331,12 @@ impl ControlPoint {
"Periodic refresh triggered for bound playlist"
);
if let Err(err) = refresh_attached_queue_for(
let _ = schedule_queue_refresh_for(
&registry_for_periodic,
&renderer_id,
&event_bus_for_periodic,
None,
) {
warn!(
renderer = renderer_id.0.as_str(),
error = %err,
"Periodic refresh failed for bound playlist"
);
}
);
}
}
})?;
@@ -851,11 +839,17 @@ impl ControlPoint {
"Playing current playback item from queue"
);
// Temporarily disable auto-advance to prevent race condition
// when renderer sends Stopped event during SetAVTransportURI
renderer.set_playback_source(PlaybackSource::None);
// Note: We set playback_source to FromQueue BEFORE calling play_from_queue().
// This prevents a race condition where:
// 1. play_from_queue() is called
// 2. watcher detects STOPPED from track transition (before playback_source is set)
// 3. with source=None, is_playing_from_queue() returns false, breaking auto-advance
// By setting it BEFORE, even if STOPPED is detected, source will be FromQueue.
// Note: We no longer temporarily set playback_source to None (that caused Bug #2).
// Start playback using play_from_queue which preserves the queue
// Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
if let Err(err) = renderer.play_from_queue() {
error!(
renderer = renderer_id.0.as_str(),
@@ -877,7 +871,7 @@ impl ControlPoint {
let metadata = playback_item_track_metadata(&item);
renderer.set_last_metadata(Some(metadata));
renderer.set_playback_source(PlaybackSource::FromQueue);
// Note: playback_source already set to FromQueue above
// Prefetch next track if supported (gapless playback)
self.prefetch_next_track(&renderer, renderer_id);
@@ -907,8 +901,9 @@ impl ControlPoint {
return Ok(());
}
// Temporarily disable auto-advance to prevent race condition
renderer.set_playback_source(PlaybackSource::None);
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
// Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
// Use the backend's play_next which handles queue advancement correctly for each backend type
if let Err(err) = renderer.play_next_from_queue() {
@@ -1047,8 +1042,9 @@ impl ControlPoint {
return Ok(());
}
// Temporarily disable auto-advance to prevent race condition
renderer.set_playback_source(PlaybackSource::None);
// Note: Same fix as play_current_from_queue - set source BEFORE calling.
// Set source to FromQueue BEFORE calling to prevent race condition
renderer.set_playback_source(PlaybackSource::FromQueue);
// Use the backend's play_from_index which handles everything correctly
if let Err(err) = renderer.play_from_index(index) {
@@ -1182,19 +1178,23 @@ impl ControlPoint {
binding.auto_play_on_refresh = auto_play;
renderer.set_playlist_binding(Some(binding));
let mut auto_start_cb = |rid: &DeviceId| self.play_current_from_queue(rid);
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
if auto_play {
Some(&mut auto_start_cb)
} else {
None
};
return refresh_attached_queue_for(
&self.registry,
renderer_id,
&self.event_bus,
callback,
);
let callback: Option<
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
> = if auto_play {
let reg = Arc::clone(&self.registry);
Some(Box::new(move |rid: &DeviceId| {
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
})?;
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
renderer.play_current_from_queue_with_retry()
}))
} else {
None
};
let _ =
schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
return Ok(());
}
// CRITICAL: When attaching a new playlist to a renderer, we must UNCONDITIONALLY
@@ -1247,21 +1247,27 @@ impl ControlPoint {
// Note: BindingChanged event is emitted automatically by MusicRenderer::set_playlist_binding()
// For initial attach with auto_play, force playback start (don't check if idle)
let mut auto_start_cb = |rid: &DeviceId| {
debug!(
renderer = rid.0.as_str(),
"Attach callback: forcing playback start (not checking if idle)"
);
self.play_current_from_queue(rid)
let callback: Option<
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
> = if auto_play {
let reg = Arc::clone(&self.registry);
Some(Box::new(move |rid: &DeviceId| {
debug!(
renderer = rid.0.as_str(),
"Attach callback: forcing playback start (not checking if idle)"
);
let renderer = reg.read().unwrap().get_renderer(rid).ok_or_else(|| {
ControlPointError::ControlPoint(format!("Renderer {} not found", rid.0))
})?;
// Use retry version to handle transient renderer errors (like JBL Authentics 300)
renderer.play_current_from_queue_with_retry()
}))
} else {
None
};
let callback: Option<&mut dyn FnMut(&DeviceId) -> Result<(), ControlPointError>> =
if auto_play {
Some(&mut auto_start_cb)
} else {
None
};
refresh_attached_queue_for(&self.registry, renderer_id, &self.event_bus, callback)
let _ = schedule_queue_refresh_for(&self.registry, renderer_id, &self.event_bus, callback);
Ok(())
}
/// Detach a renderer's queue from its associated playlist container.
@@ -1760,6 +1766,203 @@ fn refresh_attached_queue_for(
Ok(())
}
fn fetch_queue_items_for(
music_server: &Arc<crate::media_server::MusicServer>,
container_id: &str,
) -> Result<Vec<PlaybackItem>, ControlPointError> {
const MAX_BROWSE_ATTEMPTS: usize = 3;
const BROWSE_RETRY_DELAY_MS: u64 = 200;
const BROWSE_PAGE_SIZE: u32 = 64;
let entries = {
let mut all_entries = Vec::new();
let mut offset = 0u32;
loop {
let mut attempt = 1;
let page = loop {
match music_server.browse_children(container_id, offset, BROWSE_PAGE_SIZE) {
Ok(e) => break e,
Err(err) => {
if attempt >= MAX_BROWSE_ATTEMPTS {
return Err(err);
}
thread::sleep(Duration::from_millis(
BROWSE_RETRY_DELAY_MS * attempt as u64,
));
attempt += 1;
}
}
};
let fetched = page.len() as u32;
all_entries.extend(page);
if fetched < BROWSE_PAGE_SIZE {
break;
}
offset += fetched;
}
all_entries
};
let new_items: Vec<PlaybackItem> = entries
.iter()
.filter_map(|entry| playback_item_from_entry(music_server.clone(), entry))
.collect();
Ok(new_items)
}
fn schedule_queue_refresh_for(
registry: &Arc<RwLock<DeviceRegistry>>,
renderer_id: &DeviceId,
event_bus: &RendererEventBus,
after_refresh: Option<
Box<dyn FnOnce(&DeviceId) -> Result<(), ControlPointError> + Send + 'static>,
>,
) -> SyncScheduleOutcome {
// Step 1: Get renderer from registry
let renderer = {
let reg = registry.read().unwrap();
reg.get_renderer(renderer_id)
};
let renderer = match renderer {
Some(r) => r,
None => {
debug!(
renderer = renderer_id.0.as_str(),
"schedule_queue_refresh_for: renderer not found"
);
return SyncScheduleOutcome::Scheduled;
}
};
// Check if there's a binding
let (server_id, container_id) = {
let binding = match renderer.get_playlist_binding() {
Some(b) => b,
None => {
debug!(
renderer = renderer_id.0.as_str(),
"schedule_queue_refresh_for: no binding present"
);
return SyncScheduleOutcome::Scheduled;
}
};
(binding.server_id.clone(), binding.container_id.clone())
};
// Step 2: Get server from registry
let music_server = {
let reg = registry.read().unwrap();
reg.get_server(&server_id)
};
let music_server = match music_server {
Some(s) => s,
None => {
warn!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"schedule_queue_refresh_for: server not found in registry"
);
return SyncScheduleOutcome::Scheduled;
}
};
if !music_server.is_online() {
debug!(
renderer = renderer_id.0.as_str(),
server = server_id.0.as_str(),
"schedule_queue_refresh_for: server offline, skipping refresh"
);
return SyncScheduleOutcome::Scheduled;
}
// Emit QueueRefreshing
event_bus.broadcast(RendererEvent::QueueRefreshing {
id: renderer_id.clone(),
});
// Get the queue Arc
let queue_arc = {
let reg = registry.read().unwrap();
match reg.get_renderer_queue_arc(renderer_id) {
Some(q) => q,
None => {
warn!("schedule_queue_refresh_for: could not get queue arc");
return SyncScheduleOutcome::Scheduled;
}
}
};
// Build the pending_items_fn for re-fetching
let registry_clone = Arc::clone(registry);
let server_id_clone = server_id.clone();
let container_id_clone = container_id.clone();
let pending_fn = Box::new(move || {
let music_server = {
let reg = registry_clone.read().unwrap();
reg.get_server(&server_id_clone)
};
match music_server {
Some(s) => fetch_queue_items_for(&s, &container_id_clone),
None => Err(ControlPointError::MediaServerError(
"Server not found".to_string(),
)),
}
});
// Build on_ready callback
let rid = renderer_id.clone();
let bus = event_bus.clone();
let after_cb = after_refresh;
let on_ready: Option<Box<dyn FnOnce() + Send + 'static>> = Some(Box::new(move || {
bus.broadcast(RendererEvent::QueueReadyToPlay { id: rid.clone() });
if let Some(cb) = after_cb {
if let Err(e) = cb(&rid) {
warn!("auto-play callback failed: {}", e);
}
}
}));
// Build on_complete callback
let rid2 = renderer_id.clone();
let bus2 = event_bus.clone();
let on_complete = Box::new(move |queue_len: usize| {
bus2.broadcast(RendererEvent::QueueUpdated {
id: rid2.clone(),
queue_length: queue_len,
});
});
// First fetch
let items = match fetch_queue_items_for(&music_server, &container_id) {
Ok(items) => items,
Err(e) => {
warn!("schedule_queue_refresh_for: failed to fetch items: {}", e);
return SyncScheduleOutcome::Scheduled;
}
};
// Call schedule_sync
let outcome = crate::queue::MusicQueue::schedule_sync(
&queue_arc,
&renderer_id.0,
items,
pending_fn,
on_ready,
on_complete,
);
if matches!(outcome, SyncScheduleOutcome::AlreadyRunning) {
event_bus.broadcast(RendererEvent::QueueSyncCancelled {
id: renderer_id.clone(),
});
}
outcome
}
fn didl_item_from_playback_item(item: &PlaybackItem) -> DidlItem {
let metadata = item.metadata.as_ref();
let title = metadata

View File

@@ -5,14 +5,15 @@
//! This module handles the discovery of Chromecast devices and registers them
//! directly into the `DeviceRegistry`.
use std::collections::HashMap;
use std::net::IpAddr;
use std::sync::{Arc, Mutex, RwLock};
use crate::DeviceId;
use crate::DeviceRegistry;
use mdns_sd::ResolvedService;
use mdns_sd::ServiceInfo;
use crate::discovery::manager::UDNRegistry;
use crate::model::{RendererCapabilities, RendererInfo, RendererProtocol};
use crate::DeviceId;
use crate::DeviceRegistry;
use tracing::{debug, warn};
/// Gestionnaire des événements mDNS pour Chromecast.
@@ -32,146 +33,80 @@ impl ChromecastDiscoveryManager {
}
}
/// Traite une réponse mDNS pour un appareil Chromecast.
/// Traite un service Chromecast résolu par mDNS-SD.
///
/// Cette fonction parse les réponses de service discovery mDNS pour les appareils
/// Chromecast et les enregistre directement dans le registre.
pub fn handle_mdns_response(&mut self, response: mdns::Response) {
// Extract basic information from the mDNS response
let service_name = match response.records().find_map(|r| {
if let mdns::RecordKind::PTR(ref name) = r.kind {
Some(name.clone())
} else {
None
}
}) {
Some(name) => name,
None => {
warn!("No PTR record found in mDNS response");
return;
}
};
/// `ServiceInfo` arrive pré-assemblé : plus besoin de jointure manuelle
/// des enregistrements PTR / A / SRV / TXT.
pub fn handle_service_resolved(&mut self, info: &ResolvedService) {
let fullname = info.get_fullname().to_string();
debug!("Processing mDNS response for service: {}", service_name);
debug!("Processing resolved Chromecast service: {}", fullname);
// Extract IP addresses
let addresses: Vec<IpAddr> = response
.records()
.filter_map(|r| match r.kind {
mdns::RecordKind::A(addr) => Some(IpAddr::V4(addr)),
mdns::RecordKind::AAAA(addr) => Some(IpAddr::V6(addr)),
_ => None,
})
.collect();
if addresses.is_empty() {
warn!(
"No IP address found for Chromecast device: {}",
service_name
);
return;
}
// Prefer IPv4 addresses
let host = match addresses
let host = match info
.get_addresses()
.iter()
.find(|addr| matches!(addr, IpAddr::V4(_)))
.or_else(|| addresses.first())
.find(|a| a.is_ipv4())
.or_else(|| info.get_addresses().iter().next())
{
Some(addr) => addr.to_string(),
None => {
warn!("Could not extract host from addresses");
warn!("No IP address for Chromecast service: {}", fullname);
return;
}
};
// Extract port from SRV record
let port = response
.records()
.find_map(|r| {
if let mdns::RecordKind::SRV { port, .. } = r.kind {
Some(port)
} else {
None
}
})
.unwrap_or(8009); // Default Chromecast port
let port = info.get_port();
// Extract TXT records for additional metadata
let txt_records: HashMap<String, String> = response
.records()
.filter_map(|r| {
if let mdns::RecordKind::TXT(ref data) = r.kind {
Some(data.clone())
} else {
None
}
})
.flat_map(|data| {
// data is Vec<String>, each string is "key=value"
data.into_iter().filter_map(|s| {
let parts: Vec<&str> = s.splitn(2, '=').collect();
if parts.len() == 2 {
Some((parts[0].to_string(), parts[1].to_string()))
} else {
None
}
})
})
.collect();
let uuid = info
.get_property_val_str("id")
.unwrap_or_default()
.to_string();
let uuid = if uuid.is_empty() {
format!("chromecast-{}-{}", host, port)
} else {
uuid
};
// Extract metadata from TXT records
let model = txt_records.get("md").cloned();
let uuid = txt_records
.get("id")
.cloned()
.unwrap_or_else(|| format!("chromecast-{}-{}", host, port));
let manufacturer = Some("Google Inc.".to_string());
let model = info.get_property_val_str("md").map(|s| s.to_string());
// Extract friendly name from TXT record "fn" if available
// Otherwise, extract from service instance name (PTR record)
let friendly_name = txt_records.get("fn").cloned().unwrap_or_else(|| {
// Fallback: extract from service name, removing the UUID suffix if present
service_name
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.split('-')
.take_while(|part| part.len() != 32) // Skip 32-char hex UUID
.collect::<Vec<_>>()
.join("-")
.trim()
.to_string()
});
let friendly_name = info
.get_property_val_str("fn")
.map(|s| s.to_string())
.unwrap_or_else(|| {
fullname
.split("._googlecast._tcp.local")
.next()
.unwrap_or("Unknown Chromecast")
.split('-')
.take_while(|part| part.len() != 32)
.collect::<Vec<_>>()
.join("-")
.trim()
.to_string()
});
debug!(
"Discovered Chromecast: {} at {}:{} (UUID: {}, Model: {:?})",
friendly_name, host, port, uuid, model
);
// Build UDN and check cache
let udn = format!("uuid:{}", uuid);
let default_max_age = 1800u64;
// Pour Chromecast, on utilise un max_age par défaut car mDNS n'a pas ce concept
let default_max_age = 1800u64; // 30 minutes
// Check cache to avoid redundant updates
if !UDNRegistry::should_fetch(self.udn_cache.clone(), &udn, default_max_age) {
debug!("Chromecast {} recently seen, skipping", udn);
return;
}
// Create RendererInfo for the registry
let renderer_info = build_renderer_info(
&uuid,
&friendly_name,
&host,
port,
model.as_deref(),
manufacturer.as_deref(),
Some("Google Inc."),
);
// Register the renderer
self.device_registry
.write()
.expect("DeviceRegistry mutex lock failed")

View File

@@ -49,6 +49,8 @@ pub enum ControlPointError {
SnapshotError(String),
#[error("Error on ControlPoint: {0}")]
ControlPoint(String),
#[error("Queue sync cancelled (superseded by a newer request)")]
SyncCancelled,
}
impl ControlPointError {

View File

@@ -2,8 +2,8 @@ use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use pmodidl::DIDLLite;
use pmoupnp::soap::SoapEnvelope;
use pmoupnp::soap::error_codes;
use pmoupnp::soap::SoapEnvelope;
use tracing::{debug, warn};
use xmltree::{Element, XMLNode};
@@ -11,8 +11,8 @@ use crate::errors::ControlPointError;
use crate::model::TrackMetadata;
use crate::online::{DeviceConnectionState, DeviceOnline};
use crate::queue::PlaybackItem;
use crate::soap_client::{SoapCallResult, invoke_upnp_action_with_timeout};
use crate::{DEFAULT_HTTP_TIMEOUT, DeviceId, DeviceIdentity};
use crate::soap_client::{invoke_upnp_action_with_timeout, SoapCallResult};
use crate::{DeviceId, DeviceIdentity, DEFAULT_HTTP_TIMEOUT};
/// Snapshot of a media server discovered through UPnP SSDP.
#[derive(Clone, Debug)]
@@ -91,7 +91,9 @@ impl UpnpMediaServer {
start: u32,
count: u32,
) -> Result<Vec<MediaEntry>, ControlPointError> {
Ok(self.browse_with_flag_paged(object_id, browse_flag, start, count)?.entries)
Ok(self
.browse_with_flag_paged(object_id, browse_flag, start, count)?
.entries)
}
fn browse_with_flag_paged(
@@ -109,17 +111,23 @@ impl UpnpMediaServer {
("Filter", "*".to_string()),
("StartingIndex", start_str),
("RequestedCount", count_str),
("SortCriteria", String::new()),
// Tri par titre (alphabétique croissant) pour avoir un ordre déterministe
("SortCriteria", "+dc:title".to_string()),
];
let response = self.invoke_content_directory("Browse", None, args)?;
let envelope = response.envelope.ok_or_else(|| {
ControlPointError::MediaServerError("Missing SOAP envelope in Browse response".to_string())
ControlPointError::MediaServerError(
"Missing SOAP envelope in Browse response".to_string(),
)
})?;
let total_count = extract_total_matches(&envelope, "BrowseResponse");
let didl_xml = extract_result_payload(&envelope, "BrowseResponse")?;
let entries = map_didl_entries(&didl_xml)?;
Ok(BrowsePage { entries, total_count })
Ok(BrowsePage {
entries,
total_count,
})
}
fn has_content_directory(&self) -> bool {
@@ -398,7 +406,10 @@ pub trait MediaBrowser {
) -> Result<BrowsePage, ControlPointError> {
let entries = self.browse_children(object_id, start, count)?;
let total_count = entries.len() as u32 + start;
Ok(BrowsePage { entries, total_count })
Ok(BrowsePage {
entries,
total_count,
})
}
fn browse_object(&self, object_id: &str) -> Result<MediaEntry, ControlPointError>;
fn search(

View File

@@ -437,6 +437,12 @@ pub enum RendererEvent {
QueueRefreshing {
id: DeviceId,
},
QueueReadyToPlay {
id: DeviceId,
},
QueueSyncCancelled {
id: DeviceId,
},
BindingChanged {
id: DeviceId,
binding: Option<PlaylistBinding>,

View File

@@ -1,26 +1,26 @@
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::Duration;
use serde::Deserialize;
use tracing::debug;
use crate::DeviceIdentity;
use crate::arylic_client::{
ARYLIC_TCP_PORT, DEFAULT_TIMEOUT_SECS, send_command_no_response, send_command_optional,
send_command_required,
send_command_no_response, send_command_optional, send_command_required, ARYLIC_TCP_PORT,
DEFAULT_TIMEOUT_SECS,
};
use crate::errors::ControlPointError;
use crate::linkplay_client::extract_linkplay_host;
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::{format_hhmmss, ms_to_seconds, parse_hhmmss_strict};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::DeviceIdentity;
/// Raw response from Arylic MCU+PINFGET command
#[derive(Debug, Deserialize)]
@@ -409,8 +409,16 @@ impl QueueBackend for ArylicTcpRenderer {
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {

View File

@@ -11,6 +11,7 @@
//! are wrapped in sync calls using smol::block_on for compatibility with
//! the existing sync trait interfaces.
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, Mutex, Once};
use std::thread::JoinHandle;
@@ -906,8 +907,16 @@ impl QueueBackend for ChromecastRenderer {
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {

View File

@@ -1,24 +1,24 @@
use std::fmt;
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::Duration;
use ureq::Agent;
use crate::DeviceIdentity;
use crate::errors::ControlPointError;
use crate::linkplay_client::{
LinkPlayStatus, build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode,
build_agent, extract_linkplay_host, fetch_status_for_host, percent_encode, LinkPlayStatus,
};
use crate::model::{PlaybackState, RendererInfo};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::MusicRendererBackend;
use crate::music_renderer::time_utils::parse_hhmmss_strict;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::MusicQueue;
use crate::queue::{EnqueueMode, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::DeviceIdentity;
const DEFAULT_HTTP_TIMEOUT_SECS: u64 = 3;
@@ -287,8 +287,16 @@ impl QueueBackend for LinkPlayRenderer {
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {

View File

@@ -9,7 +9,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
use pmodidl::{DIDLLite, MediaMetadataParser};
use tracing::{debug, error};
@@ -312,17 +312,37 @@ impl MusicRenderer {
/// Main loop for the watcher thread.
fn watcher_loop(&self, strategy: WatchStrategy, stop_flag: Arc<AtomicBool>) {
let Some(interval) = strategy.polling_interval() else {
let Some(base_interval) = strategy.polling_interval() else {
// Pure push strategy - no polling needed (future implementation)
return;
};
let short_interval = base_interval;
let long_interval = Duration::from_millis(5_000);
let inactivity_threshold = Duration::from_secs(10);
let mut tick: u32 = 0;
let mut next_poll_time = SystemTime::now();
let mut last_activity_time = SystemTime::now();
while !stop_flag.load(Ordering::SeqCst) {
let is_active = {
let watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active
};
let interval = if is_active {
short_interval
} else {
long_interval
};
if self.is_online() {
self.poll_and_emit_changes(tick);
last_activity_time = SystemTime::now();
}
tick = tick.wrapping_add(1);
@@ -341,6 +361,17 @@ impl MusicRenderer {
);
next_poll_time = SystemTime::now();
}
// Reset is_active flag if no activity for threshold
if let Ok(elapsed) = SystemTime::now().duration_since(last_activity_time) {
if elapsed >= inactivity_threshold {
let mut watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active = false;
}
}
}
debug!(
@@ -662,18 +693,37 @@ impl MusicRenderer {
self.set_playback_source(PlaybackSource::None);
self.clear_has_played_flag();
} else if self.is_playing_from_queue() {
// Only auto-advance if we have actually seen a PLAYING state
// since the track was started. This prevents auto-advance on
// transient STOPPED states during track initialization.
if self.check_and_clear_has_played_flag() {
debug!(
renderer = self.info.friendly_name(),
"Renderer stopped after queue-driven playback; advancing"
);
let has_played = self.check_and_clear_has_played_flag();
// ✅ CORRECTION BUG PRODUCTION: Garde fou contre latence DB
// Si la base de données est saturée (mutex >400ms) on rate parfois l'événement PLAYING.
// On autorise l'auto-avance si:
// 1. On a bien vu PLAYING OU
// 2. Le titre a été lancé depuis plus de 20 secondes
let track_start = self.state.lock().unwrap().track_start_time;
let elapsed = track_start
.and_then(|t| t.elapsed().ok())
.unwrap_or_default();
let force_advance = elapsed.as_secs() > 20;
if has_played || force_advance {
if force_advance {
debug!(
renderer = self.info.friendly_name(),
elapsed_sec = elapsed.as_secs(),
"⚠️ On force l'auto-avance: on a raté l'événement PLAYING (latence DB)"
);
} else {
debug!(
renderer = self.info.friendly_name(),
"Renderer stopped after queue-driven playback; advancing"
);
}
// Use catch_unwind to prevent panics in play_next_from_queue
// from poisoning the backend mutex
// Also add retry logic for transient errors from renderer
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
self.play_next_from_queue()
self.play_next_from_queue_with_retry()
}));
match result {
Ok(Ok(())) => {}
@@ -681,7 +731,7 @@ impl MusicRenderer {
error!(
renderer = self.info.friendly_name(),
error = %err,
"Auto-advance failed; clearing queue playback state"
"Auto-advance failed after retries; clearing queue playback state"
);
self.set_playback_source(PlaybackSource::None);
}
@@ -945,6 +995,12 @@ impl MusicRenderer {
Ok(snapshot)
}
/// Get a clone of the queue Arc (for async sync operations).
pub fn queue(&self) -> Arc<Mutex<MusicQueue>> {
let backend = self.lock_backend_for("queue");
crate::music_renderer::capabilities::RendererBackend::queue(&*backend).clone()
}
/// Get the current queue item without advancing.
/// Returns the item and count of remaining items after current.
pub fn peek_current(&self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
@@ -956,13 +1012,69 @@ impl MusicRenderer {
self.lock_backend_for("upcoming_len").upcoming_len()
}
/// Play the current item from the queue with retry logic for transient renderer errors.
///
/// Some renderers (like JBL Authentics 300) may fail the first Play command
/// due to timing issues. This method retries with a small delay.
pub fn play_current_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
const MAX_RETRIES: usize = 3;
const RETRY_DELAY_MS: u64 = 200;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
// Reset the has_played flag before starting playback to prevent
// auto-advance on transient STOPPED states during track initialization.
// The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
match self
.lock_backend_for("play_current_from_queue_with_retry")
.play_from_queue()
{
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt < MAX_RETRIES - 1 {
tracing::warn!(
renderer = self.info.friendly_name(),
attempt = attempt + 1,
error = %last_error.as_ref().unwrap(),
"Initial play attempt failed, retrying..."
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
}
}
}
}
// All retries failed
Err(last_error.unwrap_or_else(|| {
ControlPointError::ControlPoint("Unknown error in retry logic".into())
}))
}
/// Play the current item from the queue.
#[deprecated(
since = "0.1.0",
note = "Use play_current_from_queue_with_retry instead"
)]
pub fn play_current_from_queue(&self) -> Result<(), ControlPointError> {
// Reset the has_played flag before starting playback to prevent
// Reset the has_played_flag before starting playback to prevent
// auto-advance on transient STOPPED states during track initialization.
// The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
self.lock_backend_for("play_current_from_queue")
.play_from_queue()
}
@@ -974,11 +1086,50 @@ impl MusicRenderer {
// The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
self.lock_backend_for("play_next_from_queue").play_next()?;
self.emit_queue_updated();
Ok(())
}
/// Play next from queue with retry logic for transient renderer errors.
///
/// Some renderers (like JBL Authentics 300) may fail the first Play command
/// due to timing issues. This method retries with a small delay.
pub fn play_next_from_queue_with_retry(&self) -> Result<(), ControlPointError> {
const MAX_RETRIES: usize = 3;
const RETRY_DELAY_MS: u64 = 200;
let mut last_error = None;
for attempt in 0..MAX_RETRIES {
match self.play_next_from_queue() {
Ok(()) => return Ok(()),
Err(e) => {
last_error = Some(e);
if attempt < MAX_RETRIES - 1 {
tracing::warn!(
renderer = self.info.friendly_name(),
attempt = attempt + 1,
error = %last_error.as_ref().unwrap(),
"Auto-advance attempt failed, retrying..."
);
std::thread::sleep(std::time::Duration::from_millis(RETRY_DELAY_MS));
}
}
}
}
// All retries failed
Err(last_error.unwrap_or_else(|| {
ControlPointError::ControlPoint("Unknown error in retry logic".into())
}))
}
/// Advance the queue index by one without starting playback.
///
/// Used by the WebRenderer gapless path: the browser autonomously transitions
@@ -996,14 +1147,21 @@ impl MusicRenderer {
/// Play from a specific index in the queue.
pub fn play_from_index(&self, index: usize) -> Result<(), ControlPointError> {
// Reset the has_played flag before starting playback to prevent
// auto-advance on transient STOPPED states during track initialization.
// The flag will be set back to true when PLAYING state is detected.
// ✅ CORRECTIF BUG SHUFFLE: Quand on change d'index manuellement
// (par exemple shuffle, clique sur un titre), on réinitialise OBLIGATOIREMENT
// le flag has_played. Sinon quand le titre se termine l'auto-avance
// pense qu'il n'a jamais démarré et s'arrête.
tracing::debug!(
index = index,
renderer = self.info.friendly_name(),
"🎯 play_from_index appelé, réinitialisation has_played_flag"
);
self.clear_has_played_flag();
self.lock_backend_for("play_from_index")
.play_from_index(index)?;
self.emit_queue_updated();
self.set_playback_source(PlaybackSource::FromQueue);
Ok(())
}
@@ -1012,11 +1170,25 @@ impl MusicRenderer {
/// Démarre ou reprend la lecture. Si une queue non vide existe,
/// joue le track courant de la queue automatiquement (comportement unifié pour tous les backends).
pub fn play(&self) -> Result<(), ControlPointError> {
// Mark renderer as active for adaptive polling
{
let mut watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active = true;
}
// Vérifier si on a une queue non vide
let backend = self.lock_backend_for("play");
let queue_not_empty = backend.len().unwrap_or(0) > 0;
if queue_not_empty {
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
// Si on a des items dans la queue, jouer le track courant (ou le premier si aucun n'est sélectionné)
// Cela fonctionne pour tous les backends (UPnP interne, OpenHome, etc.)
backend.play_from_queue()
@@ -1034,6 +1206,15 @@ impl MusicRenderer {
/// Transport control: stop
#[track_caller]
pub fn stop(&self) -> Result<(), ControlPointError> {
// Mark renderer as active for adaptive polling
{
let mut watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active = true;
}
// Reset the has_played flag when stopping playback.
// This ensures that if we start a new track, the flag will be false
// until PLAYING state is observed, preventing auto-advance on
@@ -1067,6 +1248,15 @@ impl MusicRenderer {
/// Transport control: seek to relative time
pub fn seek_rel_time(&self, hhmmss: &str) -> Result<(), ControlPointError> {
// Mark renderer as active for adaptive polling
{
let mut watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active = true;
}
self.lock_backend_for("seek_rel_time").seek_rel_time(hhmmss)
}
@@ -1314,8 +1504,28 @@ impl MusicRenderer {
/// - If the current track is NOT in the new items, it's preserved as the first item
/// - If there's no current track, the queue is simply replaced
pub fn sync_queue(&self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.sync_queue_with_callback(items, None, None)
}
/// Synchronize the queue with new items with optional cancel token and on_ready callback.
pub fn sync_queue_with_callback(
&self,
items: Vec<PlaybackItem>,
cancel_token: Option<&Arc<AtomicBool>>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
{
let mut watched = self
.watched_state
.lock()
.expect("WatchedState mutex poisoned");
watched.is_active = true;
}
let mut backend = self.lock_backend_for("sync_queue");
backend.sync_queue(items)?;
let dummy_token = Arc::new(AtomicBool::new(false));
let token = cancel_token.unwrap_or(&dummy_token);
backend.sync_queue(items, token, on_ready)?;
drop(backend);
self.emit_queue_updated();
Ok(())
@@ -1444,6 +1654,11 @@ impl MusicRenderer {
// The flag will be set back to true when PLAYING state is detected.
self.clear_has_played_flag();
// Set playback_source to FromQueue BEFORE calling backend
// to prevent race condition where watcher sees STOPPED before
// source is set, breaking auto-advance
self.set_playback_source(PlaybackSource::FromQueue);
self.lock_backend_for("play_from_queue").play_from_queue()
}
@@ -1499,11 +1714,18 @@ impl MusicRenderer {
)
}
/// Marks playback as external if currently idle (source is None).
/// Marks playback as external if currently idle.
///
/// Only sets to External if we were already playing from an external source.
/// Does NOT change None -> External because that would break queue playback
/// (the control_point will set it to FromQueue after play_from_queue succeeds).
pub fn mark_external_if_idle(&self) {
let mut state = self.state.lock().unwrap();
if matches!(state.playback_source, PlaybackSource::None) {
state.playback_source = PlaybackSource::External;
if matches!(state.playback_source, PlaybackSource::External) {
// Keep External if we were already playing externally
} else {
// Don't change None -> External - that breaks queue auto-advance!
// The control_point will set playback_source to FromQueue after play_from_queue succeeds.
}
}
@@ -1656,8 +1878,9 @@ impl MusicRenderer {
// 5. Replace the queue with shuffled items, starting at index 0
self.replace_queue(shuffled_items, Some(0))?;
// 6. Start playback from the first track
self.play_from_index(0)?;
// 6. Start playback from the first track with retry for JBL-like renderers
// that fail the first Play command due to timing issues
self.play_current_from_queue_with_retry()?;
Ok(())
}
@@ -2216,14 +2439,21 @@ impl QueueBackend for MusicRendererBackend {
}
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
match self {
MusicRendererBackend::Upnp(r) => r.sync_queue(items),
MusicRendererBackend::OpenHome(r) => r.sync_queue(items),
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items),
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items),
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => upnp.sync_queue(items),
MusicRendererBackend::Upnp(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::OpenHome(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::LinkPlay(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::ArylicTcp(r) => r.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::Chromecast(cc) => cc.sync_queue(items, cancel_token, on_ready),
MusicRendererBackend::HybridUpnpArylic { upnp, .. } => {
upnp.sync_queue(items, cancel_token, on_ready)
}
}
}

View File

@@ -1,4 +1,4 @@
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::SystemTime;
use crate::music_renderer::capabilities::{
@@ -182,9 +182,8 @@ impl OpenHomeRenderer {
/// Retourne les IDs des pistes de la playlist OpenHome.
/// Plus rapide que snapshot_openhome_playlist() car ne récupère pas les métadonnées.
pub(crate) fn openhome_playlist_ids(&self) -> Result<Vec<u32>, ControlPointError> {
// Use the queue's cached track_ids() instead of direct id_array() call
let queue = self.queue.lock().unwrap();
if let MusicQueue::OpenHome(oh_queue) = &*queue {
if let Some(oh_queue) = queue.as_openhome() {
oh_queue.track_ids()
} else {
Err(ControlPointError::QueueError(
@@ -209,9 +208,8 @@ impl OpenHomeRenderer {
let insert_after = match after_id {
Some(id) => id,
None => {
// Use the queue's cached track_ids() instead of direct id_array() call
let queue = self.queue.lock().unwrap();
if let MusicQueue::OpenHome(oh_queue) = &*queue {
if let Some(oh_queue) = queue.as_openhome() {
oh_queue
.track_ids()?
.last()
@@ -413,7 +411,7 @@ impl PlaybackPosition for OpenHomeRenderer {
// Get track ID from queue (uses cached data)
let queue_guard_for_id = self.queue.lock().unwrap();
if let MusicQueue::OpenHome(oh_queue) = &*queue_guard_for_id {
if let Some(oh_queue) = queue_guard_for_id.as_openhome() {
match oh_queue.current_track() {
Ok(id_opt) => track_id = id_opt,
Err(err) => debug!(
@@ -690,17 +688,48 @@ impl QueueBackend for OpenHomeRenderer {
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
// ✅ CORRECTION BUG PRODUCTION: On ne charge PAS toutes les métadonnées
// dans le thread principal. OpenHome sur 1000 titres inondait la base SQLite
// et bloquait TOUS les autres threads (mutex >500ms).
//
// On fait juste l'insertion minimaliste maintenant. Le préchargement
// des métadonnées est délégué à un thread background.
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.replace_queue(items, current_index)
.map_err(|_| ControlPointError::QueueError("Mutex poisoned".into()))?
.replace_queue(items, current_index)?;
// Background worker: charge les métadonnées petit à petit sans bloquer personne
let queue = self.queue.clone();
std::thread::spawn(move || {
debug!("🔄 OpenHome: préchargement métadonnées queue en background");
if let Ok(mut queue) = queue.lock() {
// On ne fait que les 10 prochains titres maintenant, le reste on s'en fout
if let Ok(Some(idx)) = queue.current_index() {
let end = std::cmp::min(idx + 10, queue.len().unwrap_or(0));
for i in idx..end {
let _ = queue.get_item(i);
// Petit délai pour ne pas noyer la base de données
std::thread::sleep(std::time::Duration::from_millis(5));
}
}
}
debug!("✅ OpenHome: préchargement métadonnées terminé");
});
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.map_err(|_| ControlPointError::QueueError("Queue mutex poisoned".into()))?
.sync_queue(items)
.sync_queue(items, cancel_token, on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {

View File

@@ -1,13 +1,13 @@
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use crate::errors::ControlPointError;
use crate::model::PlaybackState;
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::music_renderer::capabilities::{
PlaybackPosition, PlaybackPositionInfo, PlaybackStatus, QueueTransportControl, RendererBackend,
TransportControl, VolumeControl,
};
use crate::music_renderer::musicrenderer::{MusicRendererBackend, build_didl_lite_metadata};
use crate::music_renderer::musicrenderer::{build_didl_lite_metadata, MusicRendererBackend};
use crate::music_renderer::RendererFromMediaRendererInfo;
use crate::queue::{EnqueueMode, MusicQueue, PlaybackItem, QueueBackend, QueueSnapshot};
use crate::upnp_clients::{
AvTransportClient, ConnectionInfo, ConnectionManagerClient, PositionInfo, ProtocolInfo,
@@ -218,9 +218,22 @@ impl QueueTransportControl for UpnpRenderer {
// Détecte si l'URL est un flux continu en interrogeant le serveur HTTP
let is_stream = crate::music_renderer::is_continuous_stream_url(&item.uri);
*self.continuous_stream.lock().unwrap() = is_stream;
// Log current queue state for debugging
let queue_state = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
let uri = item.uri.clone();
let title = item.metadata.as_ref().and_then(|m| m.title.clone());
(idx, len, uri, title)
};
tracing::debug!(
"UpnpRenderer play_from_queue: URI={}, continuous_stream={}",
item.uri,
"UpnpRenderer play_from_queue: index={:?}/{}, uri={}, title={:?}, continuous_stream={}",
queue_state.0,
queue_state.1,
queue_state.2,
queue_state.3,
is_stream
);
@@ -243,11 +256,28 @@ impl QueueTransportControl for UpnpRenderer {
}
fn play_next(&self) -> Result<(), ControlPointError> {
let current_idx = {
let queue = self.queue.lock().unwrap();
let idx = queue.current_index().unwrap_or(None);
let len = queue.len().unwrap_or(0);
tracing::debug!(
current_index = ?idx,
queue_len = len,
"play_next: attempting to advance"
);
idx
};
{
let mut queue = self.queue.lock().unwrap();
if !queue.advance()? {
return Err(ControlPointError::QueueError("No next track".into()));
}
let new_idx = queue.current_index().unwrap_or(None);
tracing::debug!(
previous_index = ?current_idx,
new_index = ?new_idx,
"play_next: advanced"
);
}
self.play_from_queue()
@@ -269,6 +299,9 @@ impl QueueTransportControl for UpnpRenderer {
let mut queue = self.queue.lock().unwrap();
queue.set_index(Some(index))?;
}
// CORRECTIF: Quand on change l'index manuellement (shuffle, sélection d'un titre)
// on logue pour être sûr que c'est bien appelé
tracing::debug!(index = index, "✅ SHUFFLE / SEEK: play_from_index appelé");
self.play_from_queue()
}
@@ -318,8 +351,16 @@ impl QueueBackend for UpnpRenderer {
.replace_queue(items, current_index)
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
self.queue.lock().unwrap().sync_queue(items)
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
_cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
self.queue
.lock()
.unwrap()
.sync_queue(items, &Arc::new(AtomicBool::new(false)), on_ready)
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {

View File

@@ -91,6 +91,8 @@ pub struct WatchedState {
pub metadata: Option<TrackMetadata>,
/// Last known stream state (continuous stream vs bounded media)
pub is_stream: Option<bool>,
/// Flag indicating if renderer was active recently (used for adaptive polling)
pub is_active: bool,
}
// ============================================================================

View File

@@ -28,7 +28,8 @@
//! - This identity is used by the sync helpers to preserve the current
//! track across queue rebuilds when the MediaServer content changes.
use crate::{PlaybackItem, QueueSnapshot, errors::ControlPointError};
use crate::{errors::ControlPointError, PlaybackItem, QueueSnapshot};
use std::sync::{atomic::AtomicBool, Arc};
/// High-level enqueue mode.
///
@@ -107,7 +108,12 @@ pub trait QueueBackend {
/// corresponds to the old current index track.
/// If the old current index track is absent from the new queue,
/// it is kept as the first item and the new items are appended after it.
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError>;
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError>;
/// Returns the item at `index`, if it exists.
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError>;

View File

@@ -20,6 +20,7 @@ use crate::{
queue::{MusicQueue, PlaybackItem, QueueBackend, QueueFromRendererInfo, QueueSnapshot},
DeviceId, DeviceIdentity, RendererInfo,
};
use std::sync::{atomic::AtomicBool, Arc};
/// Internal/local queue implementation.
///
@@ -65,25 +66,7 @@ impl InternalQueue {
/// Vérifie si une durée a diminué (format HH:MM:SS).
/// Retourne true si new_duration < old_duration.
fn duration_decreased(old_duration: &str, new_duration: &str) -> bool {
let parse_duration = |dur: &str| -> Option<u32> {
let parts: Vec<&str> = dur.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
if let (Some(old_secs), Some(new_secs)) =
(parse_duration(old_duration), parse_duration(new_duration))
{
new_secs < old_secs
} else {
false // Impossible de parser: considérer que ça n'a pas diminué
}
super::stream_duration_decreased(old_duration, new_duration)
}
/// Protège les durées des streams contre la diminution.
@@ -164,45 +147,23 @@ impl InternalQueue {
// Même chanson sur un stream: vérifier que la durée n'a pas diminué
let should_update = match (&old_meta.duration, &new_meta.duration) {
(Some(old_dur), Some(new_dur)) => {
// Parser les durées (format HH:MM:SS)
let parse_duration = |dur: &str| -> Option<u32> {
let parts: Vec<&str> = dur.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
if let (Some(old_secs), Some(new_secs)) =
(parse_duration(old_dur), parse_duration(new_dur))
{
if new_secs < old_secs {
// Durée a diminué: garder l'ancienne
tracing::trace!(
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
if super::stream_duration_decreased(old_dur, new_dur) {
tracing::trace!(
"InternalQueue merge_metadata: uri={}, REJECTING update (same stream track, duration decreased): {} -> {}",
uri,
old_dur,
new_dur
);
false
} else {
if super::stream_duration_increased(old_dur, new_dur) {
tracing::debug!(
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
uri,
old_dur,
new_dur
);
false
} else {
// Durée a augmenté ou est égale: accepter
if new_secs > old_secs {
tracing::debug!(
"InternalQueue merge_metadata: uri={}, same stream track, duration increased: {} -> {}",
uri,
old_dur,
new_dur
);
}
true
}
} else {
// Impossible de parser: accepter par défaut
true
}
}
@@ -309,25 +270,43 @@ impl QueueBackend for InternalQueue {
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
use tracing::debug;
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
if items.is_empty() {
return self.replace_queue(Vec::new(), None);
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let has_current = self.current_index().ok().flatten().is_some();
if has_current {
if let Some(f) = on_ready.take() {
f();
}
}
if items.is_empty() {
let _ = self.replace_queue(Vec::new(), None);
return Ok(());
}
// Protéger les durées des streams contre la diminution
let updated_items = self.protect_stream_durations(items);
// Récupérer l'item actuel
let current = self.current_index.and_then(|idx| {
self.items
.get(idx)
.map(|item| (idx, item.uri.clone(), item.didl_id.clone()))
});
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if let Some((_current_idx, current_uri, current_didl_id)) = current {
// Chercher l'item actuel dans la nouvelle liste (par URI d'abord, puis par didl_id)
let new_idx = updated_items
.iter()
.position(|item| item.uri == current_uri)
@@ -338,34 +317,25 @@ impl QueueBackend for InternalQueue {
});
if let Some(new_idx) = new_idx {
// Item trouvé dans la nouvelle liste
debug!(
renderer = self.renderer_id.0.as_str(),
current_uri = current_uri.as_str(),
new_idx,
"sync_queue: current item found in new playlist"
);
self.replace_queue(updated_items, Some(new_idx))
self.replace_queue(updated_items, Some(new_idx))?;
} else {
// Item pas trouvé - cela ne devrait pas arriver si la playlist n'a pas changé
// Loguer pour diagnostic
debug!(
renderer = self.renderer_id.0.as_str(),
current_uri = current_uri.as_str(),
current_didl_id = current_didl_id.as_str(),
new_items_count = updated_items.len(),
"sync_queue: current item NOT found in new playlist, preserving as first item"
);
let current_item = self.items[self.current_index.unwrap()].clone();
let mut new_items = Vec::with_capacity(updated_items.len() + 1);
new_items.push(current_item);
new_items.extend(updated_items);
self.replace_queue(new_items, Some(0))
self.replace_queue(new_items, Some(0))?;
}
} else {
// Pas d'item actuel
self.replace_queue(updated_items, None)
self.replace_queue(updated_items, None)?;
}
if !has_current {
if let Some(f) = on_ready.take() {
f();
}
}
Ok(())
}
fn enqueue_items(
@@ -542,6 +512,6 @@ impl QueueFromRendererInfo for InternalQueue {
}
fn to_backend(self) -> MusicQueue {
MusicQueue::Internal(self)
MusicQueue::from_internal(self)
}
}

View File

@@ -7,14 +7,38 @@ mod snapshot;
use std::sync::{Arc, Mutex};
pub use backend::{EnqueueMode, QueueBackend};
pub use music_queue::MusicQueue;
pub use music_queue::{MusicQueue, SyncScheduleOutcome};
pub use snapshot::{PlaybackItem, QueueSnapshot};
// Internal queue implementations - not part of the public API
pub(crate) use interne::InternalQueue;
pub(crate) use openhome::OpenHomeQueue;
use crate::{RendererInfo, errors::ControlPointError};
use crate::music_renderer::time_utils::parse_time_flexible;
use crate::{errors::ControlPointError, RendererInfo};
/// Returns true if `new_dur` < `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
/// Used to protect stream durations from decreasing for the same track.
pub(super) fn stream_duration_decreased(old_dur: &str, new_dur: &str) -> bool {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),
) {
(Some(old_secs), Some(new_secs)) => new_secs < old_secs,
_ => false,
}
}
/// Returns true if `new_dur` > `old_dur` (both parseable as HH:MM:SS/MM:SS/SS).
pub(super) fn stream_duration_increased(old_dur: &str, new_dur: &str) -> bool {
match (
parse_time_flexible(old_dur).ok(),
parse_time_flexible(new_dur).ok(),
) {
(Some(old_secs), Some(new_secs)) => new_secs > old_secs,
_ => false,
}
}
pub trait QueueFromRendererInfo {
fn from_renderer_info(renderer: &RendererInfo) -> Result<Self, ControlPointError>

View File

@@ -3,84 +3,303 @@ use crate::queue::{
EnqueueMode, InternalQueue, OpenHomeQueue, QueueBackend, QueueFromRendererInfo,
};
use crate::{PlaybackItem, QueueSnapshot, RendererInfo};
use std::sync::{
atomic::{AtomicBool, Ordering::SeqCst},
Arc, Mutex,
};
use std::thread;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncScheduleOutcome {
Scheduled,
AlreadyRunning,
}
#[derive(Debug)]
pub enum MusicQueue {
enum MusicQueueBackend {
Internal(InternalQueue),
OpenHome(OpenHomeQueue),
}
#[derive(Debug)]
pub struct MusicQueue {
backend: MusicQueueBackend,
sync_in_progress: Arc<AtomicBool>,
sync_pending: Arc<AtomicBool>,
sync_cancel_token: Arc<AtomicBool>,
}
impl MusicQueue {
/// Creates a queue appropriate for the given renderer.
/// This is the factory method used by QueueFromRendererInfo trait.
pub fn from_renderer_info(info: &RendererInfo) -> Result<MusicQueue, ControlPointError> {
if info.capabilities().has_oh_playlist() {
Ok(MusicQueue::OpenHome(OpenHomeQueue::from_renderer_info(
info,
)?))
let backend = if info.capabilities().has_oh_playlist() {
MusicQueueBackend::OpenHome(OpenHomeQueue::from_renderer_info(info)?)
} else {
Ok(MusicQueue::Internal(InternalQueue::from_renderer_info(
info,
)?))
MusicQueueBackend::Internal(InternalQueue::from_renderer_info(info)?)
};
Ok(MusicQueue {
backend,
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
})
}
/// Creates a MusicQueue from an InternalQueue backend.
pub fn from_internal(queue: InternalQueue) -> MusicQueue {
MusicQueue {
backend: MusicQueueBackend::Internal(queue),
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
}
}
/// Creates a MusicQueue from an OpenHomeQueue backend.
pub fn from_openhome(queue: OpenHomeQueue) -> MusicQueue {
MusicQueue {
backend: MusicQueueBackend::OpenHome(queue),
sync_in_progress: Arc::new(AtomicBool::new(false)),
sync_pending: Arc::new(AtomicBool::new(false)),
sync_cancel_token: Arc::new(AtomicBool::new(false)),
}
}
/// Returns true if this is an OpenHome backend.
pub fn is_openhome(&self) -> bool {
matches!(self.backend, MusicQueueBackend::OpenHome(_))
}
/// Returns a reference to the OpenHome queue if this is an OpenHome backend.
pub fn as_openhome(&self) -> Option<&OpenHomeQueue> {
match &self.backend {
MusicQueueBackend::OpenHome(q) => Some(q),
MusicQueueBackend::Internal(_) => None,
}
}
/// Returns a mutable reference to the OpenHome queue if this is an OpenHome backend.
pub fn as_openhome_mut(&mut self) -> Option<&mut OpenHomeQueue> {
match &mut self.backend {
MusicQueueBackend::OpenHome(q) => Some(q),
MusicQueueBackend::Internal(_) => None,
}
}
pub fn len(&self) -> Result<usize, ControlPointError> {
match &self.backend {
MusicQueueBackend::Internal(q) => q.len(),
MusicQueueBackend::OpenHome(q) => q.len(),
}
}
pub fn schedule_sync(
queue_arc: &Arc<Mutex<MusicQueue>>,
renderer_id: &str,
items: Vec<PlaybackItem>,
pending_items_fn: Box<
dyn Fn() -> Result<Vec<PlaybackItem>, ControlPointError> + Send + 'static,
>,
on_ready: Option<Box<dyn FnOnce() + Send + 'static>>,
on_complete: Box<dyn Fn(usize) + Send + 'static>,
) -> SyncScheduleOutcome {
let (sync_in_progress, sync_pending, sync_cancel_token) = {
let q = queue_arc.lock().unwrap();
(
Arc::clone(&q.sync_in_progress),
Arc::clone(&q.sync_pending),
Arc::clone(&q.sync_cancel_token),
)
};
if sync_in_progress.swap(true, SeqCst) {
sync_cancel_token.store(true, SeqCst);
sync_pending.store(true, SeqCst);
return SyncScheduleOutcome::AlreadyRunning;
}
sync_cancel_token.store(false, SeqCst);
sync_pending.store(false, SeqCst);
let queue_arc = Arc::clone(queue_arc);
let thread_name = format!("queue-sync-{}", renderer_id);
thread::Builder::new()
.name(thread_name)
.spawn(move || {
struct Guard(Arc<AtomicBool>);
impl Drop for Guard {
fn drop(&mut self) {
self.0.store(false, SeqCst);
}
}
let _guard = Guard(Arc::clone(&sync_in_progress));
let mut current_items = items;
let mut current_on_ready = Some(on_ready);
let mut on_complete = Some(on_complete);
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread started");
loop {
sync_pending.store(false, SeqCst);
sync_cancel_token.store(false, SeqCst);
// Extract the real on_ready BEFORE locking the queue.
// on_ready may call play_from_queue() which re-locks the queue,
// so we must NOT call it while holding queue_arc.
let real_on_ready = current_on_ready.take().flatten();
let on_ready_triggered = Arc::new(AtomicBool::new(false));
let proxy_on_ready: Option<Box<dyn FnOnce() + Send + 'static>> =
real_on_ready.as_ref().map(|_| {
let flag = Arc::clone(&on_ready_triggered);
Box::new(move || {
flag.store(true, SeqCst);
}) as Box<dyn FnOnce() + Send + 'static>
});
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
items = current_items.len(),
has_on_ready = real_on_ready.is_some(),
"queue-sync: calling sync_queue"
);
let result = {
let mut q = queue_arc.lock().unwrap();
<MusicQueue as QueueBackend>::sync_queue(
&mut q,
current_items,
&sync_cancel_token,
proxy_on_ready,
)
};
// Queue lock is released here.
// Now safe to call on_ready (which may re-lock the queue).
// If on_ready was triggered by the proxy, consume and call it.
// If not (cancelled before first insert), keep it to pass to retry.
let carry_on_ready = if on_ready_triggered.load(SeqCst) {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready triggered, calling callback"
);
if let Some(f) = real_on_ready {
f();
}
None
} else {
if real_on_ready.is_some() {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: on_ready not triggered, carrying to next attempt"
);
}
real_on_ready
};
match result {
Err(ControlPointError::SyncCancelled) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: cancelled"
);
}
Err(e) => {
tracing::warn!("queue-sync error: {}", e);
}
Ok(()) => {
tracing::debug!(
thread = %std::thread::current().name().unwrap_or("?"),
"queue-sync: completed successfully"
);
if let Some(cb) = on_complete.take() {
let queue_len = queue_arc.lock().unwrap().len().unwrap_or(0);
cb(queue_len);
}
}
}
if !sync_pending.load(SeqCst) {
break;
}
match pending_items_fn() {
Ok(new_items) => {
current_items = new_items;
current_on_ready = Some(carry_on_ready);
}
Err(e) => {
tracing::warn!("queue-sync pending re-fetch error: {}", e);
break;
}
}
}
tracing::debug!(thread = %std::thread::current().name().unwrap_or("?"), "queue-sync thread done");
})
.expect("Failed to spawn queue-sync thread");
SyncScheduleOutcome::Scheduled
}
}
impl QueueBackend for MusicQueue {
// Primitives
fn len(&self) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.len(),
MusicQueue::OpenHome(q) => q.len(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.len(),
MusicQueueBackend::OpenHome(q) => q.len(),
}
}
fn track_ids(&self) -> Result<Vec<u32>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.track_ids(),
MusicQueue::OpenHome(q) => q.track_ids(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.track_ids(),
MusicQueueBackend::OpenHome(q) => q.track_ids(),
}
}
fn id_to_position(&self, id: u32) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.id_to_position(id),
MusicQueue::OpenHome(q) => q.id_to_position(id),
match &self.backend {
MusicQueueBackend::Internal(q) => q.id_to_position(id),
MusicQueueBackend::OpenHome(q) => q.id_to_position(id),
}
}
fn position_to_id(&self, id: usize) -> Result<u32, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.position_to_id(id),
MusicQueue::OpenHome(q) => q.position_to_id(id),
match &self.backend {
MusicQueueBackend::Internal(q) => q.position_to_id(id),
MusicQueueBackend::OpenHome(q) => q.position_to_id(id),
}
}
fn current_track(&self) -> Result<Option<u32>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.current_track(),
MusicQueue::OpenHome(q) => q.current_track(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.current_track(),
MusicQueueBackend::OpenHome(q) => q.current_track(),
}
}
fn current_index(&self) -> Result<Option<usize>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.current_index(),
MusicQueue::OpenHome(q) => q.current_index(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.current_index(),
MusicQueueBackend::OpenHome(q) => q.current_index(),
}
}
fn queue_snapshot(&self) -> Result<QueueSnapshot, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.queue_snapshot(),
MusicQueue::OpenHome(q) => q.queue_snapshot(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.queue_snapshot(),
MusicQueueBackend::OpenHome(q) => q.queue_snapshot(),
}
}
fn set_index(&mut self, index: Option<usize>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.set_index(index),
MusicQueue::OpenHome(q) => q.set_index(index),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.set_index(index),
MusicQueueBackend::OpenHome(q) => q.set_index(index),
}
}
@@ -89,30 +308,35 @@ impl QueueBackend for MusicQueue {
items: Vec<PlaybackItem>,
current_index: Option<usize>,
) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.replace_queue(items, current_index),
MusicQueue::OpenHome(q) => q.replace_queue(items, current_index),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.replace_queue(items, current_index),
MusicQueueBackend::OpenHome(q) => q.replace_queue(items, current_index),
}
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.sync_queue(items),
MusicQueue::OpenHome(q) => q.sync_queue(items),
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.sync_queue(items, cancel_token, on_ready),
MusicQueueBackend::OpenHome(q) => q.sync_queue(items, cancel_token, on_ready),
}
}
fn get_item(&self, index: usize) -> Result<Option<PlaybackItem>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.get_item(index),
MusicQueue::OpenHome(q) => q.get_item(index),
match &self.backend {
MusicQueueBackend::Internal(q) => q.get_item(index),
MusicQueueBackend::OpenHome(q) => q.get_item(index),
}
}
fn replace_item(&mut self, index: usize, item: PlaybackItem) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.replace_item(index, item),
MusicQueue::OpenHome(q) => q.replace_item(index, item),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.replace_item(index, item),
MusicQueueBackend::OpenHome(q) => q.replace_item(index, item),
}
}
@@ -121,59 +345,59 @@ impl QueueBackend for MusicQueue {
items: Vec<PlaybackItem>,
mode: EnqueueMode,
) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.enqueue_items(items, mode),
MusicQueue::OpenHome(q) => q.enqueue_items(items, mode),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.enqueue_items(items, mode),
MusicQueueBackend::OpenHome(q) => q.enqueue_items(items, mode),
}
}
// Optimized helpers
fn clear_queue(&mut self) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.clear_queue(),
MusicQueue::OpenHome(q) => q.clear_queue(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.clear_queue(),
MusicQueueBackend::OpenHome(q) => q.clear_queue(),
}
}
fn is_empty(&self) -> Result<bool, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.is_empty(),
MusicQueue::OpenHome(q) => q.is_empty(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.is_empty(),
MusicQueueBackend::OpenHome(q) => q.is_empty(),
}
}
fn upcoming_len(&self) -> Result<usize, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.upcoming_len(),
MusicQueue::OpenHome(q) => q.upcoming_len(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.upcoming_len(),
MusicQueueBackend::OpenHome(q) => q.upcoming_len(),
}
}
fn upcoming_items(&self) -> Result<Vec<PlaybackItem>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.upcoming_items(),
MusicQueue::OpenHome(q) => q.upcoming_items(),
match &self.backend {
MusicQueueBackend::Internal(q) => q.upcoming_items(),
MusicQueueBackend::OpenHome(q) => q.upcoming_items(),
}
}
fn peek_current(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.peek_current(),
MusicQueue::OpenHome(q) => q.peek_current(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.peek_current(),
MusicQueueBackend::OpenHome(q) => q.peek_current(),
}
}
fn dequeue_next(&mut self) -> Result<Option<(PlaybackItem, usize)>, ControlPointError> {
match self {
MusicQueue::Internal(q) => q.dequeue_next(),
MusicQueue::OpenHome(q) => q.dequeue_next(),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.dequeue_next(),
MusicQueueBackend::OpenHome(q) => q.dequeue_next(),
}
}
fn append_or_init_index(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
match self {
MusicQueue::Internal(q) => q.append_or_init_index(items),
MusicQueue::OpenHome(q) => q.append_or_init_index(items),
match &mut self.backend {
MusicQueueBackend::Internal(q) => q.append_or_init_index(items),
MusicQueueBackend::OpenHome(q) => q.append_or_init_index(items),
}
}
}

View File

@@ -1,5 +1,5 @@
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::sync::{atomic::AtomicBool, Arc, Mutex};
use std::time::SystemTime;
use std::usize;
@@ -170,6 +170,8 @@ pub struct OpenHomeQueue {
/// Permet de maintenir des métadonnées à jour même si le service OpenHome
/// ne permet pas de les modifier directement.
metadata_cache: Mutex<HashMap<u32, Option<crate::model::TrackMetadata>>>,
/// Cache URI by track ID for fast path matching
uri_by_id: Mutex<HashMap<u32, String>>,
/// Cache for track IDs to avoid redundant IdArray SOAP calls
track_ids_cache: Arc<Mutex<TrackIdsCache>>,
/// Cache for current track ID to avoid redundant Id SOAP calls
@@ -178,6 +180,16 @@ pub struct OpenHomeQueue {
read_list_cache: Arc<Mutex<ReadListCache>>,
}
/// Fast path result for queue sync optimization
enum FastPathResult {
/// New items are an append to the current queue
AppendOnly { new_items: Vec<PlaybackItem> },
/// Items were deleted from the end
DeleteFromEnd { delete_ids: Vec<u32> },
/// No fast path possible - need full LCS sync
NeedFullSync,
}
impl OpenHomeQueue {
pub fn new(
renderer_id: DeviceId,
@@ -191,6 +203,7 @@ impl OpenHomeQueue {
info_client,
product_client,
metadata_cache: Mutex::new(HashMap::new()),
uri_by_id: Mutex::new(HashMap::new()),
track_ids_cache: Arc::new(Mutex::new(TrackIdsCache::new())),
current_track_id_cache: Arc::new(Mutex::new(CurrentTrackIdCache::new())),
read_list_cache: Arc::new(Mutex::new(ReadListCache::new())),
@@ -218,6 +231,130 @@ impl OpenHomeQueue {
}
}
/// Invalide les caches track_ids et read_list (après insert/delete sans impact sur la piste courante).
fn invalidate_track_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
}
/// Invalide tous les caches (après delete_all, seek, stop — opérations qui changent la piste courante).
fn invalidate_all_caches(&self) {
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.uri_by_id.lock().unwrap().clear();
}
/// Tries to detect a simple append-only or delete-from-end pattern without ReadList.
///
/// This is a fast path optimization that avoids the expensive ReadList calls when:
/// 1. New items are an append to existing queue (only inserts needed)
/// 2. Items were deleted from the end (only deletes needed)
///
/// Returns `FastPathResult::NeedFullSync` if the pattern doesn't match or
/// if cache data is insufficient to verify.
fn try_fast_path(&self, new_items: &[PlaybackItem]) -> FastPathResult {
let current_ids = match self.track_ids() {
Ok(ids) => ids,
Err(e) => {
debug!(
renderer = self.renderer_id.0.as_str(),
error = %e,
"try_fast_path: failed to get current IDs, falling back to full sync"
);
return FastPathResult::NeedFullSync;
}
};
let current_len = current_ids.len();
let new_len = new_items.len();
if new_len > current_len {
let prefix_matches = self.check_prefix_matches(&current_ids, &new_items[..current_len]);
if prefix_matches {
debug!(
renderer = self.renderer_id.0.as_str(),
current_len, new_len, "try_fast_path: detected append-only pattern"
);
return FastPathResult::AppendOnly {
new_items: new_items[current_len..].to_vec(),
};
}
} else if new_len < current_len {
let prefix_matches = self.check_prefix_matches(&current_ids[..new_len], new_items);
if prefix_matches {
let delete_ids: Vec<u32> = current_ids[new_len..].to_vec();
debug!(
renderer = self.renderer_id.0.as_str(),
current_len,
new_len,
delete_count = delete_ids.len(),
"try_fast_path: detected delete-from-end pattern"
);
return FastPathResult::DeleteFromEnd { delete_ids };
}
}
debug!(
renderer = self.renderer_id.0.as_str(),
current_len, new_len, "try_fast_path: no fast path detected, falling back to full sync"
);
FastPathResult::NeedFullSync
}
/// Checks if the prefix of the new items matches the current queue items.
/// Uses local metadata cache or falls back to URI comparison.
fn check_prefix_matches(&self, current_ids: &[u32], new_items: &[PlaybackItem]) -> bool {
if current_ids.len() != new_items.len() {
return false;
}
let metadata_cache = match self.metadata_cache.lock() {
Ok(cache) => cache,
Err(_) => return false,
};
let uri_cache = match self.uri_by_id.lock() {
Ok(cache) => cache,
Err(_) => return false,
};
for (idx, new_item) in new_items.iter().enumerate() {
let current_id = current_ids[idx];
// First check if we have cached metadata for this track_id that matches
if let Some(cached_metadata) = metadata_cache.get(&current_id) {
if let Some(cached) = cached_metadata {
// Compare using title + artist as identity (for streams)
if let Some(ref new_metadata) = new_item.metadata {
let same_title = cached.title.as_ref() == new_metadata.title.as_ref();
let same_artist = cached.artist.as_ref() == new_metadata.artist.as_ref();
if same_title && same_artist {
continue;
}
}
}
}
// No metadata match - fall back to URI comparison using uri_by_id cache
if new_item.uri.is_empty() {
return false;
}
// Compare against cached URI for this track_id
if let Some(cached_uri) = uri_cache.get(&current_id) {
if cached_uri == &new_item.uri {
continue;
}
}
// No match found - fall back to full sync
return false;
}
true
}
/// Met à jour les métadonnées d'un item de la queue à l'index spécifié.
///
/// Contrairement au service OpenHome qui ne permet pas de modifier les métadonnées,
@@ -237,7 +374,7 @@ impl OpenHomeQueue {
metadata: Option<crate::model::TrackMetadata>,
) -> Result<(), ControlPointError> {
let track_id = self.position_to_id(index)?;
self.cache_metadata(track_id, metadata);
self.cache_metadata(track_id, metadata, "");
Ok(())
}
@@ -247,8 +384,19 @@ impl OpenHomeQueue {
/// chanson donc toute durée est acceptée.
/// Pour les fichiers normaux (non-streams), les métadonnées sont acceptées telles quelles.
/// Cette fonction est le SEUL point d'entrée pour modifier le cache.
fn cache_metadata(&self, track_id: u32, new_metadata: Option<crate::model::TrackMetadata>) {
fn cache_metadata(
&self,
track_id: u32,
new_metadata: Option<crate::model::TrackMetadata>,
uri: &str,
) {
let mut cache = self.metadata_cache.lock().unwrap();
let mut uri_cache = self.uri_by_id.lock().unwrap();
// Update URI cache
if !uri.is_empty() {
uri_cache.insert(track_id, uri.to_string());
}
// Vérifier s'il y a déjà des métadonnées en cache
if let Some(cached_meta) = cache.get(&track_id) {
@@ -274,45 +422,23 @@ impl OpenHomeQueue {
new_metadata.as_ref().and_then(|m| m.duration.as_ref()),
) {
(Some(cached_dur), Some(new_dur)) => {
// Parser les durées (format HH:MM:SS)
let parse_duration = |dur: &str| -> Option<u32> {
let parts: Vec<&str> = dur.split(':').collect();
if parts.len() == 3 {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
let s: u32 = parts[2].parse().ok()?;
Some(h * 3600 + m * 60 + s)
} else {
None
}
};
if let (Some(cached_secs), Some(new_secs)) =
(parse_duration(cached_dur), parse_duration(new_dur))
{
if new_secs < cached_secs {
// Durée a diminué pour la même chanson: refuser
tracing::trace!(
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
if super::stream_duration_decreased(cached_dur, new_dur) {
tracing::trace!(
"OpenHome cache_metadata: track_id={}, REJECTING update (same track, duration decreased): {} -> {}",
track_id,
cached_dur,
new_dur
);
false
} else {
if super::stream_duration_increased(cached_dur, new_dur) {
tracing::debug!(
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
track_id,
cached_dur,
new_dur
);
false
} else {
// Durée a augmenté ou est égale: accepter
if new_secs > cached_secs {
tracing::debug!(
"OpenHome cache_metadata: track_id={}, same track, duration increased: {} -> {}",
track_id,
cached_dur,
new_dur
);
}
true
}
} else {
// Impossible de parser: accepter par défaut
true
}
}
@@ -375,7 +501,7 @@ impl OpenHomeQueue {
);
drop(cache); // Libérer le lock avant d'appeler cache_metadata
// Mettre en cache pour éviter les oscillations sur les flux radio
self.cache_metadata(entry.id, fresh.clone());
self.cache_metadata(entry.id, fresh.clone(), entry.uri());
fresh
}
};
@@ -406,7 +532,7 @@ impl OpenHomeQueue {
.insert(after_id, &item.uri, &metadata_xml)?;
// Enregistrer les métadonnées dans le cache
self.cache_metadata(new_id, item.metadata);
self.cache_metadata(new_id, item.metadata, &item.uri);
Ok(new_id)
}
@@ -418,32 +544,44 @@ impl OpenHomeQueue {
&mut self,
new_items: Vec<PlaybackItem>,
playing_id: usize,
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
// Get current track IDs from OpenHome
use std::sync::atomic::Ordering::SeqCst;
let current_track_ids = self.track_ids()?;
// Delete everything except the currently playing item
// Using delete_id_if_exists() to handle cases where another control point
// may have already modified the playlist
for &track_id in current_track_ids.iter().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if track_id as usize != playing_id {
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
}
}
// Insert new items after the currently playing track
let mut previous_id = playing_id as u32;
let mut first_insert_done = false;
for item in new_items {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let metadata = build_metadata_xml(&item);
let new_id = self
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées dans le cache
self.cache_metadata(new_id, item.metadata);
self.cache_metadata(new_id, item.metadata, &item.uri);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
debug!(
@@ -451,9 +589,7 @@ impl OpenHomeQueue {
"Gentle sync completed: preserved playing track as first item (not in new playlist)"
);
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
@@ -464,8 +600,13 @@ impl OpenHomeQueue {
old_ids: &[u32],
keep_flags: &[bool],
position_label: &str,
cancel_token: &Arc<AtomicBool>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
for (idx, &track_id) in old_ids.iter().enumerate().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if !keep_flags[idx] {
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -490,8 +631,11 @@ impl OpenHomeQueue {
keep_old_flags: &[bool],
mut previous_id: u32,
position_label: &str,
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<u32, ControlPointError> {
// Collect IDs of kept items (in order)
use std::sync::atomic::Ordering::SeqCst;
let remaining_ids: Vec<u32> = old_ids
.iter()
.enumerate()
@@ -499,16 +643,18 @@ impl OpenHomeQueue {
.collect();
let mut remaining_idx = 0;
let mut first_insert_done = false;
// Rebuild section
for (idx, item) in new_items.iter().enumerate() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if keep_new_flags[idx] {
let existing_id = remaining_ids[remaining_idx];
remaining_idx += 1;
previous_id = existing_id;
// Mettre à jour les métadonnées de l'item existant conservé
self.cache_metadata(existing_id, item.metadata.clone());
self.cache_metadata(existing_id, item.metadata.clone(), &item.uri);
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -523,8 +669,7 @@ impl OpenHomeQueue {
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées du nouvel item
self.cache_metadata(new_id, item.metadata.clone());
self.cache_metadata(new_id, item.metadata.clone(), &item.uri);
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -536,6 +681,13 @@ impl OpenHomeQueue {
new_id
);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
}
@@ -550,11 +702,11 @@ impl OpenHomeQueue {
new_items: Vec<PlaybackItem>,
pivot_idx_new: usize,
pivot_id: usize,
snapshot: &QueueSnapshot,
current_track_ids: &[u32],
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
// Get current state from OpenHome
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
// Find the pivot index in our current state
let pivot_idx = current_track_ids
.iter()
@@ -576,16 +728,21 @@ impl OpenHomeQueue {
let new_after = &new_items[pivot_idx_new + 1..];
// LCS on the AFTER part (using fresh data from OpenHome)
let (keep_old_after, keep_new_after) = lcs_flags(&old_after, new_after);
let (keep_old_after, keep_new_after) = lcs_flags_optimized(&old_after, new_after);
// LCS on the BEFORE part (using fresh data from OpenHome)
let (keep_old_before, keep_new_before) = lcs_flags(&old_before, new_before);
let (keep_old_before, keep_new_before) = lcs_flags_optimized(&old_before, new_before);
// Delete items marked for deletion in AFTER part (reverse order)
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot")?;
self.delete_marked_items(&old_ids_after, &keep_old_after, "AFTER pivot", cancel_token)?;
// Delete items marked for deletion in BEFORE part (reverse order)
self.delete_marked_items(&old_ids_before, &keep_old_before, "BEFORE pivot")?;
self.delete_marked_items(
&old_ids_before,
&keep_old_before,
"BEFORE pivot",
cancel_token,
)?;
// Rebuild the playlist: [BEFORE, PIVOT, AFTER]
// Rebuild BEFORE part (we don't need the returned previous_id)
@@ -596,13 +753,19 @@ impl OpenHomeQueue {
&keep_old_before,
OPENHOME_PLAYLIST_HEAD_ID,
"BEFORE pivot",
cancel_token,
on_ready,
)?;
// PIVOT keeps its ID and position - it's the anchor point
let previous_id = pivot_id as u32;
// Mettre à jour les métadonnées du pivot
self.cache_metadata(previous_id, new_items[pivot_idx_new].metadata.clone());
self.cache_metadata(
previous_id,
new_items[pivot_idx_new].metadata.clone(),
&new_items[pivot_idx_new].uri,
);
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -621,6 +784,8 @@ impl OpenHomeQueue {
&keep_old_after,
previous_id,
"AFTER pivot",
cancel_token,
on_ready,
)?;
debug!(
@@ -631,8 +796,7 @@ impl OpenHomeQueue {
);
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
@@ -641,11 +805,12 @@ impl OpenHomeQueue {
fn replace_queue_standard_lcs(
&mut self,
items: Vec<PlaybackItem>,
_current_index: Option<usize>,
snapshot: &QueueSnapshot,
current_track_ids: &[u32],
cancel_token: &Arc<AtomicBool>,
on_ready: &mut Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
// Get current state from OpenHome
let snapshot = self.queue_snapshot()?;
let current_track_ids = self.track_ids()?;
use std::sync::atomic::Ordering::SeqCst;
debug!(
renderer = self.renderer_id.0.as_str(),
@@ -658,7 +823,7 @@ impl OpenHomeQueue {
"LCS input: current vs desired items"
);
let (keep_current, keep_desired) = lcs_flags(&snapshot.items, &items);
let (keep_current, keep_desired) = lcs_flags_optimized(&snapshot.items, &items);
let items_to_keep = keep_current.iter().filter(|&&k| k).count();
let items_to_delete = keep_current.iter().filter(|&k| !k).count();
@@ -672,32 +837,22 @@ impl OpenHomeQueue {
"LCS computed: minimizing OpenHome playlist operations"
);
// Get current playing track ID BEFORE any modifications
let current_track_id = self.playlist_client.id().ok().filter(|&id| id != 0);
// Check if the currently playing track is in the new playlist
// If so, we should NOT use delete_all() - we must preserve it
let current_track_in_new_playlist = current_track_id.and_then(|current_id| {
items
.iter()
.position(|item| item.backend_id as u32 == current_id)
});
// If we're replacing everything (keep=0), use delete_all() BUT only if
// there's no currently playing track, OR if the current track is not in the new playlist.
// If current track IS in new playlist, we must preserve it using insert/delete operations.
if items_to_keep == 0 && items_to_delete > 0 {
if current_track_in_new_playlist.is_some() {
// Current track is in new playlist - use insert/delete instead of delete_all
// to preserve playback
debug!(
renderer = self.renderer_id.0.as_str(),
current_track_in_playlist = true,
"Preserving currently playing track - using insert/delete instead of delete_all"
);
// Fall through to selective deletion below
} else {
// No current track or not in new playlist - safe to use delete_all
debug!(
renderer = self.renderer_id.0.as_str(),
"Using delete_all() for complete replacement (safe - no current track or not in new playlist)"
@@ -706,19 +861,18 @@ impl OpenHomeQueue {
self.metadata_cache.lock().unwrap().clear();
}
} else {
// Selective deletion when keeping some items
for idx in (0..current_track_ids.len()).rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if !keep_current[idx] {
let track_id = current_track_ids[idx];
// Use delete_id_if_exists() to handle cases where another control point
// may have already modified the playlist
self.playlist_client.delete_id_if_exists(track_id)?;
self.metadata_cache.lock().unwrap().remove(&track_id);
}
}
}
// Rebuild by inserting new items
let remaining_ids: Vec<u32> = current_track_ids
.iter()
.enumerate()
@@ -733,8 +887,12 @@ impl OpenHomeQueue {
let mut remaining_idx = 0usize;
let mut previous_id = OPENHOME_PLAYLIST_HEAD_ID;
let mut first_insert_done = false;
for (idx, item) in items.into_iter().enumerate() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
if keep_desired[idx] {
if remaining_idx >= remaining_ids.len() {
return Err(ControlPointError::OpenHomeError(format!(
@@ -745,19 +903,23 @@ impl OpenHomeQueue {
remaining_idx += 1;
previous_id = existing_id;
// Mettre à jour les métadonnées de l'item existant conservé
// La fonction cache_metadata gère la protection contre la diminution de durée
self.cache_metadata(existing_id, item.metadata);
self.cache_metadata(existing_id, item.metadata, &item.uri);
} else {
let metadata = build_metadata_xml(&item);
let new_id = self
.playlist_client
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées du nouvel item
self.cache_metadata(new_id, item.metadata);
self.cache_metadata(new_id, item.metadata, &item.uri);
previous_id = new_id;
if !first_insert_done && on_ready.is_some() {
first_insert_done = true;
if let Some(f) = on_ready.take() {
f();
}
}
}
}
@@ -767,9 +929,7 @@ impl OpenHomeQueue {
)));
}
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
@@ -883,6 +1043,52 @@ fn lcs_flags(current: &[PlaybackItem], desired: &[PlaybackItem]) -> (Vec<bool>,
(keep_current, keep_desired)
}
fn lcs_flags_optimized(
current: &[PlaybackItem],
desired: &[PlaybackItem],
) -> (Vec<bool>, Vec<bool>) {
if current.is_empty() {
return (vec![], vec![true; desired.len()]);
}
if desired.is_empty() {
return (vec![true; current.len()], vec![]);
}
let leading = current
.iter()
.zip(desired.iter())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_tail = &current[leading..];
let d_tail = &desired[leading..];
let trailing = c_tail
.iter()
.rev()
.zip(d_tail.iter().rev())
.take_while(|(c, d)| items_match(c, d))
.count();
let c_mid = &c_tail[..c_tail.len().saturating_sub(trailing)];
let d_mid = &d_tail[..d_tail.len().saturating_sub(trailing)];
if c_mid.is_empty() && d_mid.is_empty() {
return (vec![true; current.len()], vec![true; desired.len()]);
}
let (keep_c_mid, keep_d_mid) = lcs_flags(c_mid, d_mid);
let mut keep_current = vec![true; leading];
keep_current.extend(keep_c_mid);
keep_current.extend(vec![true; trailing]);
let mut keep_desired = vec![true; leading];
keep_desired.extend(keep_d_mid);
keep_desired.extend(vec![true; trailing]);
(keep_current, keep_desired)
}
impl QueueBackend for OpenHomeQueue {
fn len(&self) -> Result<usize, ControlPointError> {
Ok(self.track_ids()?.len())
@@ -983,7 +1189,7 @@ impl QueueBackend for OpenHomeQueue {
// Read metadata for all tracks (batched), with 500ms cache to avoid
// redundant SOAP calls during sync_queue (which calls queue_snapshot twice).
const MAX_BATCH: usize = 64;
const MAX_BATCH: usize = 256;
let mut entries = Vec::with_capacity(ids.len());
for chunk in ids.chunks(MAX_BATCH) {
if let Some(cached) = self.read_list_cache.lock().unwrap().get(chunk) {
@@ -1056,9 +1262,7 @@ impl QueueBackend for OpenHomeQueue {
self.playlist_client.stop()?;
}
// Invalidate caches (seek_id/stop modifies playlist state and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.invalidate_all_caches();
Ok(())
}
@@ -1082,9 +1286,7 @@ impl QueueBackend for OpenHomeQueue {
self.metadata_cache.lock().unwrap().clear();
// Invalidate caches after delete_all (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.invalidate_all_caches();
if items.is_empty() {
return Ok(());
@@ -1099,22 +1301,31 @@ impl QueueBackend for OpenHomeQueue {
.insert(previous_id, &item.uri, &metadata)?;
// Enregistrer les métadonnées dans le cache
self.cache_metadata(new_id, item.metadata);
self.cache_metadata(new_id, item.metadata, &item.uri);
previous_id = new_id;
}
// Invalidate cache after insertions
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
fn sync_queue(&mut self, items: Vec<PlaybackItem>) -> Result<(), ControlPointError> {
fn sync_queue(
&mut self,
items: Vec<PlaybackItem>,
cancel_token: &Arc<AtomicBool>,
mut on_ready: Option<Box<dyn FnOnce() + Send>>,
) -> Result<(), ControlPointError> {
use std::sync::atomic::Ordering::SeqCst;
self.ensure_playlist_source_selected()?;
// DIAGNOSTIC: Log current track state before any modifications
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let pre_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1130,12 +1341,8 @@ impl QueueBackend for OpenHomeQueue {
);
self.playlist_client.delete_all()?;
self.metadata_cache.lock().unwrap().clear();
// Invalidate caches after delete_all (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.invalidate_all_caches();
// DIAGNOSTIC: Log state after delete_all
let post_current_track = self.playlist_client.id().ok();
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1145,9 +1352,58 @@ impl QueueBackend for OpenHomeQueue {
return Ok(());
}
// Synchronize local state with the actual OpenHome playlist before computing
// differences. Without this, any drift between our cache and the renderer
// (e.g., manual edits from another control point) would keep the stale items.
match self.try_fast_path(&items) {
FastPathResult::AppendOnly { new_items } => {
debug!(
renderer = self.renderer_id.0.as_str(),
new_items_count = new_items.len(),
"sync_queue: fast path - append-only detected"
);
let current_ids = self.track_ids()?;
let mut after_id = current_ids
.last()
.copied()
.unwrap_or(OPENHOME_PLAYLIST_HEAD_ID);
let mut uri_cache = self.uri_by_id.lock().unwrap();
for item in &new_items {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
let metadata = build_metadata_xml(item);
let uri = item.uri.as_str();
let metadata_xml = metadata.as_str();
after_id = self.playlist_client.insert(after_id, uri, metadata_xml)?;
if !item.uri.is_empty() {
uri_cache.insert(after_id, item.uri.clone());
}
}
drop(uri_cache);
self.invalidate_track_caches();
return Ok(());
}
FastPathResult::DeleteFromEnd { delete_ids } => {
debug!(
renderer = self.renderer_id.0.as_str(),
delete_count = delete_ids.len(),
"sync_queue: fast path - delete-from-end detected"
);
for id in delete_ids.iter().rev() {
if cancel_token.load(SeqCst) {
return Err(ControlPointError::SyncCancelled);
}
self.playlist_client.delete_id(*id)?;
}
self.invalidate_all_caches();
return Ok(());
}
FastPathResult::NeedFullSync => {
debug!(
renderer = self.renderer_id.0.as_str(),
"sync_queue: fast path not applicable, proceeding with full sync"
);
}
}
let snapshot = self.queue_snapshot()?;
debug!(
@@ -1158,9 +1414,6 @@ impl QueueBackend for OpenHomeQueue {
"sync_queue: snapshot vs new items comparison"
);
// Note: current_index may point to an index that doesn't exist in items
// if the OpenHome renderer is in an inconsistent state (e.g., IdArray returns
// IDs but ReadList returns empty TrackList). We must bounds-check here.
let playing_info = snapshot.current_index.and_then(|idx| {
if idx < snapshot.items.len() {
Some((
@@ -1187,8 +1440,14 @@ impl QueueBackend for OpenHomeQueue {
"OpenHome playlist state"
);
let has_pivot = playing_info.is_some();
if has_pivot {
if let Some(f) = on_ready.take() {
f();
}
}
if let Some((playing_idx, playing_id, playing_uri, playing_didl_id)) = playing_info {
// Find if the currently playing item is in the new playlist (by URI first, then by didl_id)
let new_playing_idx = items
.iter()
.position(|item| item.uri == playing_uri)
@@ -1208,8 +1467,6 @@ impl QueueBackend for OpenHomeQueue {
);
if let Some(pivot_idx) = new_playing_idx {
// CASE 2: Currently playing item IS in the new playlist
// Use gentle double-LCS strategy: preserve the pivot and sync before/after separately
debug!(
renderer = self.renderer_id.0.as_str(),
playing_idx,
@@ -1218,22 +1475,32 @@ impl QueueBackend for OpenHomeQueue {
pivot_idx
);
self.replace_queue_with_pivot(items, pivot_idx, playing_id)?;
let current_ids_for_pivot: Vec<u32> =
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
self.replace_queue_with_pivot(
items,
pivot_idx,
playing_id,
&snapshot,
&current_ids_for_pivot,
cancel_token,
&mut on_ready,
)?;
} else {
// CASE 1: Currently playing item NOT in the new playlist
// Keep it as first item and append the new playlist after it
debug!(
renderer = self.renderer_id.0.as_str(),
playing_idx,
"Gentle sync: currently playing item not in new playlist, preserving as first item"
);
self.replace_queue_preserve_current(items, playing_id)?;
self.replace_queue_preserve_current(
items,
playing_id,
cancel_token,
&mut on_ready,
)?;
}
} else {
// No currently playing item or can't determine it - use standard LCS
// BUT first check if this is because the OpenHome device returned empty playlist
// This could cause the queue to be cleared incorrectly
if snapshot.items.is_empty() && !items.is_empty() {
tracing::warn!(
renderer = self.renderer_id.0.as_str(),
@@ -1241,8 +1508,6 @@ impl QueueBackend for OpenHomeQueue {
new_items = items.len(),
"OpenHome playlist appears empty - possible stale cache or device issue, NOT clearing queue"
);
// Don't call replace_queue_standard_lcs with empty snapshot - it would clear our queue
// Instead, just add the new items without deleting existing ones
return self.enqueue_items(items, crate::queue::EnqueueMode::AppendToEnd);
}
@@ -1250,10 +1515,17 @@ impl QueueBackend for OpenHomeQueue {
renderer = self.renderer_id.0.as_str(),
"No currently playing item, using standard LCS sync"
);
self.replace_queue_standard_lcs(items, Some(0))?;
let current_ids_for_lcs: Vec<u32> =
snapshot.items.iter().map(|i| i.backend_id as u32).collect();
self.replace_queue_standard_lcs(
items,
&snapshot,
&current_ids_for_lcs,
cancel_token,
&mut on_ready,
)?;
}
// DIAGNOSTIC: Log state after sync completes
let post_current_track = self.playlist_client.id().ok();
let post_ids = self.track_ids();
tracing::warn!(
@@ -1312,15 +1584,15 @@ impl QueueBackend for OpenHomeQueue {
// Mettre à jour le cache avec les nouvelles métadonnées
self.metadata_cache.lock().unwrap().remove(&track_id);
self.cache_metadata(new_id, item.metadata);
self.uri_by_id.lock().unwrap().remove(&track_id);
self.cache_metadata(new_id, item.metadata, &item.uri);
if ci == Some(index) {
self.playlist_client.seek_id(new_id)?;
}
// Invalidate cache after playlist modifications
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
@@ -1365,8 +1637,7 @@ impl QueueBackend for OpenHomeQueue {
}
// Invalidate cache after playlist modifications (except ReplaceAll which already does it)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.invalidate_track_caches();
Ok(())
}
@@ -1378,9 +1649,7 @@ impl QueueBackend for OpenHomeQueue {
self.ensure_playlist_source_selected()?;
self.playlist_client.delete_all()?;
// Invalidate caches after clearing playlist (clears queue and current track)
self.track_ids_cache.lock().unwrap().invalidate();
self.read_list_cache.lock().unwrap().invalidate();
self.current_track_id_cache.lock().unwrap().invalidate();
self.invalidate_all_caches();
Ok(())
}
@@ -1475,6 +1744,6 @@ impl QueueFromRendererInfo for OpenHomeQueue {
}
fn to_backend(self) -> MusicQueue {
MusicQueue::OpenHome(self)
MusicQueue::from_openhome(self)
}
}

View File

@@ -1,4 +1,4 @@
use crate::{DeviceId, model::TrackMetadata};
use crate::{model::TrackMetadata, DeviceId};
/// Canonical representation of a track in a renderer queue.
///

View File

@@ -136,6 +136,14 @@ impl DeviceRegistry {
self.devices.get(id)?.as_music_renderer().ok()
}
pub fn get_renderer_queue_arc(
&self,
id: &DeviceId,
) -> Option<std::sync::Arc<std::sync::Mutex<crate::queue::MusicQueue>>> {
let renderer = self.get_renderer(id)?;
Some(renderer.queue())
}
pub fn get_server(&self, id: &DeviceId) -> Option<Arc<MusicServer>> {
self.devices.get(id)?.as_music_server().ok()
}

View File

@@ -1,12 +1,30 @@
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::Duration;
use anyhow::{Context, Result};
use pmoupnp::soap::{SoapEnvelope, build_soap_request, parse_soap_envelope};
use pmoupnp::soap::{build_soap_request, parse_soap_envelope, SoapEnvelope};
use tracing::{debug, trace, warn};
use ureq::Agent;
use crate::errors::ControlPointError;
static SOAP_AGENT: OnceLock<Arc<Agent>> = OnceLock::new();
fn get_soap_agent() -> Arc<Agent> {
SOAP_AGENT
.get_or_init(|| {
Arc::new(
Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(Duration::from_secs(30)))
.build()
.into(),
)
})
.clone()
}
/// Result of a SOAP call:
/// - HTTP status code
/// - raw XML body (always)
@@ -62,21 +80,20 @@ pub fn invoke_upnp_action_with_timeout(
trace!(body = body_xml.as_str(), "SOAP request body");
let mut builder = Agent::config_builder();
builder = builder.http_status_as_error(false);
if let Some(duration) = timeout {
builder = builder.timeout_global(Some(duration));
}
let request = if let Some(duration) = timeout {
let config = Agent::config_builder()
.http_status_as_error(false)
.timeout_global(Some(duration))
.build();
let agent: Agent = config.into();
agent.post(control_url)
} else {
get_soap_agent().post(control_url)
};
let config = builder.build();
let agent: Agent = config.into();
// 3. SOAPAction header
let soap_action_header = format!(r#""{}#{}""#, service_type, action);
// 4. HTTP POST
let mut response = agent
.post(control_url)
let mut response = request
.header("Content-Type", r#"text/xml; charset="utf-8""#)
.header("SOAPAction", &soap_action_header)
.send(body_xml)

Some files were not shown because too many files have changed in this diff Show More