gukebox 1.9.0__tar.gz → 1.10.0.dev1__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 (122) hide show
  1. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/PKG-INFO +7 -9
  2. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/README.md +3 -3
  3. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/export_router.py +29 -0
  4. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/models.py +38 -1
  5. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/pn532_router.py +102 -0
  6. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/sonos_router.py +75 -0
  7. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api_controller.py +99 -0
  8. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/cli_controller.py +21 -10
  9. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/cli_display.py +112 -0
  10. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +54 -7
  11. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-34dQf4uL.js +5 -0
  12. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-CioYPxa4.css +1 -0
  13. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/static/index.html +3 -3
  14. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/ui_controller.py +31 -0
  15. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/app.py +23 -2
  16. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/command_handlers.py +1 -1
  17. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/di_container.py +12 -3
  18. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/library_commands.py +4 -0
  19. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/pn532_command_handlers.py +8 -18
  20. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/services.py +1 -0
  21. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/disc.py +13 -2
  22. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/resolve_tag_id.py +8 -1
  23. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/pn532/profiles.py +1 -1
  24. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/entities.py +1 -1
  25. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/file_settings_repository.py +6 -5
  26. gukebox-1.10.0.dev1/jukebox/settings/pn532_configuration.py +39 -0
  27. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/pyproject.toml +12 -16
  28. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/pyproject.toml.orig +11 -13
  29. gukebox-1.9.0/jukebox/adapters/inbound/admin/api_controller.py +0 -194
  30. gukebox-1.9.0/jukebox/adapters/inbound/admin/cli_display.py +0 -63
  31. gukebox-1.9.0/jukebox/adapters/inbound/admin/static/assets/index-BzFrC-C3.js +0 -4
  32. gukebox-1.9.0/jukebox/adapters/inbound/admin/static/assets/index-pWJnAHA9.css +0 -1
  33. gukebox-1.9.0/jukebox/adapters/inbound/admin/ui_controller.py +0 -362
  34. gukebox-1.9.0/jukebox/adapters/inbound/admin/ui_pages/library.py +0 -381
  35. gukebox-1.9.0/jukebox/adapters/inbound/admin/ui_pages/settings.py +0 -525
  36. gukebox-1.9.0/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -501
  37. gukebox-1.9.0/jukebox/shared/__init__.py +0 -0
  38. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/LICENSE +0 -0
  39. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/__init__.py +0 -0
  40. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/__init__.py +0 -0
  41. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/__init__.py +0 -0
  42. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/__init__.py +0 -0
  43. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
  44. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
  45. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
  46. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
  47. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
  48. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/cli_controller.py +0 -0
  49. {gukebox-1.9.0/jukebox/adapters/inbound/admin/ui_pages → gukebox-1.10.0.dev1/jukebox/adapters/outbound}/__init__.py +0 -0
  50. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  51. {gukebox-1.9.0/jukebox/adapters/outbound → gukebox-1.10.0.dev1/jukebox/adapters/outbound/players}/__init__.py +0 -0
  52. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  53. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  54. {gukebox-1.9.0/jukebox/adapters/outbound/players → gukebox-1.10.0.dev1/jukebox/adapters/outbound/readers}/__init__.py +0 -0
  55. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  56. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  57. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  58. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  59. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/__init__.py +0 -0
  60. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/cli_presentation.py +0 -0
  61. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/commands.py +0 -0
  62. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/library_command_handlers.py +0 -0
  63. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/pn532_commands.py +0 -0
  64. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/admin/sonos_households.py +0 -0
  65. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/app.py +0 -0
  66. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/di_container.py +0 -0
  67. {gukebox-1.9.0/jukebox/adapters/outbound/readers → gukebox-1.10.0.dev1/jukebox/domain}/__init__.py +0 -0
  68. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/__init__.py +0 -0
  69. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/current_tag_state.py +0 -0
  70. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/current_tag_status.py +0 -0
  71. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/library.py +0 -0
  72. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/playback_state.py +0 -0
  73. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/entities/tag_event.py +0 -0
  74. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/errors.py +0 -0
  75. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/ports/__init__.py +0 -0
  76. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/ports/player_port.py +0 -0
  77. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/ports/reader_port.py +0 -0
  78. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/__init__.py +0 -0
  79. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  80. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/library_repository.py +0 -0
  81. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/__init__.py +0 -0
  82. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  83. {gukebox-1.9.0/jukebox/domain → gukebox-1.10.0.dev1/jukebox/domain/use_cases/library}/__init__.py +0 -0
  84. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/add_disc.py +0 -0
  85. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
  86. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
  87. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/get_disc.py +0 -0
  88. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/list_discs.py +0 -0
  89. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
  90. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/search_discs.py +0 -0
  91. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
  92. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
  93. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/transition_playback.py +0 -0
  94. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/pn532/__init__.py +0 -0
  95. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/__init__.py +0 -0
  96. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/definitions.py +0 -0
  97. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/dict_utils.py +0 -0
  98. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/errors.py +0 -0
  99. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/migration.py +0 -0
  100. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/repositories.py +0 -0
  101. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/resolve.py +0 -0
  102. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/runtime_resolver.py +0 -0
  103. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/runtime_validation.py +0 -0
  104. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  105. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/service_protocols.py +0 -0
  106. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/timing_validation.py +0 -0
  107. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/types.py +0 -0
  108. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/validation_rules.py +0 -0
  109. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/settings/view_utils.py +0 -0
  110. {gukebox-1.9.0/jukebox/domain/use_cases/library → gukebox-1.10.0.dev1/jukebox/shared}/__init__.py +0 -0
  111. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/shared/config_utils.py +0 -0
  112. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/shared/errors.py +0 -0
  113. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/shared/logger.py +0 -0
  114. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/shared/terminal_ui.py +0 -0
  115. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/shared/timing.py +0 -0
  116. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/sonos/__init__.py +0 -0
  117. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/sonos/discovery.py +0 -0
  118. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/sonos/selection.py +0 -0
  119. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/jukebox/sonos/service.py +0 -0
  120. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/pn532/__init__.py +0 -0
  121. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/pn532/pn532.py +0 -0
  122. {gukebox-1.9.0 → gukebox-1.10.0.dev1}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.9.0
