gukebox 1.0.0.dev4__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.
Files changed (103) hide show
  1. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/PKG-INFO +1 -1
  2. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/api_controller.py +17 -0
  3. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/ui_controller.py +11 -1
  4. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/app.py +25 -12
  5. gukebox-1.0.0.dev5/jukebox/adapters/outbound/sonos_discovery_adapter.py +199 -0
  6. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/app.py +37 -12
  7. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/cli_presentation.py +26 -5
  8. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/command_handlers.py +47 -14
  9. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/commands.py +10 -0
  10. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/di_container.py +27 -4
  11. gukebox-1.0.0.dev5/jukebox/admin/services.py +10 -0
  12. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/app.py +9 -1
  13. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/__init__.py +0 -3
  14. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/resolve.py +8 -61
  15. gukebox-1.0.0.dev5/jukebox/settings/runtime_resolver.py +59 -0
  16. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/service_protocols.py +8 -2
  17. gukebox-1.0.0.dev5/jukebox/sonos/__init__.py +16 -0
  18. gukebox-1.0.0.dev5/jukebox/sonos/discovery.py +25 -0
  19. gukebox-1.0.0.dev5/jukebox/sonos/service.py +73 -0
  20. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/pyproject.toml +1 -1
  21. gukebox-1.0.0.dev4/jukebox/settings/sonos_runtime.py +0 -175
  22. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/LICENSE +0 -0
  23. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/README.md +0 -0
  24. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/__init__.py +0 -0
  25. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/__init__.py +0 -0
  26. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/__init__.py +0 -0
  27. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/cli_controller.py +0 -0
  28. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/cli_display.py +0 -0
  29. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/config.py +0 -0
  30. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  31. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/__init__.py +0 -0
  32. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  33. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  34. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/command_handlers.py +0 -0
  35. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/commands.py +0 -0
  36. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/di_container.py +0 -0
  37. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/__init__.py +0 -0
  38. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/entities/__init__.py +0 -0
  39. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/entities/current_tag_status.py +0 -0
  40. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/repositories/__init__.py +0 -0
  41. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/__init__.py +0 -0
  42. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/add_disc.py +0 -0
  43. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/edit_disc.py +0 -0
  44. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  45. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/get_disc.py +0 -0
  46. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/list_discs.py +0 -0
  47. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/remove_disc.py +0 -0
  48. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  49. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/discstore/domain/use_cases/search_discs.py +0 -0
  50. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/__init__.py +0 -0
  51. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/__init__.py +0 -0
  52. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/__init__.py +0 -0
  53. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/cli_controller.py +0 -0
  54. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/inbound/config.py +0 -0
  55. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/__init__.py +0 -0
  56. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  57. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/__init__.py +0 -0
  58. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  59. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  60. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  61. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  62. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/readers/nfc_reader_adapter.py +0 -0
  63. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  64. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/admin/__init__.py +0 -0
  65. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/di_container.py +0 -0
  66. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/__init__.py +0 -0
  67. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/__init__.py +0 -0
  68. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/current_tag_action.py +0 -0
  69. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/disc.py +0 -0
  70. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/library.py +0 -0
  71. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/playback_action.py +0 -0
  72. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/playback_session.py +0 -0
  73. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/entities/tag_event.py +0 -0
  74. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/ports/__init__.py +0 -0
  75. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/ports/player_port.py +0 -0
  76. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/ports/reader_port.py +0 -0
  77. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/__init__.py +0 -0
  78. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  79. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/repositories/library_repository.py +0 -0
  80. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/__init__.py +0 -0
  81. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/determine_action.py +0 -0
  82. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  83. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  84. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/definitions.py +0 -0
  85. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/dict_utils.py +0 -0
  86. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/entities.py +0 -0
  87. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/errors.py +0 -0
  88. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/file_settings_repository.py +0 -0
  89. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/migration.py +0 -0
  90. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/repositories.py +0 -0
  91. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/runtime_validation.py +0 -0
  92. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/timing_validation.py +0 -0
  93. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/types.py +0 -0
  94. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/validation_rules.py +0 -0
  95. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/settings/view_utils.py +0 -0
  96. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/shared/__init__.py +0 -0
  97. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/shared/config_utils.py +0 -0
  98. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/shared/dependency_messages.py +0 -0
  99. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/shared/logger.py +0 -0
  100. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/jukebox/shared/timing.py +0 -0
  101. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/pn532/__init__.py +0 -0
  102. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/pn532/pn532.py +0 -0
  103. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev5}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev4
3
+ Version: 1.0.0.dev5
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,discstore,music,nfc
6
6
  Author: Gudsfile
@@ -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__(add_disc, list_discs, remove_disc, edit_disc, get_current_tag_status, settings_service)
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 execute_admin_command
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
- execute_admin_command(
44
- verbose=config.verbose,
45
- command=config.command,
46
- settings_service=settings_service,
47
- build_api_app=build_admin_api_app,
48
- build_ui_app=build_admin_ui_app,
49
- source_command="discstore",
50
- library=config.library,
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 execute_admin_command
26
- from .commands import ApiCommand, SettingsResetCommand, SettingsSetCommand, SettingsShowCommand, UiCommand
27
- from .di_container import build_admin_api_app, build_admin_ui_app, build_settings_service
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
- settings_service = build_settings_service(
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
- execute_admin_command(
62
- verbose=state.verbose,
63
- command=command,
64
- settings_service=settings_service,
65
- build_api_app=build_admin_api_app,
66
- build_ui_app=build_admin_ui_app,
67
- source_command="jukebox-admin",
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: JsonObject,
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(payload)
31
- return _render_persisted_settings(payload)
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
- from .cli_presentation import build_discstore_settings_deprecation_warning, render_settings_output
9
- from .commands import ApiCommand, SettingsResetCommand, SettingsSetCommand, SettingsShowCommand, UiCommand
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, SettingsService], AppController],
49
+ build_app: Callable[[str, AdminServices], AppController],
37
50
  library_path: str,
38
- settings_service: SettingsService,
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, settings_service)
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 execute_admin_command(
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
- runtime_config = settings_service.resolve_admin_runtime(verbose=verbose)
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
- settings_service=settings_service,
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
- settings_service=settings_service,
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 admin command")
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)