Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
6445 lines (5688 loc) · 291 KB
/
Copy pathmain.py
File metadata and controls
6445 lines (5688 loc) · 291 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# SPDX-License-Identifier: AGPL-3.0-only
"""Main FastAPI application for FXRoute."""
import copy
import cmath
import json
import logging
import math
import os
import re
import shutil
import time
import weakref
import asyncio
import hashlib
import inspect
import subprocess
import playback.queue as playback_queue
import playback.media_readiness as media_readiness
from contextlib import asynccontextmanager
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable, Mapping, Optional
from urllib.parse import quote, unquote, urlparse
import uvicorn
from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from starlette.datastructures import MutableHeaders
from starlette.middleware import Middleware
from starlette.types import ASGIApp, Receive, Scope, Send
from config import get_settings
from http_errors import bad_request, internal_error
from http_origin import (
TrustedOriginMiddleware,
effective_request_scheme,
is_request_origin_trusted,
)
from connection_manager import ConnectionManager
from common.atomic_write import atomic_write_bytes
import system_update as update_lifecycle
from library.sources import LibraryContext, MusicLibraryManager
from radio.metadata import RadioMetadataService
from safe_http import BlockedUrlError
BASE_DIR = Path(__file__).resolve().parent
STATIC_DIR = BASE_DIR / "static"
COVER_CACHE_DIR = BASE_DIR / "media" / "cache" / "covers"
UPDATE_SCRIPT = BASE_DIR / "scripts" / "update_fxroute.sh"
# Same rule as install.sh valid_local_hostname().
_LOCAL_HOSTNAME_PATTERN = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
_LOCAL_HOSTNAME_RESERVED = {"localhost"}
# Cooldown to prevent rapid mpv IPC flooding (ms)
PLAY_COMMAND_COOLDOWN_MS = 400
# Bounded window for the idempotent MPV->DSP ingress link reconciliation
# after the source ports appeared (link creation plus readback confirm).
MPV_LINK_REPAIR_TIMEOUT_MS = 1500
PEAK_MONITOR_RESTART_SETTLE_MS = 320
# Bounded readback wait for the native DSP ports after a rate switch or a
# missing-graph repair. No fixed sleeps: the handoff polls pw-link until the
# fxroute_dsp input/output ports are exposed, then starts/syncs the helper.
PLAYBACK_HANDOFF_DSP_PORT_TIMEOUT_MS = 5000
# A post-source-start graph repair is deliberately a short, deterministic
# readback window. It is not a second watcher or a general graph recovery.
POST_START_GRAPH_STABILITY_READBACKS = 2
SPOTIFY_STATE_POLL_INTERVAL_SECONDS = 2.0
SPOTIFY_STATE_IDLE_POLL_INTERVAL_SECONDS = 5.0
SPOTIFY_STATE_REFRESH_DEBOUNCE_SECONDS = 0.20
MEASUREMENT_WINDOW_TTL_SECONDS = 30.0
# Track last play command time to debounce rapid requests
_last_play_command_time = 0.0
def _can_send_play_command():
"""Debounce rapid play/pause/seek commands to prevent mpv IPC overload."""
global _last_play_command_time
now = time.monotonic()
if now - _last_play_command_time < PLAY_COMMAND_COOLDOWN_MS / 1000:
return False
_last_play_command_time = now
return True
def _read_version_file() -> str:
"""Thin wrapper: VERSION reading lives in install_info (REFACTOR-009)."""
return install_info.read_version_file()
def is_live_mode() -> bool:
"""Return True inside the volatile Try FXRoute live session.
Detection is intentionally redundant: the live root ships the marker
file /etc/fxroute-live, and the live GRUB entry passes fxroute.live=1
on the kernel cmdline. Either signal counts; both are absent on
installed systems. Read-only, no caching (tests patch Path/cmdline).
"""
try:
if Path("/etc/fxroute-live").is_file():
return True
except OSError:
pass
try:
cmdline = Path("/proc/cmdline").read_text(encoding="utf-8", errors="replace")
except OSError:
return False
return "fxroute.live=1" in cmdline.split()
def _read_build_id() -> str:
"""Thin wrapper: build-id resolution lives in install_info (REFACTOR-009)."""
return install_info.read_build_id()
def _configured_service_name() -> str:
"""Thin wrapper: service-name resolution lives in install_info (REFACTOR-009)."""
return install_info.configured_service_name()
_UPDATE_CHECK_TIMEOUT_SECONDS = 90
_UPDATE_APPLY_TIMEOUT_SECONDS = 15 * 60
_UPDATE_TERMINATE_GRACE_SECONDS = 5
_SERVICE_RESTART_TIMEOUT_SECONDS = 15
_SERVICE_RESTART_TERMINATE_GRACE_SECONDS = 3
def _make_system_update_deps() -> update_lifecycle.SystemUpdateDeps:
"""Bind the update orchestration to the application services.
Resolved at call time, so tests that patch main attributes (UPDATE_SCRIPT,
timeout constants, logger) observe the patched values through the thin
wrappers below (same contract as the ``configure_*`` pattern used by the
extracted routers).
"""
return update_lifecycle.SystemUpdateDeps(
stop_process_group=pw_link.stop_process_group_cancellation_safe,
stop_command_child=pw_link.stop_command_child_cancellation_safe,
log_warning=logger.warning,
)
async def _run_update_script(timeout: float, *args: str) -> dict:
"""Thin wrapper: update-script lifecycle lives in system_update (REFACTOR-012)."""
return await update_lifecycle.run_update_script(
UPDATE_SCRIPT,
timeout,
*args,
terminate_grace_seconds=_UPDATE_TERMINATE_GRACE_SECONDS,
deps=_make_system_update_deps(),
)
def _get_update_operation_lock() -> asyncio.Lock:
"""Thin wrapper: the exclusive update guard lives in system_update (REFACTOR-012)."""
return update_lifecycle.get_update_operation_lock()
async def _run_update_operation(timeout: float, *args: str, on_result=None) -> dict:
"""Thin wrapper: exclusive update guard lives in system_update (REFACTOR-012)."""
return await update_lifecycle.run_update_operation(
timeout,
*args,
script_path=UPDATE_SCRIPT,
terminate_grace_seconds=_UPDATE_TERMINATE_GRACE_SECONDS,
deps=_make_system_update_deps(),
on_result=on_result,
)
async def _restart_fxroute_service_after_response(service_name: str) -> None:
"""Thin wrapper: deferred service restart lives in system_update (REFACTOR-012)."""
try:
await update_lifecycle.restart_service_after_response(
service_name,
restart_timeout_seconds=_SERVICE_RESTART_TIMEOUT_SECONDS,
restart_terminate_grace_seconds=_SERVICE_RESTART_TERMINATE_GRACE_SECONDS,
deps=_make_system_update_deps(),
)
finally:
update_lifecycle.finish_deferred_restart()
def _measurement_blocks_playback_rate(expected_rate: Optional[int]) -> Optional[int]:
"""Resolve the session-owned playback-rate block decision for samplerate deps.
The active-and-jobs decision lives on ``MeasurementSampleRateSession``
(``blocks_playback_rate``); this is only the injection bridge that guards
the not-yet-created session and delegates to the owner.
"""
if measurement_sr_session is None:
return None
return measurement_sr_session.blocks_playback_rate(expected_rate)
def _measurement_session_owns_live_rate() -> bool:
"""Return whether an open measurement window owns the live sample rate.
Idle playback paths must not clear the force-rate pin then: the sweep runs
at the measurement rate, and the session release is the owner that
restores or clears the pin.
"""
session = measurement_sr_session
return session is not None and session.owns_audio_graph
def _is_local_playback_active(state: dict | None) -> bool:
return playback_state_helpers.is_local_playback_active(state)
def _is_spotify_playback_active(state: dict | None) -> bool:
return playback_state_helpers.is_spotify_playback_active(state)
def _is_measurement_window_open() -> bool:
if last_measurement_window_seen_at <= 0:
return False
return (time.monotonic() - last_measurement_window_seen_at) <= MEASUREMENT_WINDOW_TTL_SECONDS
def _heartbeat_session_epoch(value: object) -> int | None:
"""Return the heartbeat's measurement-session context, if it carries one.
Tabs echo the ``measurement_session_epoch`` of the last heartbeat
response so a stale tab cannot extend a newer measurement context.
Missing or invalid values keep the legacy context-free behavior.
"""
if value is None or isinstance(value, bool):
return None
try:
epoch = int(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
return epoch if epoch >= 0 else None
def _active_line_source_for_power() -> str | None:
"""Routed line source keeping the amp hint on, if any.
Bluetooth counts only while its linked capture source shows live
WirePlumber stream links (merely connected or paused stays off);
external input counts while its loopback link is established. Both
link names are owned by the routing singletons; the Bluetooth case
costs one local ``wpctl`` read.
"""
try:
bluetooth_source = (bluetooth_input.input_source_name or "").strip()
except Exception:
bluetooth_source = ""
if bluetooth_source and is_bluetooth_audio_streaming(bluetooth_source):
return "bluetooth"
try:
external_source = (external_input.loopback_source_name or "").strip()
except Exception:
external_source = ""
if external_source:
return "external-input"
try:
stdin_service = runtime.stdin_input
snapshot = stdin_service.snapshot() if stdin_service else None
except Exception:
snapshot = None
if snapshot and snapshot.get("selected") and snapshot.get("routed") and snapshot.get("state") == "streaming":
return "stdin"
return None
def _external_input_link_channels() -> tuple[str, str]:
"""Return the stereo channels of the established external-input link."""
try:
raw = getattr(external_input, "active_channels", None)
except Exception:
raw = None
if isinstance(raw, (tuple, list)) and len(raw) == 2:
left = str(raw[0] or "").strip() or "FL"
right = str(raw[1] or "").strip() or "FR"
else:
left, right = "FL", "FR"
if left == right:
left, right = "FL", "FR"
return left, right
async def _active_line_source_for_power_async() -> str | None:
"""Routed line source keeping the amp hint on, if any (never blocks).
Same ground truth as the synchronous probe, but safe for the async
HTTP path: the blocking ``wpctl`` Bluetooth read runs off the event
loop, and the external-input hint requires the loopback link to be
present in the live graph instead of trusting the stored source
name. Both probes stay fail-closed on any error.
"""
try:
bluetooth_source = (bluetooth_input.input_source_name or "").strip()
except Exception:
bluetooth_source = ""
if bluetooth_source:
try:
streaming = await asyncio.to_thread(is_bluetooth_audio_streaming, bluetooth_source)
except Exception:
streaming = False
if streaming:
return "bluetooth"
try:
external_source = (external_input.loopback_source_name or "").strip()
except Exception:
external_source = ""
if external_source:
left, right = _external_input_link_channels()
try:
if await input_links_present(external_source, ((left, "FL"), (right, "FR"))):
return "external-input"
except Exception:
return None
return None
try:
stdin_service = runtime.stdin_input
except Exception:
stdin_service = None
if stdin_service is not None:
try:
if await stdin_service.active_for_power():
return "stdin"
except Exception:
return None
return None
def _finish_power_state_payload(
playback_active: bool, measurement_window_open: bool, line_source: str | None
) -> dict:
if measurement_window_open:
reason = "measurement_window"
elif playback_active:
reason = "playback"
elif line_source == "bluetooth":
reason = "bluetooth"
elif line_source == "external-input":
reason = "external-input"
elif line_source == "stdin":
reason = "stdin"
else:
reason = "idle"
return {
"amp_should_be_on": bool(playback_active or measurement_window_open or line_source),
"reason": reason,
"playback_active": bool(playback_active),
"measurement_window_open": bool(measurement_window_open),
}
def _power_state_playback_flags() -> tuple[bool, bool]:
local_state = runtime.player_instance.state if runtime.player_instance else {}
spotify_state = playback_state.latest_spotify_state or {}
qobuz_state = playback_state.latest_qobuz_state or {}
playback_active = (
_is_local_playback_active(local_state)
or _is_spotify_playback_active(spotify_state)
or _is_qobuz_playback_active(qobuz_state)
)
return playback_active, _is_measurement_window_open()
def _build_power_state_payload() -> dict:
playback_active, measurement_window_open = _power_state_playback_flags()
# Measurement keeps priority and skips the line-source probe entirely;
# its behavior is unchanged by the Bluetooth/external extension below.
line_source = None if measurement_window_open else _active_line_source_for_power()
return _finish_power_state_payload(playback_active, measurement_window_open, line_source)
async def _build_power_state_payload_async() -> dict:
playback_active, measurement_window_open = _power_state_playback_flags()
# Measurement keeps priority and skips the line-source probe entirely;
# its behavior is unchanged by the Bluetooth/external extension below.
line_source = None if measurement_window_open else await _active_line_source_for_power_async()
return _finish_power_state_payload(playback_active, measurement_window_open, line_source)
def _is_qobuz_playback_active(state: dict | None) -> bool:
return playback_state_helpers.is_external_playback_active(state)
def _has_local_footer_context(state: dict | None) -> bool:
state = state or {}
track = playback_state.current_track_info or state.get("current_track") or {}
source = (track or {}).get("source")
if not source_policy.is_mpv_source(source):
return False
return bool(
state.get("current_file")
or state.get("playing")
or state.get("paused")
or state.get("ended")
)
def _derive_playback_owner_readonly(
player_state: dict | None = None,
spotify_state: dict | None = None,
qobuz_state: dict | None = None,
) -> str | None:
"""Resolve a read-only owner fallback from the live active sources.
Used only when no owner has been committed yet (e.g. right after boot,
before the external watchers have claimed). Never mutates the committed
owner: a status/metadata read must not change ownership.
"""
player_state = player_state or (runtime.player_instance.state if runtime.player_instance else {})
spotify_state = spotify_state or playback_state.latest_spotify_state or {}
qobuz_state = qobuz_state or playback_state.latest_qobuz_state or {}
if _is_spotify_playback_active(spotify_state):
return "spotify"
if _is_qobuz_playback_active(qobuz_state):
return "qobuz"
if _is_local_playback_active(player_state):
track = playback_state.current_track_info or {}
source = (track or {}).get("source")
return source if source_policy.is_mpv_source(source) else "local"
return None
def _resolve_playback_owner() -> str | None:
"""Return the authoritative playback owner.
The committed ``current_playback_owner`` is authoritative and only changes
on a real playback intent (native play) or an external source claim.
Pausing keeps the owner. When nothing is committed yet, a read-only
fallback is derived from the live active sources for display only; it is
never persisted.
"""
owner = playback_state.current_playback_owner
if owner:
return owner
return _derive_playback_owner_readonly()
def _set_playback_owner(source: str | None) -> None:
playback_state.current_playback_owner = source
from models import (
PlayRequest,
)
from playback.player import get_player, MPVNotInstalledError
from playback.stream_info import StreamInfoLedger
from radio.api import _station_api_payload, router as radio_api_router
from radio.stations import get_stations
import playback.state as playback_state_helpers
from playback.state import PlaybackState
import playback.source_policy as source_policy
import audio.samplerate as samplerate
from library.core import (
LibraryScanner,
)
from downloader import Downloader
from dsp.manager import DSPManager, ensure_kernel_supported_ir, parse_wav_frames
from dsp.crossover import crossover_response, design_crossover
from dsp.runtime import DSPRuntime, DSPRuntimeConfig, PlannedSyncTarget, _contains_link
import dsp.api as dsp_api
import dsp.orchestration as dsp_orchestration
import dsp.preset_loading as preset_loading
import playback.orchestration as playback_orchestration
from audio import pw_link
from audio.input_links import input_links_present
from audio.output_ports import hardware_playback_port_fallback_from_mode
from audio.output_service import MeasurementActiveError, OutputService, OutputServiceDeps
from audio.output_state import (
FILTER_SLOPES,
bass_crossover_for_side,
roles_for_mode,
shared_bass_crossover,
routing_for_device,
select_bank,
set_bank_preset,
set_bass_management,
set_crossover,
set_mode_extras,
set_mode_routing,
set_output_processing,
switch_mode,
validate_output_state,
)
from audio.output_state_store import OutputStateStore, StateConflictError
from audio.output_topology import MODES, SUB_ROLES, derive_topology, side_for_role
from audio.filter_banks import bank_catalog, resolve_bank, selected_bank, summarize_all_banks
from audio.output_state import switch_all_banks
from audio.bluetooth import BluetoothInputDependencies, BluetoothInputMonitor
from audio.drift import SamplerateDriftDependencies, SamplerateDriftObserver
from audio.external_input import ExternalInputRouting, ExternalInputRoutingDependencies
from audio.stdin_input import StdinInputDependencies, StdinInputService
from audio.stdin_pipewire import PipeWirePcmSink, pcm_adapter_error
from audio.stdin_protocol import socket_path
from audio.source_feed import SourceOverviewFeed, SourceTransitionLock
from audio.source_transitions import SourceTransitionDependencies, SourceTransitions
from common.run_to_completion import finish_then_cancel
from playback.radio_reconnect import RadioReconnect, RadioReconnectDependencies
from playback.silent_active import SilentActiveDependencies, SilentActiveRecovery
from playback.spotify_watch import (
SpotifyPlayerctlWatch,
SpotifyWatchDependencies,
)
from playback.qobuz_watch import (
QobuzPlayerWatch,
QobuzWatchDependencies,
)
from playback.qbzd_volume_watch import (
QobuzVolumeWatch,
QobuzVolumeWatchDependencies,
)
from playback.spotifyd_volume_watch import (
SpotifydVolumeWatch,
SpotifydVolumeWatchDependencies,
)
from dsp.orchestration import (
DspOrchestrationDeps,
DspOrchestrator,
helper_argument_sample_rate,
with_subwoofer_derived_delays,
)
try:
from hardware_controller import HardwareController
except ImportError:
HardwareController = None
from measurement.store import (
MeasurementStore,
)
from measurement.target import (
freeze_measurement_target,
measurement_target_from_context,
require_commit_target,
)
from dsp.peak_monitor import DSPPeakMonitor, PeakMonitorCoordinator, PeakMonitorCoordinatorDeps
from playback.transition import (
PlaybackTransitionCoordinator,
PlaybackTransitionFailure,
TransitionRequest,
)
from playback.runtime import (
FxrouteTransitionRuntime,
PlaybackRuntimeDependencies,
RADIO_EXPECTED_SAMPLE_RATE_HZ,
SOURCE_HANDOFF_SETTLE_MS,
_playback_gate_state_path,
)
from audio.samplerate import (
OUTPUT_MODE_STEREO,
OUTPUT_MODE_SUBWOOFER_MODES,
SOURCE_MODE_APP_PLAYBACK,
apply_persisted_audio_output_selection,
configure_source_availability,
configure_stdin_input_status,
get_audio_output_overview,
get_audio_source_overview,
get_bluetooth_audio_overview,
get_samplerate_status,
is_bluetooth_audio_streaming,
normalize_sample_rate_policy,
recover_saved_output_sink,
set_audio_output_selection,
set_audio_source_selection,
set_bluetooth_receiver_enabled,
with_current_stdin_status,
)
import streaming
from streaming.tidal import auth as tidal_auth
from streaming.tidal import playback as tidal_playback
from streaming.qobuz import connect_state
from streaming.qobuz.provider import QobuzSeekError, QobuzSeekNotConfirmed, QobuzSeekPositionError
from streaming.spotify import mpris as spotify_mpris
from streaming.spotify.mpris import playerctl_available, spotify_installed
from streaming.spotify.provider import (
SPOTIFY_PREARM_SAMPLE_RATE_HZ,
get_status as spotify_get_status,
play as spotify_play,
pause as spotify_pause,
next_track as spotify_next,
previous as spotify_previous,
shuffle_toggle as spotify_shuffle_toggle,
loop_cycle as spotify_loop_cycle,
seek_to as spotify_seek_to,
)
from audio.system_volume import SystemVolumeError, get_output_volume, set_output_volume, get_status_volume, start_volume_read_monitor, volume_percent_to_db
import audio.volume_contract as volume_contract
logger = logging.getLogger(__name__)
import install_info
import audio.power_api as power_api
import audio.canonical_volume as canonical_volume
import measurement.spl_calibration as spl_calibration
import measurement.speaker_api as speaker_api
import measurement.autosub as autosub
import measurement.session as measurement_session
from measurement.session import (
MeasurementServices,
MeasurementSampleRateSession,
_measurement_helper_snapshot_summary,
_wait_for_selected_output_effective_rate,
)
from library.api import (
LibraryApiRuntime,
_record_local_track_started,
_track_cover_available,
configure_runtime as configure_library_api_runtime,
router as library_api_router,
)
from library.metadata import LibraryMetadataStore
@dataclass
class MusicLibraryRuntime:
"""Current music-library services and their switch serialization."""
manager: Any = None
scanner: Any = None
switch_lock: Optional[asyncio.Lock] = None
def reset(self) -> None:
self.manager = None
self.scanner = None
self.switch_lock = None
@dataclass(frozen=True)
class PlaybackCommitIntent:
"""Commit boundaries captured when one playback intent starts.
Carries every boundary that can invalidate a prepared app publish: the
audio source selection and the music-library context the transition was
prepared for. A library switch advances the library generation, so the
prepared transition can no longer publish a track from the library the
user already left.
"""
source: tuple[str, int]
library: LibraryContext | None
@dataclass
class RuntimeResources:
"""Lifecycle-owned runtime resources created and torn down by the FastAPI lifespan.
One authoritative location for the mutable resources that are initialized
on startup and reset on shutdown. Persistent configuration, manager
singletons stay at module scope.
"""
player_instance: Any = None
music_library: MusicLibraryRuntime = field(default_factory=MusicLibraryRuntime)
dsp_runtime: Any = None
peak_monitor: Any = None
measurement_watchdog_task: Optional[asyncio.Task] = None
library_scan_task: Optional[asyncio.Task] = None
spotify_state_refresh_task: Optional[asyncio.Task] = None
spotify_state_poll_task: Optional[asyncio.Task] = None
lifecycle_background_tasks: set[asyncio.Task] = field(default_factory=set)
library_refresh_tasks: set[asyncio.Task] = field(default_factory=set)
dsp_runtime_link_watch_task: Optional[asyncio.Task] = None
dsp_preset_load_lock: Optional[asyncio.Lock] = None
# Serializes threaded DSP mutations (convolver IR upload/create) so
# concurrent HTTP requests cannot interleave filesystem/preset state
# changes that used to run serially in the event loop.
dsp_mutation_lock: Optional[asyncio.Lock] = None
source_transition_lock: Optional[asyncio.Lock] = None
# Serializes canonical volume writes (/api/volume, streaming volume actions) so
# concurrent requests cannot interleave their set -> verified get -> status
# cache publish sequences.
canonical_volume_write_lock: Optional[asyncio.Lock] = None
stdin_input: Any = None
def reset(self) -> None:
"""Clear every lifecycle-owned resource at shutdown."""
self.player_instance = None
self.music_library.reset()
self.dsp_runtime = None
self.peak_monitor = None
self.measurement_watchdog_task = None
self.library_scan_task = None
self.spotify_state_refresh_task = None
self.spotify_state_poll_task = None
self.lifecycle_background_tasks.clear()
self.library_refresh_tasks.clear()
self.dsp_runtime_link_watch_task = None
self.dsp_preset_load_lock = None
self.dsp_mutation_lock = None
self.source_transition_lock = None
self.canonical_volume_write_lock = None
self.stdin_input = None
# Global instances (initialized on startup)
settings = None
runtime = RuntimeResources()
dsp_manager = None
downloader = None
measurement_store = None
measurement_sr_session = None
hardware_controller = None
# Cooldown between player (re)start attempts from request context: a missing
# mpv binary must not turn every play press into minutes of blocking probes.
_player_restart_cooldown_until = 0.0
PLAYER_RESTART_COOLDOWN_S = 60.0
def _ensure_player_running() -> bool:
"""(Re)start the MPV player when it is missing or stopped.
Backend-startup start failures are transient (slow first-run probes under
load); without lazy recovery one failure 503s every /api/play until the
service is restarted. Returns True when a running player is available.
"""
global _player_restart_cooldown_until
player = runtime.player_instance
if player is not None and _player_is_running(player):
return True
if time.monotonic() < _player_restart_cooldown_until:
return False
try:
player = player if player is not None else get_player()
player.start()
except Exception as exc:
logger.warning("Player (re)start failed: %s", exc)
_player_restart_cooldown_until = time.monotonic() + PLAYER_RESTART_COOLDOWN_S
return False
runtime.player_instance = player
return _player_is_running(player)
def _dsp_mutation_lock() -> asyncio.Lock:
if runtime.dsp_mutation_lock is None:
runtime.dsp_mutation_lock = asyncio.Lock()
return runtime.dsp_mutation_lock
def _get_dsp_preset_load_lock() -> asyncio.Lock:
if runtime.dsp_preset_load_lock is None:
runtime.dsp_preset_load_lock = asyncio.Lock()
return runtime.dsp_preset_load_lock
def _canonical_volume_write_lock() -> asyncio.Lock:
if runtime.canonical_volume_write_lock is None:
runtime.canonical_volume_write_lock = asyncio.Lock()
return runtime.canonical_volume_write_lock
def _source_transition_lock() -> SourceTransitionLock:
if runtime.source_transition_lock is None:
runtime.source_transition_lock = SourceTransitionLock()
return runtime.source_transition_lock
async def _drain_worker(func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""Run a sync worker off the event loop, surviving caller cancellation.
The caller owns the surrounding critical section (lock). A cancelled
caller must not release that section while the worker thread is still
running: threads cannot be cancelled, so this helper waits until the
worker actually finished, then re-raises CancelledError. Worker
exceptions are re-raised in the normal path and logged on the
cancellation path (the cancellation takes precedence).
"""
worker = asyncio.create_task(asyncio.to_thread(func, *args, **kwargs))
cancelled = False
while not worker.done():
try:
await asyncio.shield(worker)
except asyncio.CancelledError:
cancelled = True
if cancelled:
try:
worker.result()
except BaseException:
logger.exception("Worker failed while its caller was cancelled")
raise asyncio.CancelledError
return worker.result()
async def _run_locked_worker(lock: asyncio.Lock, func: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
"""Run a sync worker inside a critical section that survives cancellation.
The lock is only released after the worker actually finished, even when
the caller is cancelled.
"""
async with lock:
return await _drain_worker(func, *args, **kwargs)
async def _finish_despite_cancellation(coro, *, name: str) -> Any:
"""Run a commit section to completion even when the caller is cancelled.
Commit sections mutate state that cannot be unwound: an already published
manager state, a stopped player, a swapped scanner. A cancellation in
the middle would leave those halves describing different things, so the
section is run as its own task and awaited until it actually finished.
A failed section reports its own error, so the caller can undo the switch;
a section that merely ran while the caller was cancelled propagates the
cancellation.
"""
task = asyncio.create_task(coro, name=name)
cancelled = False
while not task.done():
try:
await asyncio.shield(task)
except asyncio.CancelledError:
cancelled = True
if task.cancelled():
raise asyncio.CancelledError
error = task.exception()
if error is not None:
raise error
if cancelled:
raise asyncio.CancelledError
return task.result()
playback_transition_coordinator: PlaybackTransitionCoordinator | None = None
# Measurement-window heartbeat timestamp: set by
# /api/power/measurement-heartbeat, read only via _is_measurement_window_open().
# It is measurement-window/power state, not silent-active recovery, so it
# stays a plain module scalar rather than joining SilentActiveRecoveryState.
last_measurement_window_seen_at = 0.0
# Wait primitive for ended callbacks: set once no playback transition
# is in flight. asyncio.Event binds to the first used event loop; tests
# use a fresh loop per test, so the signal is kept per loop (production:
# exactly one loop). No second transition lock.
_playback_settled_events: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Event]" = weakref.WeakKeyDictionary()
def _playback_settled_event() -> asyncio.Event:
"""Return the settle signal for the running event loop (create if new)."""
loop = asyncio.get_running_loop()
event = _playback_settled_events.get(loop)
if event is None:
event = asyncio.Event()
event.set()
_playback_settled_events[loop] = event
return event
# Single authoritative owner of the mutable playback/transition state
# (current/last track, Spotify UI state, footer owner, intent generation,
# transition epoch/pending attempts and the published commit tokens).
# See playback_state.PlaybackState for the field relationships.
playback_state = PlaybackState()
# Last known MPV stream facts per track, so rate/status refreshes never
# degrade a complete radio/local/TIDAL track's quality data during a
# transient telemetry gap (see playback.stream_info.StreamInfoLedger).
stream_info_ledger = StreamInfoLedger()
radio_reconnect = RadioReconnect(RadioReconnectDependencies(
get_player_instance=lambda: runtime.player_instance,
get_playback_state=lambda: playback_state,
request_coordinated_recovery=lambda *args, **kwargs: playback_orchestration.configured().request_coordinated_recovery(*args, **kwargs),
))
silent_active_recovery = SilentActiveRecovery(SilentActiveDependencies(
get_peak_monitor=lambda: runtime.peak_monitor,
get_player_instance=lambda: runtime.player_instance,
get_current_track_info=lambda: playback_state.current_track_info,
get_current_playback_owner=lambda: _resolve_playback_owner(),
get_spotify_ui_state=lambda *args, **kwargs: get_spotify_ui_state(*args, **kwargs),
list_mpv_sink_inputs=lambda: media_readiness.list_mpv_sink_inputs(),
list_spotify_sink_inputs=lambda: media_readiness.list_spotify_sink_inputs(),
list_all_sink_inputs=lambda: media_readiness.list_sink_inputs(),
get_output_volume_safe=lambda default=100: get_output_volume_safe(default),
run_debug_command=lambda args, timeout=2.0: _run_debug_command(args, timeout),
is_measurement_window_open=lambda: _is_measurement_window_open(),
dsp_preset_load_locked=lambda: runtime.dsp_preset_load_lock is not None and runtime.dsp_preset_load_lock.locked(),
current_track_matches=lambda track: _current_track_matches(track),
))
peak_monitor_coordinator = PeakMonitorCoordinator(PeakMonitorCoordinatorDeps(
get_peak_monitor=lambda: runtime.peak_monitor,
get_player_state=lambda: runtime.player_instance.state if runtime.player_instance else {},
get_current_track_info=lambda: playback_state.current_track_info,
broadcast=lambda message: manager.broadcast(message),
get_spotify_ui_state=lambda *args, **kwargs: get_spotify_ui_state(*args, **kwargs),
get_qobuz_ui_state=lambda *args, **kwargs: get_qobuz_ui_state(*args, **kwargs),
get_audio_source_overview=lambda: get_audio_source_overview(),
capture_transition_epoch=lambda *a, **k: _capture_playback_transition_epoch(*a, **k),
transition_context_is_current=lambda *a, **k: _playback_transition_context_is_current(*a, **k),
transition_is_active=lambda: _playback_transition_is_active(),
sleep=lambda delay: asyncio.sleep(delay),
))
external_input = ExternalInputRouting(ExternalInputRoutingDependencies(
get_audio_source_overview=lambda: get_audio_source_overview(),
get_persisted_source_mode=lambda: (
samplerate._load_audio_source_selection().get("mode") or SOURCE_MODE_APP_PLAYBACK
),
get_source_transition_lock=lambda: _source_transition_lock(),
# Also pushes the overview to clients (SourceTransitions.sync_source_mode_state).
sync_peak_monitor_for_source_mode_state=lambda overview: source_transitions.sync_source_mode_state(overview),
))
bluetooth_input = BluetoothInputMonitor(BluetoothInputDependencies(
# Also pushes the overview to clients (SourceTransitions.sync_source_mode_state).
sync_peak_monitor_for_source_mode_state=lambda overview: source_transitions.sync_source_mode_state(overview),
get_persisted_source_mode=lambda: (
samplerate._load_audio_source_selection().get("mode") or SOURCE_MODE_APP_PLAYBACK
),
get_source_transition_lock=lambda: _source_transition_lock(),
))
# The monitors observe source availability and own the loss confirmation;
# the source overview only reads it.
configure_source_availability(
bluetooth_loss_confirmed=lambda: bluetooth_input.availability.confirmed(),
external_loss_confirmed=lambda: external_input.availability.confirmed(),
remembered_external_input=lambda key: external_input.remembered_input(key),
)
samplerate_drift = SamplerateDriftObserver(SamplerateDriftDependencies(
get_current_track_info=lambda: playback_state.current_track_info,
get_player_instance=lambda: runtime.player_instance,
coordinator_source_rate=lambda *args, **kwargs: playback_orchestration.configured().coordinator_source_rate(*args, **kwargs),
get_player_audio_samplerate=lambda: media_readiness.get_player_audio_samplerate(runtime.player_instance),
get_samplerate_status=lambda: get_samplerate_status(),
playback_transition_is_active=lambda: _playback_transition_is_active(),
is_measurement_window_open=lambda: _is_measurement_window_open(),
measurement_session_active=lambda: measurement_sr_session is not None and measurement_sr_session.active,
measurement_audio_graph_owned=lambda: playback_orchestration.configured().measurement_audio_graph_owned(),
request_coordinated_recovery=lambda *args, **kwargs: playback_orchestration.configured().request_coordinated_recovery(*args, **kwargs),
))
spotify_playerctl_watch = SpotifyPlayerctlWatch(SpotifyWatchDependencies(
get_playback_state=lambda: playback_state,
get_spotify_ui_state=lambda *args, **kwargs: get_spotify_ui_state(*args, **kwargs),
list_spotify_sink_inputs=lambda: media_readiness.list_spotify_sink_inputs(),
spotify_sink_input_observation=lambda *args, **kwargs: media_readiness.spotify_sink_input_observation(*args, **kwargs),
request_coordinated_recovery=lambda *args, **kwargs: playback_orchestration.configured().request_coordinated_recovery(*args, **kwargs),
schedule_spotify_state_refresh=lambda reason: _schedule_spotify_state_refresh(reason),
claim_spotify_playback=lambda *args, **kwargs: _claim_spotify_playback(*args, **kwargs),
))
qobuz_player_watch = QobuzPlayerWatch(QobuzWatchDependencies(
get_playback_state=lambda: playback_state,
broadcast_qobuz_state=lambda *args, **kwargs: broadcast_qobuz_state(*args, **kwargs),
is_qobuz_playback_active=lambda *args, **kwargs: _is_qobuz_playback_active(*args, **kwargs),
claim_qobuz_playback=lambda *args, **kwargs: _claim_qobuz_playback(*args, **kwargs),
))
qobuz_volume_watch = QobuzVolumeWatch(QobuzVolumeWatchDependencies(
is_active=lambda: _resolve_playback_owner() == "qobuz" and connect_state.is_device_active() is not False,
apply_volume_value=lambda value: _apply_remote_volume_value(
value,
owner="qobuz",
source_active=lambda: connect_state.is_device_active() is not False,
),
current_master=lambda: get_output_volume_safe(),
on_device_active=lambda value: connect_state.set_device_active(value),
pin_unity=lambda: _qobuz_pin_unity(),
))
spotifyd_volume_watch = SpotifydVolumeWatch(SpotifydVolumeWatchDependencies(
is_active=lambda: _resolve_playback_owner() == "spotify",
apply_volume_value=lambda value: _apply_remote_volume_value(value, owner="spotify"),
current_master=lambda: get_output_volume_safe(),
))
radio_metadata_service = RadioMetadataService()
# queue_advancing is a reentrancy/dispatch guard for
# on_player_state_change and deliberately not queue state: the queue
# state (list, original order, index, mode, loop, shuffle, single-track-
# loop) lives exclusively in playback.queue.PlaybackQueue.
queue_advancing = False
def _sync_playback_track_favorite(track_id: str, favorite: bool) -> None:
"""Mirror a persisted track favorite into live playback/queue track dicts.
The library mutation updates the scanner's in-memory Track objects, but
the currently playing track and the queue hold independent dict copies;
without this the next status/WS poll would revert the footer heart.
"""
if playback_state.current_track_info and playback_state.current_track_info.get("id") == track_id:
playback_state.current_track_info["favorite"] = favorite
if playback_state.last_track_info and playback_state.last_track_info.get("id") == track_id:
playback_state.last_track_info["favorite"] = favorite
queue = playback_queue.queue
if queue is not None:
for track in queue.tracks:
if track.get("id") == track_id:
track["favorite"] = favorite
configure_library_api_runtime(LibraryApiRuntime(
get_scanner=lambda: runtime.music_library.scanner,
get_settings=lambda: settings,
run_blocking=_drain_worker,
sync_track_favorite=_sync_playback_track_favorite,
))
spl_calibration.configure_runtime(spl_calibration.SplCalibrationDependencies(
get_measurement_store=lambda: measurement_store,
get_measurement_session=lambda: measurement_sr_session,