3
+ Version: 1.10.0.dev1
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,music,nfc
6
6
  Author: Gudsfile
@@ -32,18 +32,16 @@ Classifier: Programming Language :: Python :: 3 :: Only
32
32
  Classifier: Programming Language :: Python :: 3.11
33
33
  Classifier: Programming Language :: Python :: 3.12
34
34
  Classifier: Programming Language :: Python :: 3.13
35
- Requires-Dist: pydantic==2.14.0
35
+ Requires-Dist: pydantic==2.14.1
36
36
  Requires-Dist: questionary==2.1.1
37
- Requires-Dist: soco==0.31.5
37
+ Requires-Dist: soco==0.32.0
38
38
  Requires-Dist: typer==0.27.3
39
- Requires-Dist: fastapi==0.143.0 ; extra == 'api'
39
+ Requires-Dist: fastapi==0.162.0 ; extra == 'api'
40
40
  Requires-Dist: uvicorn==0.54.0 ; extra == 'api'
41
41
  Requires-Dist: pyserial==3.5 ; extra == 'pn532'
42
42
  Requires-Dist: spidev==3.8 ; extra == 'pn532'
43
43
  Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'pn532'
44
44
  Requires-Dist: gukebox[api] ; extra == 'ui'
45
- Requires-Dist: fastui==0.9.0 ; extra == 'ui'
46
- Requires-Dist: python-multipart==0.0.32 ; extra == 'ui'
47
45
  Requires-Python: >=3.11, <3.14
48
46
  Project-URL: Repository, https://github.com/Gudsfile/jukebox
49
47
  Provides-Extra: api
@@ -168,9 +166,9 @@ Launch the web UI (requires the `ui` extra):
168
166
  jukebox-admin ui
169
167
  ```
170
168
 
171
- ![Jukebox Admin UI showing a disc library table with three entries and a yellow warning banner indicating an unknown NFC tag is on the reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
169
+ ![Jukebox Admin UI showing the disc library table, with the floating Reader panel reporting an unknown disc on the NFC reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
172
170
 
173
- ![Add disc form with the NFC tag ID pre-filled, and empty fields for URI, artist, album, track and shuffle](docs/img/ui-add-disc-form.png)
171
+ ![Add disc form with the Tag ID pre-filled from the reader, and empty fields for URI / Path, artist, album, track, playlist and shuffle](docs/img/ui-add-disc-form.png)
174
172
 
175
173
  ## Usage
176
174
 
@@ -242,7 +240,7 @@ uv run --extra ui jukebox-admin ui
242
240
  ```
