gukebox 1.0.0.dev3__tar.gz → 1.0.0.dev5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/PKG-INFO +1 -1
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/api_controller.py +17 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/ui_controller.py +11 -1
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/app.py +25 -12
- gukebox-1.0.0.dev5/jukebox/adapters/outbound/sonos_discovery_adapter.py +199 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/app.py +37 -12
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/cli_presentation.py +26 -5
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/command_handlers.py +47 -14
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/commands.py +10 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/di_container.py +27 -4
- gukebox-1.0.0.dev5/jukebox/admin/services.py +10 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/app.py +9 -1
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/__init__.py +0 -3
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/resolve.py +8 -61
- gukebox-1.0.0.dev5/jukebox/settings/runtime_resolver.py +59 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/service_protocols.py +8 -2
- gukebox-1.0.0.dev5/jukebox/sonos/__init__.py +16 -0
- gukebox-1.0.0.dev5/jukebox/sonos/discovery.py +25 -0
- gukebox-1.0.0.dev5/jukebox/sonos/service.py +73 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/pyproject.toml +1 -1
- gukebox-1.0.0.dev3/jukebox/settings/sonos_runtime.py +0 -175
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/LICENSE +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/README.md +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/cli_display.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/config.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/command_handlers.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/commands.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/di_container.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/add_disc.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/edit_disc.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/get_disc.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/list_discs.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/remove_disc.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/search_discs.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/config.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/nfc_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/di_container.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/current_tag_action.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/disc.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/playback_action.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/playback_session.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/library_repository.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/determine_action.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/definitions.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/entities.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/errors.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/file_settings_repository.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/migration.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/types.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/shared/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/shared/dependency_messages.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/shared/logger.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/jukebox/shared/timing.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/pn532/__init__.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/pn532/pn532.py +0 -0
- {gukebox-1.0.0.dev3 → gukebox-1.0.0.dev5}/pn532/spi.py +0 -0
|
@@ -20,6 +20,8 @@ from discstore.domain.use_cases.remove_disc import RemoveDisc
|
|
|
20
20
|
from jukebox.settings.errors import SettingsError
|
|
21
21
|
from jukebox.settings.service_protocols import SettingsService
|
|
22
22
|
from jukebox.settings.types import JsonObject
|
|
23
|
+
from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError
|
|
24
|
+
from jukebox.sonos.service import SonosService
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class DiscInput(Disc):
|
|
@@ -34,6 +36,10 @@ class CurrentTagStatusOutput(CurrentTagStatus):
|
|
|
34
36
|
pass
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
class SonosSpeakerOutput(DiscoveredSonosSpeaker):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
37
43
|
class SettingsResetInput(BaseModel):
|
|
38
44
|
path: str
|
|
39
45
|
|
|
@@ -51,6 +57,7 @@ class APIController:
|
|
|
51
57
|
edit_disc: EditDisc,
|
|
52
58
|
get_current_tag_status: GetCurrentTagStatus,
|
|
53
59
|
settings_service: SettingsService,
|
|
60
|
+
sonos_service: SonosService,
|
|
54
61
|
):
|
|
55
62
|
self.add_disc = add_disc
|
|
56
63
|
self.list_discs = list_discs
|
|
@@ -58,6 +65,7 @@ class APIController:
|
|
|
58
65
|
self.edit_disc = edit_disc
|
|
59
66
|
self.get_current_tag_status = get_current_tag_status
|
|
60
67
|
self.settings_service = settings_service
|
|
68
|
+
self.sonos_service = sonos_service
|
|
61
69
|
self.app = FastAPI(
|
|
62
70
|
title="DiscStore API",
|
|
63
71
|
description="API for managing Jukebox disc library",
|
|
@@ -83,6 +91,15 @@ class APIController:
|
|
|
83
91
|
|
|
84
92
|
return CurrentTagStatusOutput(**current_tag_status.model_dump())
|
|
85
93
|
|
|
94
|
+
@self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput])
|
|
95
|
+
def get_sonos_speakers():
|
|
96
|
+
try:
|
|
97
|
+
return self.sonos_service.list_available_speakers()
|
|
98
|
+
except SonosDiscoveryError as err:
|
|
99
|
+
raise HTTPException(status_code=502, detail=str(err))
|
|
100
|
+
except Exception as err:
|
|
101
|
+
raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
|
|
102
|
+
|
|
86
103
|
@self.app.get("/api/v1/settings")
|
|
87
104
|
def get_settings():
|
|
88
105
|
try:
|
|
@@ -42,6 +42,7 @@ from jukebox.settings.definitions import (
|
|
|
42
42
|
from jukebox.settings.errors import SettingsError
|
|
43
43
|
from jukebox.settings.service_protocols import SettingsService
|
|
44
44
|
from jukebox.settings.types import JsonObject
|
|
45
|
+
from jukebox.sonos.service import SonosService
|
|
45
46
|
|
|
46
47
|
_MISSING = object()
|
|
47
48
|
|
|
@@ -74,9 +75,18 @@ class UIController(APIController):
|
|
|
74
75
|
get_disc: GetDisc,
|
|
75
76
|
get_current_tag_status: GetCurrentTagStatus,
|
|
76
77
|
settings_service: SettingsService,
|
|
78
|
+
sonos_service: SonosService,
|
|
77
79
|
):
|
|
78
80
|
self.get_disc = get_disc
|
|
79
|
-
super().__init__(
|
|
81
|
+
super().__init__(
|
|
82
|
+
add_disc,
|
|
83
|
+
list_discs,
|
|
84
|
+
remove_disc,
|
|
85
|
+
edit_disc,
|
|
86
|
+
get_current_tag_status,
|
|
87
|
+
settings_service,
|
|
88
|
+
sonos_service,
|
|
89
|
+
)
|
|
80
90
|
|
|
81
91
|
def register_routes(self):
|
|
82
92
|
super().register_routes()
|
|
@@ -10,10 +10,11 @@ from discstore.command_handlers import execute_library_command
|
|
|
10
10
|
from discstore.commands import is_library_command
|
|
11
11
|
from discstore.di_container import build_cli_controller, build_interactive_cli_controller
|
|
12
12
|
from jukebox.admin.cli_presentation import render_cli_error
|
|
13
|
-
from jukebox.admin.command_handlers import
|
|
14
|
-
from jukebox.admin.commands import is_admin_command
|
|
13
|
+
from jukebox.admin.command_handlers import execute_server_command, execute_settings_command
|
|
14
|
+
from jukebox.admin.commands import is_admin_command, is_settings_command
|
|
15
15
|
from jukebox.admin.di_container import (
|
|
16
16
|
build_admin_api_app,
|
|
17
|
+
build_admin_services,
|
|
17
18
|
build_admin_ui_app,
|
|
18
19
|
)
|
|
19
20
|
from jukebox.admin.di_container import (
|
|
@@ -37,24 +38,36 @@ def main():
|
|
|
37
38
|
config = parse_config()
|
|
38
39
|
set_logger("discstore", config.verbose)
|
|
39
40
|
try:
|
|
40
|
-
settings_service = _build_settings_service(config)
|
|
41
41
|
if is_admin_command(config.command):
|
|
42
|
+
services = build_admin_services(
|
|
43
|
+
library=config.library,
|
|
44
|
+
command=config.command,
|
|
45
|
+
logger_warning=LOGGER.warning,
|
|
46
|
+
)
|
|
42
47
|
try:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
if is_settings_command(config.command):
|
|
49
|
+
execute_settings_command(
|
|
50
|
+
command=config.command,
|
|
51
|
+
settings_service=services.settings,
|
|
52
|
+
source_command="discstore",
|
|
53
|
+
library=config.library,
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
execute_server_command(
|
|
57
|
+
verbose=config.verbose,
|
|
58
|
+
command=config.command,
|
|
59
|
+
services=services,
|
|
60
|
+
build_api_app=build_admin_api_app,
|
|
61
|
+
build_ui_app=build_admin_ui_app,
|
|
62
|
+
source_command="discstore",
|
|
63
|
+
)
|
|
52
64
|
except RuntimeError as err:
|
|
53
65
|
print(str(err), file=sys.stderr)
|
|
54
66
|
raise SystemExit(1) from err
|
|
55
67
|
return
|
|
56
68
|
|
|
57
69
|
if is_library_command(config.command):
|
|
70
|
+
settings_service = _build_settings_service(config)
|
|
58
71
|
try:
|
|
59
72
|
execute_library_command(
|
|
60
73
|
verbose=config.verbose,
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Any, Optional, Protocol
|
|
3
|
+
|
|
4
|
+
from jukebox.sonos.discovery import (
|
|
5
|
+
DiscoveredSonosSpeaker,
|
|
6
|
+
SonosDiscoveryError,
|
|
7
|
+
SonosDiscoveryPort,
|
|
8
|
+
sort_sonos_speakers,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class _SonosDiscoverySnapshot:
|
|
14
|
+
speakers: list[DiscoveredSonosSpeaker]
|
|
15
|
+
retry_hosts_by_uid: dict[str, list[str]]
|
|
16
|
+
normalization_errors: list[str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SoCoSonosDiscoveryAdapter(SonosDiscoveryPort):
|
|
20
|
+
def discover_speakers(self) -> list[DiscoveredSonosSpeaker]:
|
|
21
|
+
snapshot = self._discover_runtime_snapshot()
|
|
22
|
+
speakers_by_uid = {speaker.uid: speaker for speaker in snapshot.speakers}
|
|
23
|
+
for expected_uid, hosts in snapshot.retry_hosts_by_uid.items():
|
|
24
|
+
for host in hosts:
|
|
25
|
+
try:
|
|
26
|
+
recovered = self._resolve_speaker_by_host(expected_uid, host)
|
|
27
|
+
except ValueError:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
existing = speakers_by_uid.get(recovered.uid)
|
|
31
|
+
speakers_by_uid[recovered.uid] = self._choose_preferred(existing, recovered)
|
|
32
|
+
break
|
|
33
|
+
|
|
34
|
+
recovered_speakers = sort_sonos_speakers(list(speakers_by_uid.values()))
|
|
35
|
+
if not recovered_speakers and snapshot.normalization_errors:
|
|
36
|
+
raise SonosDiscoveryError(
|
|
37
|
+
"Discovered Sonos speakers but failed to inspect any reachable speakers: "
|
|
38
|
+
f"{snapshot.normalization_errors[0]}"
|
|
39
|
+
)
|
|
40
|
+
return recovered_speakers
|
|
41
|
+
|
|
42
|
+
def _discover_runtime_snapshot(self) -> _SonosDiscoverySnapshot:
|
|
43
|
+
import soco
|
|
44
|
+
from requests.exceptions import RequestException
|
|
45
|
+
from soco.exceptions import SoCoException
|
|
46
|
+
from urllib3.exceptions import HTTPError
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
discovered = soco.discover()
|
|
50
|
+
except (HTTPError, OSError, RequestException, SoCoException) as err:
|
|
51
|
+
raise SonosDiscoveryError(f"Failed to discover Sonos speakers: {err}") from err
|
|
52
|
+
|
|
53
|
+
if not discovered:
|
|
54
|
+
return _SonosDiscoverySnapshot(
|
|
55
|
+
speakers=[],
|
|
56
|
+
retry_hosts_by_uid={},
|
|
57
|
+
normalization_errors=[],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
available_speakers = set(discovered)
|
|
61
|
+
for speaker in list(discovered):
|
|
62
|
+
try:
|
|
63
|
+
available_speakers.update(speaker.all_zones)
|
|
64
|
+
except Exception:
|
|
65
|
+
available_speakers.add(speaker)
|
|
66
|
+
|
|
67
|
+
speakers_by_uid = {}
|
|
68
|
+
retry_hosts_by_uid = {}
|
|
69
|
+
normalization_errors = []
|
|
70
|
+
for speaker in available_speakers:
|
|
71
|
+
expected_uid = _safe_speaker_uid(speaker)
|
|
72
|
+
normalized, error = self._normalize_speaker(speaker)
|
|
73
|
+
if normalized is None:
|
|
74
|
+
if error is not None:
|
|
75
|
+
normalization_errors.append(error)
|
|
76
|
+
if expected_uid is not None:
|
|
77
|
+
host = _safe_speaker_host(speaker)
|
|
78
|
+
if host is not None:
|
|
79
|
+
retry_hosts_by_uid.setdefault(expected_uid, set()).add(host)
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
existing = speakers_by_uid.get(normalized.uid)
|
|
83
|
+
speakers_by_uid[normalized.uid] = self._choose_preferred(existing, normalized)
|
|
84
|
+
|
|
85
|
+
return _SonosDiscoverySnapshot(
|
|
86
|
+
speakers=sort_sonos_speakers(list(speakers_by_uid.values())),
|
|
87
|
+
retry_hosts_by_uid={uid: sorted(hosts) for uid, hosts in sorted(retry_hosts_by_uid.items())},
|
|
88
|
+
normalization_errors=normalization_errors,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
def _resolve_speaker_by_host(self, expected_uid: str, host: str) -> DiscoveredSonosSpeaker:
|
|
92
|
+
from requests.exceptions import RequestException
|
|
93
|
+
from soco import SoCo
|
|
94
|
+
from soco.exceptions import SoCoException, SoCoUPnPException
|
|
95
|
+
from urllib3.exceptions import HTTPError
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
speaker = SoCo(host)
|
|
99
|
+
resolved_uid = speaker.uid
|
|
100
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
101
|
+
raise ValueError(f"Failed to contact saved Sonos speaker at {host}: {err}") from err
|
|
102
|
+
|
|
103
|
+
if resolved_uid != expected_uid:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Saved Sonos speaker UID mismatch for host {host}: expected {expected_uid}, resolved {resolved_uid}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
return DiscoveredSonosSpeaker(
|
|
110
|
+
uid=speaker.uid,
|
|
111
|
+
name=speaker.player_name,
|
|
112
|
+
host=speaker.ip_address,
|
|
113
|
+
household_id=speaker.household_id,
|
|
114
|
+
is_visible=getattr(speaker, "is_visible", True) is not False,
|
|
115
|
+
)
|
|
116
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
117
|
+
raise ValueError(f"Failed to inspect discovered Sonos speaker at {host}: {err}") from err
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _choose_preferred(
|
|
121
|
+
existing: Optional[DiscoveredSonosSpeaker],
|
|
122
|
+
candidate: DiscoveredSonosSpeaker,
|
|
123
|
+
) -> DiscoveredSonosSpeaker:
|
|
124
|
+
if existing is None:
|
|
125
|
+
return candidate
|
|
126
|
+
if candidate.is_visible and not existing.is_visible:
|
|
127
|
+
return candidate
|
|
128
|
+
if existing.is_visible and not candidate.is_visible:
|
|
129
|
+
return existing
|
|
130
|
+
if (candidate.name, candidate.host, candidate.uid) < (existing.name, existing.host, existing.uid):
|
|
131
|
+
return candidate
|
|
132
|
+
return existing
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _normalize_speaker(
|
|
136
|
+
speaker: "_SonosSpeakerLike",
|
|
137
|
+
) -> tuple[Optional[DiscoveredSonosSpeaker], Optional[str]]:
|
|
138
|
+
from requests.exceptions import RequestException
|
|
139
|
+
from soco.exceptions import SoCoException, SoCoUPnPException
|
|
140
|
+
from urllib3.exceptions import HTTPError
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
return (
|
|
144
|
+
DiscoveredSonosSpeaker(
|
|
145
|
+
uid=speaker.uid,
|
|
146
|
+
name=speaker.player_name,
|
|
147
|
+
host=speaker.ip_address,
|
|
148
|
+
household_id=speaker.household_id,
|
|
149
|
+
is_visible=getattr(speaker, "is_visible", True) is not False,
|
|
150
|
+
),
|
|
151
|
+
None,
|
|
152
|
+
)
|
|
153
|
+
except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
|
|
154
|
+
return (
|
|
155
|
+
None,
|
|
156
|
+
"{}: {}".format(
|
|
157
|
+
_safe_speaker_identifier(speaker),
|
|
158
|
+
err,
|
|
159
|
+
),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _SonosSpeakerLike(Protocol):
|
|
164
|
+
uid: str
|
|
165
|
+
player_name: str
|
|
166
|
+
ip_address: str
|
|
167
|
+
household_id: str
|
|
168
|
+
all_zones: set[Any]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _safe_speaker_identifier(speaker: "_SonosSpeakerLike") -> str:
|
|
172
|
+
ip_address = _safe_speaker_host(speaker)
|
|
173
|
+
if ip_address:
|
|
174
|
+
return ip_address
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
uid = getattr(speaker, "uid")
|
|
178
|
+
except Exception:
|
|
179
|
+
return "unknown speaker"
|
|
180
|
+
|
|
181
|
+
return str(uid)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _safe_speaker_host(speaker: "_SonosSpeakerLike") -> Optional[str]:
|
|
185
|
+
try:
|
|
186
|
+
ip_address = getattr(speaker, "ip_address", None)
|
|
187
|
+
except Exception:
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
if ip_address:
|
|
191
|
+
return str(ip_address)
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _safe_speaker_uid(speaker: "_SonosSpeakerLike") -> Optional[str]:
|
|
196
|
+
try:
|
|
197
|
+
return str(getattr(speaker, "uid"))
|
|
198
|
+
except Exception:
|
|
199
|
+
return None
|
|
@@ -22,9 +22,18 @@ from jukebox.shared.config_utils import get_package_version
|
|
|
22
22
|
from jukebox.shared.logger import set_logger
|
|
23
23
|
|
|
24
24
|
from .cli_presentation import render_cli_error
|
|
25
|
-
from .command_handlers import
|
|
26
|
-
from .commands import
|
|
27
|
-
|
|
25
|
+
from .command_handlers import execute_server_command, execute_settings_command, execute_sonos_command
|
|
26
|
+
from .commands import (
|
|
27
|
+
ApiCommand,
|
|
28
|
+
SettingsResetCommand,
|
|
29
|
+
SettingsSetCommand,
|
|
30
|
+
SettingsShowCommand,
|
|
31
|
+
SonosListCommand,
|
|
32
|
+
UiCommand,
|
|
33
|
+
is_settings_command,
|
|
34
|
+
is_sonos_command,
|
|
35
|
+
)
|
|
36
|
+
from .di_container import build_admin_api_app, build_admin_services, build_admin_ui_app, build_settings_service
|
|
28
37
|
|
|
29
38
|
LOGGER = logging.getLogger("jukebox-admin")
|
|
30
39
|
|
|
@@ -52,20 +61,29 @@ def _run_command(ctx: typer.Context, command: object) -> None:
|
|
|
52
61
|
state = _get_state(ctx)
|
|
53
62
|
|
|
54
63
|
try:
|
|
55
|
-
|
|
64
|
+
services = build_admin_services(
|
|
56
65
|
library=state.library,
|
|
57
66
|
command=command,
|
|
58
67
|
logger_warning=LOGGER.warning,
|
|
59
68
|
)
|
|
60
69
|
try:
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
if is_settings_command(command):
|
|
71
|
+
execute_settings_command(
|
|
72
|
+
command=command,
|
|
73
|
+
settings_service=services.settings,
|
|
74
|
+
source_command="jukebox-admin",
|
|
75
|
+
)
|
|
76
|
+
elif is_sonos_command(command):
|
|
77
|
+
execute_sonos_command(command=command, sonos_service=services.sonos)
|
|
78
|
+
else:
|
|
79
|
+
execute_server_command(
|
|
80
|
+
verbose=state.verbose,
|
|
81
|
+
command=command,
|
|
82
|
+
services=services,
|
|
83
|
+
build_api_app=build_admin_api_app,
|
|
84
|
+
build_ui_app=build_admin_ui_app,
|
|
85
|
+
source_command="jukebox-admin",
|
|
86
|
+
)
|
|
69
87
|
except RuntimeError as err:
|
|
70
88
|
typer.echo(str(err), err=True)
|
|
71
89
|
raise typer.Exit(code=1)
|
|
@@ -131,8 +149,10 @@ def _exit_on_command_validation_error(err: ValidationError) -> None:
|
|
|
131
149
|
app = typer.Typer(help="Admin CLI for jukebox")
|
|
132
150
|
settings_app = typer.Typer(help="Inspect and manage application settings")
|
|
133
151
|
library_app = typer.Typer(help="Manage the library")
|
|
152
|
+
sonos_app = typer.Typer(help="Inspect Sonos speakers discovered on the network")
|
|
134
153
|
app.add_typer(settings_app, name="settings")
|
|
135
154
|
app.add_typer(library_app, name="library")
|
|
155
|
+
app.add_typer(sonos_app, name="sonos")
|
|
136
156
|
|
|
137
157
|
|
|
138
158
|
@app.callback()
|
|
@@ -238,6 +258,11 @@ def ui(
|
|
|
238
258
|
_run_command(ctx, UiCommand(type="ui", port=port))
|
|
239
259
|
|
|
240
260
|
|
|
261
|
+
@sonos_app.command("list")
|
|
262
|
+
def sonos_list(ctx: typer.Context) -> None:
|
|
263
|
+
_run_command(ctx, SonosListCommand(type="sonos_list"))
|
|
264
|
+
|
|
265
|
+
|
|
241
266
|
@library_app.command("add")
|
|
242
267
|
def library_add(
|
|
243
268
|
ctx: typer.Context,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import re
|
|
3
3
|
import shlex
|
|
4
|
-
from typing import Dict, Iterable, List, Optional, Tuple, cast
|
|
4
|
+
from typing import Dict, Iterable, List, Mapping, Optional, Tuple, cast
|
|
5
5
|
|
|
6
6
|
from jukebox.settings.definitions import SETTINGS, get_setting_definition, is_editable_setting_path
|
|
7
7
|
from jukebox.settings.errors import (
|
|
@@ -12,6 +12,7 @@ from jukebox.settings.errors import (
|
|
|
12
12
|
)
|
|
13
13
|
from jukebox.settings.types import JsonObject, JsonValue
|
|
14
14
|
from jukebox.settings.view_utils import MISSING, lookup_object, lookup_optional_dotted_path, lookup_provenance_label
|
|
15
|
+
from jukebox.sonos.discovery import DiscoveredSonosSpeaker
|
|
15
16
|
|
|
16
17
|
from .commands import SettingsResetCommand, SettingsSetCommand, SettingsShowCommand
|
|
17
18
|
|
|
@@ -21,19 +22,20 @@ _VALIDATION_SUFFIX_RE = re.compile(r"\s+\[type=.*$")
|
|
|
21
22
|
|
|
22
23
|
def render_settings_output(
|
|
23
24
|
command: object,
|
|
24
|
-
payload:
|
|
25
|
+
payload: Mapping[str, object],
|
|
25
26
|
) -> str:
|
|
26
27
|
if isinstance(command, SettingsShowCommand):
|
|
27
28
|
if command.json_output:
|
|
28
29
|
return json.dumps(payload, indent=2)
|
|
30
|
+
settings_payload = cast(JsonObject, payload)
|
|
29
31
|
if command.effective:
|
|
30
|
-
return _render_effective_settings(
|
|
31
|
-
return _render_persisted_settings(
|
|
32
|
+
return _render_effective_settings(settings_payload)
|
|
33
|
+
return _render_persisted_settings(settings_payload)
|
|
32
34
|
|
|
33
35
|
if isinstance(command, (SettingsSetCommand, SettingsResetCommand)):
|
|
34
36
|
if command.json_output:
|
|
35
37
|
return json.dumps(payload, indent=2)
|
|
36
|
-
return _render_write_result(payload)
|
|
38
|
+
return _render_write_result(cast(JsonObject, payload))
|
|
37
39
|
|
|
38
40
|
raise TypeError("Unsupported settings command")
|
|
39
41
|
|
|
@@ -52,6 +54,25 @@ def render_cli_error(err: BaseException, verbose: bool = False) -> str:
|
|
|
52
54
|
return message
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
def render_sonos_speakers_output(speakers: list[DiscoveredSonosSpeaker]) -> str:
|
|
58
|
+
if not speakers:
|
|
59
|
+
return "No visible Sonos speakers found."
|
|
60
|
+
|
|
61
|
+
name_width = max(len(speaker.name) for speaker in speakers)
|
|
62
|
+
host_width = max(len(speaker.host) for speaker in speakers)
|
|
63
|
+
return "\n".join(
|
|
64
|
+
"{index}. {name:<{name_width}} {host:<{host_width}} {uid}".format(
|
|
65
|
+
index=index,
|
|
66
|
+
name=speaker.name,
|
|
67
|
+
name_width=name_width,
|
|
68
|
+
host=speaker.host,
|
|
69
|
+
host_width=host_width,
|
|
70
|
+
uid=speaker.uid,
|
|
71
|
+
)
|
|
72
|
+
for index, speaker in enumerate(speakers, start=1)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
55
76
|
def _render_persisted_settings(payload: JsonObject) -> str:
|
|
56
77
|
lines = ["Persisted Settings", "Schema Version: {}".format(payload.get("schema_version", "unknown"))]
|
|
57
78
|
entries = list(_collect_persisted_entries(payload))
|
|
@@ -4,9 +4,22 @@ from typing import Callable, Optional, Protocol
|
|
|
4
4
|
|
|
5
5
|
from jukebox.settings.service_protocols import SettingsService
|
|
6
6
|
from jukebox.shared.dependency_messages import optional_extra_dependency_message
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
from .
|
|
7
|
+
from jukebox.sonos.service import SonosService
|
|
8
|
+
|
|
9
|
+
from .cli_presentation import (
|
|
10
|
+
build_discstore_settings_deprecation_warning,
|
|
11
|
+
render_settings_output,
|
|
12
|
+
render_sonos_speakers_output,
|
|
13
|
+
)
|
|
14
|
+
from .commands import (
|
|
15
|
+
ApiCommand,
|
|
16
|
+
SettingsResetCommand,
|
|
17
|
+
SettingsSetCommand,
|
|
18
|
+
SettingsShowCommand,
|
|
19
|
+
SonosListCommand,
|
|
20
|
+
UiCommand,
|
|
21
|
+
)
|
|
22
|
+
from .services import AdminServices
|
|
10
23
|
|
|
11
24
|
|
|
12
25
|
class AppController(Protocol):
|
|
@@ -33,27 +46,24 @@ def _load_uvicorn(command_name: str, extra_name: str, source_command: str):
|
|
|
33
46
|
|
|
34
47
|
|
|
35
48
|
def _build_server_app(
|
|
36
|
-
build_app: Callable[[str,
|
|
49
|
+
build_app: Callable[[str, AdminServices], AppController],
|
|
37
50
|
library_path: str,
|
|
38
|
-
|
|
51
|
+
services: AdminServices,
|
|
39
52
|
command_name: str,
|
|
40
53
|
extra_name: str,
|
|
41
54
|
source_command: str,
|
|
42
55
|
):
|
|
43
56
|
try:
|
|
44
|
-
return build_app(library_path,
|
|
57
|
+
return build_app(library_path, services)
|
|
45
58
|
except ModuleNotFoundError as err:
|
|
46
59
|
if err.name in {"fastapi", "fastui"} or "requires the optional" in str(err):
|
|
47
60
|
_raise_optional_extra_error(command_name, extra_name, source_command, err)
|
|
48
61
|
raise
|
|
49
62
|
|
|
50
63
|
|
|
51
|
-
def
|
|
52
|
-
verbose: bool,
|
|
64
|
+
def execute_settings_command(
|
|
53
65
|
command: object,
|
|
54
66
|
settings_service: SettingsService,
|
|
55
|
-
build_api_app: Callable[[str, SettingsService], AppController],
|
|
56
|
-
build_ui_app: Callable[[str, SettingsService], AppController],
|
|
57
67
|
source_command: str,
|
|
58
68
|
library: Optional[str] = None,
|
|
59
69
|
stdout_fn: Callable[[str], None] = print,
|
|
@@ -84,14 +94,37 @@ def execute_admin_command(
|
|
|
84
94
|
stdout_fn(render_settings_output(command, payload))
|
|
85
95
|
return
|
|
86
96
|
|
|
87
|
-
|
|
97
|
+
raise TypeError("Unsupported settings command")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def execute_sonos_command(
|
|
101
|
+
command: object,
|
|
102
|
+
sonos_service: SonosService,
|
|
103
|
+
stdout_fn: Callable[[str], None] = print,
|
|
104
|
+
) -> None:
|
|
105
|
+
if isinstance(command, SonosListCommand):
|
|
106
|
+
stdout_fn(render_sonos_speakers_output(sonos_service.list_available_speakers()))
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
raise TypeError("Unsupported Sonos command")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def execute_server_command(
|
|
113
|
+
verbose: bool,
|
|
114
|
+
command: object,
|
|
115
|
+
services: AdminServices,
|
|
116
|
+
build_api_app: Callable[[str, AdminServices], AppController],
|
|
117
|
+
build_ui_app: Callable[[str, AdminServices], AppController],
|
|
118
|
+
source_command: str,
|
|
119
|
+
) -> None:
|
|
120
|
+
runtime_config = services.settings.resolve_admin_runtime(verbose=verbose)
|
|
88
121
|
|
|
89
122
|
if isinstance(command, ApiCommand):
|
|
90
123
|
uvicorn = _load_uvicorn("api", "api", source_command)
|
|
91
124
|
api = _build_server_app(
|
|
92
125
|
build_app=build_api_app,
|
|
93
126
|
library_path=runtime_config.library_path,
|
|
94
|
-
|
|
127
|
+
services=services,
|
|
95
128
|
command_name="api",
|
|
96
129
|
extra_name="api",
|
|
97
130
|
source_command=source_command,
|
|
@@ -104,7 +137,7 @@ def execute_admin_command(
|
|
|
104
137
|
ui = _build_server_app(
|
|
105
138
|
build_app=build_ui_app,
|
|
106
139
|
library_path=runtime_config.library_path,
|
|
107
|
-
|
|
140
|
+
services=services,
|
|
108
141
|
command_name="ui",
|
|
109
142
|
extra_name="ui",
|
|
110
143
|
source_command=source_command,
|
|
@@ -112,4 +145,4 @@ def execute_admin_command(
|
|
|
112
145
|
uvicorn.run(ui.app, host="0.0.0.0", port=runtime_config.ui_port)
|
|
113
146
|
return
|
|
114
147
|
|
|
115
|
-
raise TypeError("Unsupported
|
|
148
|
+
raise TypeError("Unsupported server command")
|
|
@@ -18,6 +18,10 @@ class UiCommand(BaseModel):
|
|
|
18
18
|
port: Optional[int] = None
|
|
19
19
|
|
|
20
20
|
|
|
21
|
+
class SonosListCommand(BaseModel):
|
|
22
|
+
type: Literal["sonos_list"]
|
|
23
|
+
|
|
24
|
+
|
|
21
25
|
class SettingsShowCommand(BaseModel):
|
|
22
26
|
type: Literal["settings_show"]
|
|
23
27
|
effective: bool = False
|
|
@@ -42,6 +46,7 @@ AdminCommand = Union[
|
|
|
42
46
|
SettingsResetCommand,
|
|
43
47
|
SettingsSetCommand,
|
|
44
48
|
SettingsShowCommand,
|
|
49
|
+
SonosListCommand,
|
|
45
50
|
UiCommand,
|
|
46
51
|
]
|
|
47
52
|
|
|
@@ -54,6 +59,7 @@ def is_admin_command(command: object) -> bool:
|
|
|
54
59
|
SettingsResetCommand,
|
|
55
60
|
SettingsSetCommand,
|
|
56
61
|
SettingsShowCommand,
|
|
62
|
+
SonosListCommand,
|
|
57
63
|
UiCommand,
|
|
58
64
|
),
|
|
59
65
|
)
|
|
@@ -68,3 +74,7 @@ def is_settings_command(command: object) -> bool:
|
|
|
68
74
|
SettingsShowCommand,
|
|
69
75
|
),
|
|
70
76
|
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_sonos_command(command: object) -> bool:
|
|
80
|
+
return isinstance(command, SonosListCommand)
|