1212import base64
1313import logging
1414import threading
15+ import time
1516from collections .abc import Callable
16- from dataclasses import asdict , dataclass
17+ from dataclasses import asdict , dataclass , replace
1718from typing import Any
1819
1920from .config import ConfigStore , MediaFilter
@@ -114,10 +115,17 @@ def _seconds(value: Any) -> int:
114115
115116
116117class MediaWatcher :
117- """Interroge Windows en boucle et publie la piste courante .
118+ """Publie la piste courante à partir des évènements WinRT de la session média .
118119
119- Le thread est démarré/arrêté avec le serveur. ``current`` reste lisible à
120- tout moment depuis n'importe quel thread.
120+ Le thread est démarré/arrêté avec le serveur et fait tourner une boucle
121+ asyncio dédiée. Au lieu de sonder Windows en continu, on s'abonne aux
122+ évènements de la session (``MediaPropertiesChanged``, etc.) : le coût
123+ WinRT/NPSMSvc n'est payé que quand quelque chose change réellement, plus
124+ jamais à un rythme fixe. ``refresh_interval`` ne sert plus que de filet de
125+ sécurité (certains lecteurs ne déclenchent pas ces évènements de façon
126+ fiable) et de base au recalcul de la position pendant la lecture.
127+
128+ ``current`` reste lisible à tout moment depuis n'importe quel thread.
121129 """
122130
123131 def __init__ (self , config : ConfigStore , on_error : Callable [[Exception ], None ] | None = None ):
@@ -130,13 +138,36 @@ def __init__(self, config: ConfigStore, on_error: Callable[[Exception], None] |
130138 self ._thread : threading .Thread | None = None
131139 self ._last_error : str | None = None
132140
141+ # Etat interne au thread media-watcher uniquement (pas de verrou requis).
142+ self ._loop : asyncio .AbstractEventLoop | None = None
143+ self ._manager : Any = None
144+ self ._session : Any = None
145+ self ._session_tokens : list [Any ] = []
146+ self ._session_changed_token : Any = None
147+ self ._background_tasks : set [asyncio .Task [None ]] = set ()
148+
149+ # Anchre pour interpoler la position de lecture sans repoller WinRT :
150+ # position = _position_anchor + ecoule_depuis_anchor * _playback_rate.
151+ self ._position_anchor = 0
152+ self ._position_anchor_time = 0.0
153+ self ._playback_rate = 0.0
154+ self ._duration = 0
155+
133156 # ------------------------------------------------------------------
134157 # État
135158 # ------------------------------------------------------------------
136159 @property
137160 def current (self ) -> Track :
138161 with self ._lock :
139- return self ._track
162+ track = self ._track
163+ if self ._playback_rate == 0.0 :
164+ return track
165+ elapsed = time .monotonic () - self ._position_anchor_time
166+ position = int (self ._position_anchor + elapsed * self ._playback_rate )
167+ position = max (0 , min (self ._duration , position ))
168+ if position == track .position :
169+ return track
170+ return replace (track , position = position )
140171
141172 @property
142173 def running (self ) -> bool :
@@ -162,12 +193,16 @@ def start(self) -> None:
162193 def stop (self , timeout : float = 3.0 ) -> None :
163194 """Demande l'arrêt du thread et attend sa fin."""
164195 self ._stop_event .set ()
196+ loop = self ._loop
197+ if loop is not None and loop .is_running ():
198+ loop .call_soon_threadsafe (loop .stop )
165199 thread = self ._thread
166200 if thread is not None and thread .is_alive ():
167201 thread .join (timeout = timeout )
168202 self ._thread = None
169203 with self ._lock :
170204 self ._track = NO_TRACK
205+ self ._playback_rate = 0.0
171206 logger .info ("Surveillance media arretee" )
172207
173208 # ------------------------------------------------------------------
@@ -183,63 +218,211 @@ def _run(self) -> None:
183218
184219 loop = asyncio .new_event_loop ()
185220 asyncio .set_event_loop (loop )
221+ self ._loop = loop
186222 try :
187223 while not self ._stop_event .is_set ():
188224 try :
189- track = loop .run_until_complete (self ._poll_once ())
190- with self ._lock :
191- self ._track = track or NO_TRACK
192- self ._last_error = None
225+ loop .run_until_complete (self ._setup ())
226+ loop .run_forever ()
193227 except Exception as exc :
194228 with self ._lock :
195229 self ._last_error = str (exc )
196- logger .debug ("Lecture media echouee : %s" , exc )
230+ logger .debug ("Surveillance media interrompue : %s" , exc )
197231 if self ._on_error is not None :
198232 self ._on_error (exc )
199-
200- self ._stop_event .wait (self ._config .settings .refresh_interval )
233+ self ._detach_session ()
234+ # `_setup()` a echoue avant tout abonnement (WinRT
235+ # temporairement indisponible) : on retente au lieu de
236+ # laisser le thread mourir definitivement.
237+ self ._stop_event .wait (self ._config .settings .refresh_interval )
201238 finally :
239+ self ._detach_session ()
240+ if self ._manager is not None and self ._session_changed_token is not None :
241+ try :
242+ self ._manager .remove_current_session_changed (self ._session_changed_token )
243+ except Exception as exc :
244+ logger .debug ("Desabonnement manager echoue : %s" , exc )
245+ self ._manager = None
246+ self ._session_changed_token = None
247+ self ._loop = None
202248 loop .close ()
203249
204- async def _poll_once (self ) -> Track | None :
205- manager = await MediaManager .request_async ()
206- session = manager .get_current_session ()
207- if session is None :
208- return None
250+ async def _setup (self ) -> None :
251+ self ._manager = await MediaManager .request_async ()
252+ self ._session_changed_token = self ._manager .add_current_session_changed (
253+ self ._on_current_session_changed
254+ )
255+ await self ._attach_session (self ._manager .get_current_session ())
256+ self ._schedule_safety_tick ()
257+
258+ def _spawn (self , coro : Any ) -> None :
259+ """Lance une coroutine en tâche de fond sans perdre sa référence.
260+
261+ ``asyncio`` ne garantit pas qu'une tâche créée sans réference reste en
262+ vie jusqu'à son terme (RUF006) : on la garde dans un set le temps
263+ qu'elle s'exécute.
264+ """
265+ task = self ._loop .create_task (coro )
266+ self ._background_tasks .add (task )
267+ task .add_done_callback (self ._background_tasks .discard )
268+
269+ # Multiplicateur appliqué a ``refresh_interval`` pour l'intervalle du
270+ # filet de securite. Les evenements WinRT font le travail reactif ; ce
271+ # tick ne sert qu'a rattraper un evenement manque ou un lecteur qui n'en
272+ # emet pas. Le decoupler ainsi de ``refresh_interval`` evite de retomber
273+ # dans un sondage continu au meme rythme que l'ancienne boucle.
274+ _SAFETY_TICK_FACTOR = 5.0
275+ _SAFETY_TICK_MIN = 2.0
276+
277+ def _schedule_safety_tick (self ) -> None :
278+ if self ._stop_event .is_set () or self ._loop is None :
279+ return
280+ delay = max (
281+ self ._SAFETY_TICK_MIN , self ._config .settings .refresh_interval * self ._SAFETY_TICK_FACTOR
282+ )
283+ self ._loop .call_later (delay , self ._safety_tick )
209284
210- app_id = session . source_app_user_model_id or ""
211- media_filter : MediaFilter = self . _config . media_filter
212- if not media_filter . allows ( app_id ) :
213- logger . debug ( "Application filtree : %s" , app_id )
214- return None
215-
216- properties = await session . try_get_media_properties_async ()
217- playback = session . get_playback_info ()
218- timeline = session . get_timeline_properties ()
219-
220- title = getattr ( properties , "title" , "" ) or "Unknown Title"
221- artist = getattr ( properties , "artist" , "" ) or "Unknown Artist"
222- album = getattr ( properties , "album_title" , "" ) or ""
223- key = ( title , artist , album )
224-
225- cached_key , cached_thumbnail = self ._thumbnail_cache
226- if cached_key == key :
227- thumbnail = cached_thumbnail
285+ def _safety_tick ( self ) -> None :
286+ """Filet de sécurité : rattrape un évènement manqué ou un lecteur muet."""
287+ if self . _stop_event . is_set () or self . _loop is None :
288+ return
289+ self . _spawn ( self . _recheck_current_session ())
290+ self . _schedule_safety_tick ()
291+
292+ async def _recheck_current_session ( self ) -> None :
293+ try :
294+ current = self . _manager . get_current_session ()
295+ except Exception as exc :
296+ logger . debug ( "Relecture de la session courante echouee : %s" , exc )
297+ return
298+
299+ current_app_id = getattr ( current , "source_app_user_model_id" , None )
300+ attached_app_id = getattr ( self ._session , "source_app_user_model_id" , None )
301+ if current_app_id != attached_app_id :
302+ await self . _attach_session ( current )
228303 else :
229- thumbnail = await _read_thumbnail (properties )
230- self ._thumbnail_cache = (key , thumbnail )
231-
232- return Track (
233- title = title ,
234- artist = artist ,
235- album = album ,
236- thumbnail = thumbnail ,
237- is_playing = getattr (playback , "playback_status" , 0 ) == PLAYBACK_STATUS_PLAYING ,
238- position = _seconds (getattr (timeline , "position" , None )),
239- duration = _seconds (getattr (timeline , "end_time" , None )),
240- source_app = app_id ,
304+ await self ._refresh_current_session ()
305+
306+ # ------------------------------------------------------------------
307+ # Session courante : abonnement et lecture
308+ # ------------------------------------------------------------------
309+ def _on_current_session_changed (self , manager : Any , args : Any ) -> None :
310+ loop = self ._loop
311+ if loop is None :
312+ return
313+ loop .call_soon_threadsafe (
314+ lambda : self ._spawn (self ._attach_session (manager .get_current_session ()))
241315 )
242316
317+ def _on_session_event (self , sender : Any , args : Any ) -> None :
318+ loop = self ._loop
319+ if loop is None :
320+ return
321+ loop .call_soon_threadsafe (lambda : self ._spawn (self ._refresh_current_session ()))
322+
323+ def _detach_session (self ) -> None :
324+ session = self ._session
325+ if session is not None :
326+ removers = (
327+ (session .remove_media_properties_changed , self ._session_tokens [0 ])
328+ if len (self ._session_tokens ) > 0
329+ else None ,
330+ (session .remove_playback_info_changed , self ._session_tokens [1 ])
331+ if len (self ._session_tokens ) > 1
332+ else None ,
333+ (session .remove_timeline_properties_changed , self ._session_tokens [2 ])
334+ if len (self ._session_tokens ) > 2
335+ else None ,
336+ )
337+ for entry in removers :
338+ if entry is None :
339+ continue
340+ remover , token = entry
341+ try :
342+ remover (token )
343+ except Exception as exc :
344+ logger .debug ("Desabonnement session echoue : %s" , exc )
345+ self ._session = None
346+ self ._session_tokens = []
347+
348+ async def _attach_session (self , session : Any ) -> None :
349+ self ._detach_session ()
350+ self ._session = session
351+ if session is None :
352+ with self ._lock :
353+ self ._track = NO_TRACK
354+ self ._playback_rate = 0.0
355+ self ._last_error = None
356+ return
357+
358+ self ._session_tokens = [
359+ session .add_media_properties_changed (self ._on_session_event ),
360+ session .add_playback_info_changed (self ._on_session_event ),
361+ session .add_timeline_properties_changed (self ._on_session_event ),
362+ ]
363+ await self ._refresh_current_session ()
364+
365+ async def _refresh_current_session (self ) -> None :
366+ session = self ._session
367+ if session is None :
368+ return
369+
370+ try :
371+ app_id = session .source_app_user_model_id or ""
372+ media_filter : MediaFilter = self ._config .media_filter
373+ if not media_filter .allows (app_id ):
374+ logger .debug ("Application filtree : %s" , app_id )
375+ with self ._lock :
376+ self ._track = NO_TRACK
377+ self ._playback_rate = 0.0
378+ self ._last_error = None
379+ return
380+
381+ properties = await session .try_get_media_properties_async ()
382+ playback = session .get_playback_info ()
383+ timeline = session .get_timeline_properties ()
384+
385+ title = getattr (properties , "title" , "" ) or "Unknown Title"
386+ artist = getattr (properties , "artist" , "" ) or "Unknown Artist"
387+ album = getattr (properties , "album_title" , "" ) or ""
388+ key = (title , artist , album )
389+
390+ cached_key , cached_thumbnail = self ._thumbnail_cache
391+ if cached_key == key :
392+ thumbnail = cached_thumbnail
393+ else :
394+ thumbnail = await _read_thumbnail (properties )
395+ self ._thumbnail_cache = (key , thumbnail )
396+
397+ is_playing = getattr (playback , "playback_status" , 0 ) == PLAYBACK_STATUS_PLAYING
398+ position = _seconds (getattr (timeline , "position" , None ))
399+ duration = _seconds (getattr (timeline , "end_time" , None ))
400+ rate = getattr (playback , "playback_rate" , None ) or 1.0
401+
402+ track = Track (
403+ title = title ,
404+ artist = artist ,
405+ album = album ,
406+ thumbnail = thumbnail ,
407+ is_playing = is_playing ,
408+ position = position ,
409+ duration = duration ,
410+ source_app = app_id ,
411+ )
412+ with self ._lock :
413+ self ._track = track
414+ self ._duration = duration
415+ self ._position_anchor = position
416+ self ._position_anchor_time = time .monotonic ()
417+ self ._playback_rate = rate if is_playing else 0.0
418+ self ._last_error = None
419+ except Exception as exc :
420+ with self ._lock :
421+ self ._last_error = str (exc )
422+ logger .debug ("Lecture media echouee : %s" , exc )
423+ if self ._on_error is not None :
424+ self ._on_error (exc )
425+
243426
244427def _all_sessions (manager : Any ) -> list [Any ]:
245428 """Toutes les sessions média, avec repli sur la session courante.
0 commit comments