243
241
 
244
242
  > [!NOTE]
245
- > The admin UI is being migrated from FastUI to a Svelte SPA (see `admin-ui/`). Both currently coexist within the `jukebox-admin ui` process: FastUI serves `/`, while the new SPA is mounted at `/ui`.
243
+ > The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
246
244
 
247
245
  #### `library`
248
246
 
@@ -115,9 +115,9 @@ Launch the web UI (requires the `ui` extra):
115
115
  jukebox-admin ui
116
116
  ```
117
117
 
118
- ![Jukebox Admin UI showing a disc library table with three entries and a yellow warning banner indicating an unknown NFC tag is on the reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
118
+ ![Jukebox Admin UI showing the disc library table, with the floating Reader panel reporting an unknown disc on the NFC reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
119
119
 
120
- ![Add disc form with the NFC tag ID pre-filled, and empty fields for URI, artist, album, track and shuffle](docs/img/ui-add-disc-form.png)
120
+ ![Add disc form with the Tag ID pre-filled from the reader, and empty fields for URI / Path, artist, album, track, playlist and shuffle](docs/img/ui-add-disc-form.png)
121
121
 
122
122
  ## Usage
123
123
 
@@ -189,7 +189,7 @@ uv run --extra ui jukebox-admin ui
189
189
  ```
190
190
 
191
191
  > [!NOTE]
192
- > The admin UI is being migrated from FastUI to a Svelte SPA (see `admin-ui/`). Both currently coexist within the `jukebox-admin ui` process: FastUI serves `/`, while the new SPA is mounted at `/ui`.
192
+ > The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
193
193
 
194
194
  #### `library`
195
195
 
@@ -0,0 +1,29 @@
1
+ import os
2
+ from collections.abc import Callable
3
+ from datetime import date
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from fastapi.responses import FileResponse
7
+
8
+
9
+ def _file_download(path: str, filename: str, missing_detail: str) -> FileResponse:
10
+ # Serve the bytes as stored on disk: a backup must not be rebuilt from parsed (or migrated) data.
11
+ if not os.path.isfile(path):
12
+ raise HTTPException(status_code=404, detail=missing_detail)
13
+ return FileResponse(path, media_type="application/json", filename=filename)
14
+
15
+
16
+ def build_export_router(library_path: str, settings_path: str, today: Callable[[], date] = date.today) -> APIRouter:
17
+ router = APIRouter(prefix="/api/v1/export", tags=["export"])
18
+
19
+ @router.get("/library", summary="Download the library file")
20
+ def export_library() -> FileResponse:
21
+ return _file_download(library_path, f"library-{today().isoformat()}.json", "No library file to export.")
22
+
23
+ @router.get("/settings", summary="Download the persisted settings file")
24
+ def export_settings() -> FileResponse:
25
+ return _file_download(
26
+ settings_path, f"settings-{today().isoformat()}.json", "No persisted settings file to export."
27
+ )
28
+
29
+ return router
@@ -3,6 +3,8 @@ from typing import Any
3
3
  from pydantic import BaseModel, RootModel, computed_field
4
4
 
5
5
  from jukebox.domain.entities import CurrentTagStatus, Disc
6
+ from jukebox.settings.entities import SelectedSonosGroupSettings
7
+ from jukebox.sonos.discovery import DiscoveredSonosSpeaker
6
8
 
7
9
 
8
10
  class DiscInput(Disc):
@@ -30,7 +32,6 @@ class DiscPatchMetadataInput(BaseModel):
30
32
 
31
33
  class DiscPatchOptionInput(BaseModel):
32
34
  shuffle: bool | None = None
33
- is_test: bool | None = None
34
35
 
35
36
 
36
37
  class DiscPatchInput(BaseModel):
@@ -84,3 +85,39 @@ class EditableSettingDisplayOutput(BaseModel):
84
85
  class SettingsDisplaysOutput(BaseModel):
85
86
  settings: list[EditableSettingDisplayOutput]
86
87
  effective_settings_error: str | None = None
88
+
89
+
90
+ class SonosSpeakerOutput(DiscoveredSonosSpeaker):
91
+ pass
92
+
93
+
94
+ class SelectedSonosGroupOutput(SelectedSonosGroupSettings):
95
+ pass
96
+
97
+
98
+ class SonosSelectionMemberAvailabilityOutput(BaseModel):
99
+ uid: str
100
+ status: str
101
+ speaker: SonosSpeakerOutput | None = None
102
+
103
+
104
+ class SonosSelectionAvailabilityOutput(BaseModel):
105
+ status: str
106
+ members: list[SonosSelectionMemberAvailabilityOutput]
107
+
108
+
109
+ class SonosSelectionOutput(BaseModel):
110
+ selected_group: SelectedSonosGroupOutput | None = None
111
+ availability: SonosSelectionAvailabilityOutput
112
+
113
+
114
+ class SonosSelectionInput(BaseModel):
115
+ uids: list[str]
116
+ coordinator_uid: str | None = None
117
+
118
+
119
+ class SonosSelectionUpdateOutput(BaseModel):
120
+ selected_group: SelectedSonosGroupOutput
121
+ availability: SonosSelectionAvailabilityOutput
122
+ message: str
123
+ restart_required: bool
@@ -0,0 +1,102 @@
1
+ from fastapi import APIRouter, HTTPException
2
+ from pydantic import BaseModel
3
+
4
+ from jukebox.pn532.profiles import PN532_PROFILES, Pn532BoardProfile, SpiConnectionParams, resolve_connection_params
5
+ from jukebox.settings.definitions import get_setting_definition
6
+ from jukebox.settings.entities import Pn532SpiSettings
7
+ from jukebox.settings.errors import SettingsError
8
+ from jukebox.settings.pn532_configuration import save_pn532_configuration
9
+ from jukebox.settings.service_protocols import SettingsService
10
+
11
+
12
+ class Pn532PinsOutput(BaseModel):
13
+ reset: int | None
14
+ cs: int | None
15
+ irq: int | None
16
+
17
+
18
+ class Pn532ProfileOutput(BaseModel):
19
+ name: str
20
+ label: str
21
+ protocol: str
22
+ defaults: Pn532PinsOutput
23
+
24
+
25
+ class Pn532ConfigOutput(BaseModel):
26
+ board_profile: str
27
+ protocol: str
28
+ overrides: Pn532PinsOutput
29
+ resolved: Pn532PinsOutput
30
+ profiles: list[Pn532ProfileOutput]
31
+
32
+
33
+ class Pn532ConfigInput(BaseModel):
34
+ board_profile: Pn532BoardProfile
35
+ spi: Pn532SpiSettings
36
+
37
+
38
+ def _pins(params: SpiConnectionParams) -> Pn532PinsOutput:
39
+ return Pn532PinsOutput(reset=params.reset, cs=params.cs, irq=params.irq)
40
+
41
+
42
+ def _profile_labels() -> dict[str, str]:
43
+ definition = get_setting_definition("jukebox.reader.pn532.board_profile")
44
+ return {choice.value: choice.label for choice in definition.choices} if definition else {}
45
+
46
+
47
+ def build_pn532_router(settings_service: SettingsService) -> APIRouter:
48
+ router = APIRouter(prefix="/api/v1", tags=["pn532"])
49
+
50
+ @router.get(
51
+ "/pn532",
52
+ response_model=Pn532ConfigOutput,
53
+ summary="Get the effective PN532 config (profile defaults merged with pin overrides)",
54
+ )
55
+ def get_pn532() -> Pn532ConfigOutput:
56
+ try:
57
+ return _build_config_output(settings_service)
58
+ except Exception as err:
59
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
60
+
61
+ @router.put(
62
+ "/pn532",
63
+ response_model=Pn532ConfigOutput,
64
+ summary="Save the PN532 board profile and SPI pin overrides",
65
+ )
66
+ def put_pn532(payload: Pn532ConfigInput) -> Pn532ConfigOutput:
67
+ try:
68
+ save_pn532_configuration(
69
+ settings_service,
70
+ payload.board_profile,
71
+ PN532_PROFILES[payload.board_profile].default_protocol,
72
+ payload.spi.model_dump(),
73
+ )
74
+ return _build_config_output(settings_service)
75
+ except SettingsError as err:
76
+ raise HTTPException(status_code=400, detail=str(err))
77
+ except Exception as err:
78
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
79
+
80
+ return router
81
+
82
+
83
+ def _build_config_output(settings_service: SettingsService) -> Pn532ConfigOutput:
84
+ labels = _profile_labels()
85
+ pn532 = settings_service.get_effective_settings().jukebox.reader.pn532
86
+ overrides = SpiConnectionParams(reset=pn532.spi.reset, cs=pn532.spi.cs, irq=pn532.spi.irq)
87
+ resolved = resolve_connection_params(pn532.board_profile, pn532.protocol, overrides)
88
+ return Pn532ConfigOutput(
89
+ board_profile=pn532.board_profile,
90
+ protocol=pn532.protocol,
91
+ overrides=_pins(overrides),
92
+ resolved=_pins(resolved),
93
+ profiles=[
94
+ Pn532ProfileOutput(
95
+ name=name,
96
+ label=labels.get(name, name),
97
+ protocol=profile.default_protocol,
98
+ defaults=_pins(profile.connections[profile.default_protocol]),
99
+ )
100
+ for name, profile in PN532_PROFILES.items()
101
+ ],
102
+ )
@@ -0,0 +1,75 @@
1
+ from fastapi import APIRouter, HTTPException
2
+
3
+ from jukebox.adapters.inbound.admin.api.models import (
4
+ SelectedSonosGroupOutput,
5
+ SonosSelectionAvailabilityOutput,
6
+ SonosSelectionInput,
7
+ SonosSelectionMemberAvailabilityOutput,
8
+ SonosSelectionOutput,
9
+ SonosSelectionUpdateOutput,
10
+ SonosSpeakerOutput,
11
+ )
12
+ from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
13
+ from jukebox.settings.service_protocols import SettingsService
14
+ from jukebox.sonos.discovery import SonosDiscoveryError
15
+ from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection
16
+ from jukebox.sonos.service import SonosService
17
+
18
+
19
+ def build_sonos_router(settings_service: SettingsService, sonos_service: SonosService) -> APIRouter:
20
+ router = APIRouter(prefix="/api/v1", tags=["sonos"])
21
+
22
+ @router.get(
23
+ "/sonos/speakers", response_model=list[SonosSpeakerOutput], summary="List Sonos speakers on the network"
24
+ )
25
+ def get_sonos_speakers():
26
+ try:
27
+ return sonos_service.list_network_speakers()
28
+ except SonosDiscoveryError as err:
29
+ raise HTTPException(status_code=502, detail=str(err))
30
+ except Exception as err:
31
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
32
+
33
+ @router.get("/sonos/selection", response_model=SonosSelectionOutput, summary="Get the selected Sonos group")
34
+ def get_sonos_selection():
35
+ try:
36
+ return GetSonosSelectionStatus(
37
+ SettingsSelectedSonosGroupRepository(settings_service),
38
+ sonos_service,
39
+ ).execute()
40
+ except SonosDiscoveryError as err:
41
+ raise HTTPException(status_code=502, detail=str(err))
42
+ except Exception as err:
43
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
44
+
45
+ @router.put("/sonos/selection", response_model=SonosSelectionUpdateOutput, summary="Save the selected Sonos group")
46
+ def put_sonos_selection(payload: SonosSelectionInput):
47
+ try:
48
+ result = SaveSonosSelection(
49
+ SettingsSelectedSonosGroupRepository(settings_service),
50
+ sonos_service,
51
+ ).execute(payload.uids, coordinator_uid=payload.coordinator_uid)
52
+ return SonosSelectionUpdateOutput(
53
+ selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()),
54
+ availability=SonosSelectionAvailabilityOutput(
55
+ status="available",
56
+ members=[
57
+ SonosSelectionMemberAvailabilityOutput(
58
+ uid=member.uid,
59
+ status="available",
60
+ speaker=SonosSpeakerOutput(**member.model_dump()),
61
+ )
62
+ for member in result.members
63
+ ],
64
+ ),
65
+ message=result.settings_message,
66
+ restart_required=result.restart_required,
67
+ )
68
+ except SonosDiscoveryError as err:
69
+ raise HTTPException(status_code=502, detail=str(err))
70
+ except ValueError as err:
71
+ raise HTTPException(status_code=400, detail=str(err))
72
+ except Exception as err:
73
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
74
+
75
+ return router
@@ -0,0 +1,99 @@
1
+ from jukebox.shared.errors import MissingOptionalDependencyError
2
+
3
+ try:
4
+ from fastapi import FastAPI
5
+
6
+ from jukebox.adapters.inbound.admin.api.current_tag_router import build_current_tag_router
7
+ from jukebox.adapters.inbound.admin.api.discs_router import build_discs_router
8
+ from jukebox.adapters.inbound.admin.api.export_router import build_export_router
9
+ from jukebox.adapters.inbound.admin.api.models import (
10
+ CurrentTagDiscOutput,
11
+ CurrentTagStatusOutput,
12
+ DiscInput,
13
+ DiscOutput,
14
+ DiscPatchInput,
15
+ SettingsPatchInput,
16
+ SettingsResetInput,
17
+ SonosSelectionInput,
18
+ )
19
+ from jukebox.adapters.inbound.admin.api.pn532_router import build_pn532_router
20
+ from jukebox.adapters.inbound.admin.api.settings_router import build_settings_router
21
+ from jukebox.adapters.inbound.admin.api.sonos_router import build_sonos_router
22
+ except ModuleNotFoundError as e:
23
+ if e.name != "fastapi":
24
+ raise
25
+ raise MissingOptionalDependencyError("The `api_controller` module", "api", "jukebox-admin api") from e
26
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, ListDiscs, RemoveDisc
27
+ from jukebox.settings.service_protocols import SettingsService
28
+ from jukebox.sonos.service import SonosService
29
+
30
+ __all__ = [
31
+ "APIController",
32
+ "CurrentTagDiscOutput",
33
+ "CurrentTagStatusOutput",
34
+ "DiscInput",
35
+ "DiscOutput",
36
+ "DiscPatchInput",
37
+ "SettingsPatchInput",
38
+ "SettingsResetInput",
39
+ "SonosSelectionInput",
40
+ ]
41
+
42
+
43
+ class APIController:
44
+ def __init__(
45
+ self,
46
+ add_disc: AddDisc,
47
+ list_discs: ListDiscs,
48
+ remove_disc: RemoveDisc,
49
+ edit_disc: EditDisc,
50
+ get_disc: GetDisc,
51
+ get_current_tag_status: GetCurrentTagStatus,
52
+ settings_service: SettingsService,
53
+ sonos_service: SonosService,
54
+ library_path: str,
55
+ settings_path: str,
56
+ ):
57
+ self.add_disc = add_disc
58
+ self.list_discs = list_discs
59
+ self.remove_disc = remove_disc
60
+ self.edit_disc = edit_disc
61
+ self.get_disc = get_disc
62
+ self.get_current_tag_status = get_current_tag_status
63
+ self.settings_service = settings_service
64
+ self.sonos_service = sonos_service
65
+ self.library_path = library_path
66
+ self.settings_path = settings_path
67
+ self.app = FastAPI(
68
+ title="Jukebox Admin API",
69
+ description="API for managing Jukebox disc library and settings",
70
+ docs_url="/docs",
71
+ redoc_url="/redoc",
72
+ )
73
+ self.register_routes()
74
+
75
+ def register_routes(self):
76
+ self.app.include_router(
77
+ build_discs_router(
78
+ add_disc=self.add_disc,
79
+ list_discs=self.list_discs,
80
+ remove_disc=self.remove_disc,
81
+ edit_disc=self.edit_disc,
82
+ get_disc=self.get_disc,
83
+ )
84
+ )
85
+ self.app.include_router(
86
+ build_current_tag_router(
87
+ get_current_tag_status=self.get_current_tag_status,
88
+ add_disc=self.add_disc,
89
+ edit_disc=self.edit_disc,
90
+ get_disc=self.get_disc,
91
+ remove_disc=self.remove_disc,
92
+ )
93
+ )
94
+ self.app.include_router(build_settings_router(self.settings_service))
95
+ self.app.include_router(
96
+ build_sonos_router(settings_service=self.settings_service, sonos_service=self.sonos_service)
97
+ )
98
+ self.app.include_router(build_pn532_router(self.settings_service))
99
+ self.app.include_router(build_export_router(self.library_path, self.settings_path))
@@ -1,6 +1,13 @@
1
+ import sys
2
+
1
3
  import typer
2
4
 
3
- from jukebox.adapters.inbound.admin.cli_display import display_disc, display_library_line, display_library_table
5
+ from jukebox.adapters.inbound.admin.cli_display import (
6
+ build_remove_confirmation,
7
+ display_disc,
8
+ display_library_line,
9
+ display_library_table,
10
+ )
4
11
  from jukebox.admin.library_commands import (
5
12
  CliAddCommand,
6
13
  CliEditCommand,
@@ -51,7 +58,7 @@ class CLIController:
51
58
  self.search_discs_flow(command)
52
59
 
53
60
  def add_disc_flow(self, command: CliAddCommand) -> None:
54
- tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag)
61
+ tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
55
62
  option = DiscOption()
56
63
  metadata = DiscMetadata(track=command.track, artist=command.artist, album=command.album)
57
64
 
@@ -70,7 +77,14 @@ class CLIController:
70
77
  typer.echo(f"Displaying mode not implemented yet: mode='{command.mode}'", err=True)
71
78
 
72
79
  def remove_disc_flow(self, command: CliRemoveCommand) -> None:
73
- tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag)
80
+ tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
81
+ if not command.yes:
82
+ disc = self.get_disc.execute(tag)
83
+ if not sys.stdin.isatty():
84
+ raise ValueError(f"Refusing to remove disc '{tag}' without confirmation: pass --yes to confirm.")
85
+ if not typer.confirm(build_remove_confirmation(tag, disc), default=False):
86
+ typer.echo("Disc not removed")
87
+ return
74
88
  self.remove_disc.execute(tag)
75
89
  typer.echo("🗑️ Disc successfully removed")
76
90
 
@@ -86,7 +100,7 @@ class CLIController:
86
100
  metadata = DiscMetadata(**metadata_fields) if metadata_fields else None
87
101
 
88
102
  self.edit_disc.execute(
89
- tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag),
103
+ tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id),
90
104
  uri=command.uri,
91
105
  metadata=metadata,
92
106
  option=None,
@@ -94,12 +108,9 @@ class CLIController:
94
108
  typer.echo("✅ Disc successfully edited")
95
109
 
96
110
  def get_disc_flow(self, command: CliGetCommand) -> None:
97
- try:
98
- tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag)
99
- disc = self.get_disc.execute(tag)
100
- display_disc(tag, disc)
101
- except ValueError as e:
102
- typer.echo(str(e), err=True)
111
+ tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
112
+ disc = self.get_disc.execute(tag)
113
+ display_disc(tag, disc)
103
114
 
104
115
  def search_discs_flow(self, command: CliSearchCommand) -> None:
105
116
  results = self.search_discs.execute(command.query)
@@ -0,0 +1,112 @@
1
+ from collections.abc import Callable
2
+
3
+ from rich.cells import cell_len
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ from jukebox.domain.entities import Disc
8
+
9
+ MAX_COL_WIDTH = 20
10
+
11
+ # A new field also needs a place in TABLE_DROP_ORDER (and maybe TABLE_COL_BOUNDS) below
12
+ DISC_FIELDS: list[tuple[str, Callable[[Disc], str]]] = [
13
+ ("Type", lambda disc: disc.metadata.display_type),
14
+ ("Title", lambda disc: disc.metadata.display_title),
15
+ ("URI", lambda disc: disc.uri),
16
+ ("Artist", lambda disc: disc.metadata.artist or "/"),
17
+ ("Album", lambda disc: disc.metadata.album or "/"),
18
+ ("Track", lambda disc: disc.metadata.track or "/"),
19
+ ("Playlist", lambda disc: disc.metadata.playlist or "/"),
20
+ ("Shuffle", lambda disc: str(disc.option.shuffle)),
21
+ ]
22
+ LABEL_WIDTH = max(len(label) for label, _ in DISC_FIELDS)
23
+
24
+ # Table only: Type and Title get a guaranteed width, and Title may grow past MAX_COL_WIDTH.
25
+ # The raw fields (redundant with Title) shrink first, then disappear in TABLE_DROP_ORDER.
26
+ TABLE_COL_BOUNDS: dict[str, tuple[int, int]] = {"Type": (11, 11), "Title": (24, 40), "Shuffle": (5, 7)}
27
+ RAW_COL_BOUNDS = (8, MAX_COL_WIDTH)
28
+ TABLE_DROP_ORDER = ["Playlist", "Track", "Album", "Artist", "Shuffle", "URI"]
29
+ COL_CHROME = 3 # left border + padding on both sides
30
+
31
+
32
+ def _disc_lines(disc: Disc) -> list[str]:
33
+ return [f" {label:<{LABEL_WIDTH}} : {value(disc)}" for label, value in DISC_FIELDS]
34
+
35
+
36
+ def display_disc(tag_id: str, disc: Disc, console: Console | None = None) -> None:
37
+ _console = console or Console()
38
+ _console.print(f"\n📀 Disc: {tag_id}")
39
+ for line in _disc_lines(disc):
40
+ _console.print(line)
41
+
42
+
43
+ def build_remove_confirmation(tag_id: str, disc: Disc) -> str:
44
+ suffix = f" ({disc.metadata.display_title})" if disc.metadata.has_title else ""
45
+ return f'Delete disc "{tag_id}"{suffix}?'
46
+
47
+
48
+ def display_library_line(discs: dict[str, Disc]) -> None:
49
+ if not discs:
50
+ print("The library is empty")
51
+ return
52
+
53
+ print("=== Discs Library ===\n")
54
+ for disc_id, disc in discs.items():
55
+ print(f"ID : {disc_id}")
56
+ for line in _disc_lines(disc):
57
+ print(line)
58
+ print("-" * 30)
59
+
60
+
61
+ def display_library_table(discs: dict[str, Disc], console: Console | None = None) -> None:
62
+ _console = console or Console()
63
+ if not discs:
64
+ _console.print("The library is empty")
65
+ return
66
+
67
+ # The ID is what users copy into `library get`, so it never shrinks
68
+ id_width = max(cell_len(str(disc_id)) for disc_id in discs)
69
+ widths = _table_widths(discs, available=_console.width - 1 - (id_width + COL_CHROME))
70
+ fields = [(label, value) for label, value in DISC_FIELDS if label in widths]
71
+
72
+ table = Table(title="Discs Library")
73
+ table.add_column("ID", no_wrap=True, min_width=id_width)
74
+ for label, _ in fields:
75
+ table.add_column(label, no_wrap=True, width=widths[label])
76
+
77
+ for disc_id, disc in discs.items():
78
+ table.add_row(str(disc_id), *(value(disc) for _, value in fields))
79
+
80
+ _console.print(table)
81
+
82
+
83
+ def _table_widths(discs: dict[str, Disc], available: int) -> dict[str, int]:
84
+ """Width of each shown column, in DISC_FIELDS order, which is also the order they grow in."""
85
+ targets: dict[str, int] = {}
86
+ minimums: dict[str, int] = {}
87
+ for label, value in DISC_FIELDS:
88
+ low, high = TABLE_COL_BOUNDS.get(label, RAW_COL_BOUNDS)
89
+ natural = max(cell_len(label), *(cell_len(value(disc)) for disc in discs.values()))
90
+ targets[label] = min(natural, high)
91
+ minimums[label] = min(targets[label], low)
92
+
93
+ shown = [label for label, _ in DISC_FIELDS]
94
+ for label in TABLE_DROP_ORDER:
95
+ if sum(minimums[col] + COL_CHROME for col in shown) <= available:
96
+ break
97
+ shown.remove(label)
98
+
99
+ spare = available - sum(minimums[col] + COL_CHROME for col in shown)
100
+ widths = {}
101
+ for label in shown:
102
+ extra = min(max(spare, 0), targets[label] - minimums[label])
103
+ widths[label] = minimums[label] + extra
104
+ spare -= extra
105
+ # Only Type and Title are left and still too wide (very long ID): give up their minimums
106
+ for label in reversed(shown):
107
+ if spare >= 0:
108
+ break
109
+ cut = min(-spare, widths[label] - 1)
110
+ widths[label] -= cut
111
+ spare += cut
112
+ return widths