gukebox 1.9.1__tar.gz → 1.10.0.dev2__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.9.1 → gukebox-1.10.0.dev2}/PKG-INFO +8 -10
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/README.md +4 -4
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/backup_archive.py +53 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/export_router.py +57 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/import_router.py +200 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/models.py +38 -1
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/pn532_router.py +102 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/sonos_router.py +75 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api_controller.py +121 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_controller.py +21 -10
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/cli_display.py +112 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/interactive_cli_controller.py +200 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-BPFMVFmr.js +5 -0
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-FlN0CmqN.css +1 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/index.html +3 -3
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/ui_controller.py +31 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/json_library_adapter.py +31 -2
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/app.py +23 -2
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/command_handlers.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/di_container.py +20 -3
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/library_commands.py +4 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_command_handlers.py +8 -18
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/services.py +1 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/__init__.py +2 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/disc.py +13 -2
- gukebox-1.10.0.dev2/jukebox/domain/entities/library_import_summary.py +10 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/library_repository.py +13 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/__init__.py +3 -0
- gukebox-1.10.0.dev2/jukebox/domain/use_cases/library/import_library.py +27 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/resolve_tag_id.py +8 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/pn532/profiles.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/definitions.py +9 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/entities.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/file_settings_repository.py +10 -14
- gukebox-1.10.0.dev2/jukebox/settings/persisted_validation.py +16 -0
- gukebox-1.10.0.dev2/jukebox/settings/pn532_configuration.py +39 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/resolve.py +96 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/service_protocols.py +5 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pyproject.toml +13 -16
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pyproject.toml.orig +12 -13
- gukebox-1.9.1/jukebox/adapters/inbound/admin/api_controller.py +0 -194
- gukebox-1.9.1/jukebox/adapters/inbound/admin/cli_display.py +0 -63
- gukebox-1.9.1/jukebox/adapters/inbound/admin/interactive_cli_controller.py +0 -136
- gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-BzFrC-C3.js +0 -4
- gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-pWJnAHA9.css +0 -1
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_controller.py +0 -362
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/library.py +0 -381
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/settings.py +0 -525
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -501
- gukebox-1.9.1/jukebox/shared/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/LICENSE +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages → gukebox-1.10.0.dev2/jukebox/adapters/outbound}/__init__.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound → gukebox-1.10.0.dev2/jukebox/adapters/outbound/players}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound/players → gukebox-1.10.0.dev2/jukebox/adapters/outbound/readers}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/cli_presentation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/commands.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/library_command_handlers.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_commands.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/sonos_households.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/app.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/di_container.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound/readers → gukebox-1.10.0.dev2/jukebox/domain}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_state.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/playback_state.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.9.1/jukebox/domain → gukebox-1.10.0.dev2/jukebox/domain/use_cases/library}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/add_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/list_discs.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/search_discs.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_playback.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/pn532/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/migration.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_resolver.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/types.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.9.1/jukebox/domain/use_cases/library → gukebox-1.10.0.dev2/jukebox/shared}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/logger.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/terminal_ui.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/timing.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/discovery.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/selection.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/service.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pn532/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pn532/pn532.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pn532/spi.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: gukebox
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10.0.dev2
|
|
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.
|
|
35
|
+
Requires-Dist: pydantic==2.14.1
|
|
36
36
|
Requires-Dist: questionary==2.1.1
|
|
37
|
-
Requires-Dist: soco==0.
|
|
37
|
+
Requires-Dist: soco==0.32.0
|
|
38
38
|
Requires-Dist: typer==0.27.3
|
|
39
|
-
Requires-Dist: fastapi==0.
|
|
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
|
|
@@ -61,7 +59,7 @@ Pick an album, place it on the reader, and the music starts playing. Remove it,
|
|
|
61
59
|
|
|
62
60
|
[](https://pypi.python.org/pypi/gukebox)
|
|
63
61
|
[](https://pypi.python.org/pypi/gukebox)
|
|
64
|
-
[](https://github.com/gudsfile/jukebox/actions)
|
|
65
63
|
|
|
66
64
|

|
|
67
65
|
[](https://pypi.python.org/pypi/gukebox) [](https://github.com/astral-sh/uv)
|
|
@@ -168,9 +166,9 @@ Launch the web UI (requires the `ui` extra):
|
|
|
168
166
|
jukebox-admin ui
|
|
169
167
|
```
|
|
170
168
|
|
|
171
|
-

|
|
172
170
|
|
|
173
|
-

|
|
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
|
|
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
|
|
|
@@ -8,7 +8,7 @@ Pick an album, place it on the reader, and the music starts playing. Remove it,
|
|
|
8
8
|
|
|
9
9
|
[](https://pypi.python.org/pypi/gukebox)
|
|
10
10
|
[](https://pypi.python.org/pypi/gukebox)
|
|
11
|
-
[](https://github.com/gudsfile/jukebox/actions)
|
|
12
12
|
|
|
13
13
|

|
|
14
14
|
[](https://pypi.python.org/pypi/gukebox) [](https://github.com/astral-sh/uv)
|
|
@@ -115,9 +115,9 @@ Launch the web UI (requires the `ui` extra):
|
|
|
115
115
|
jukebox-admin ui
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-

|
|
119
119
|
|
|
120
|
-

|
|
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
|
|
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,53 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import zipfile
|
|
3
|
+
|
|
4
|
+
LIBRARY_ENTRY = "library.json"
|
|
5
|
+
SETTINGS_ENTRY = "settings.json"
|
|
6
|
+
EXPECTED_ENTRIES = (LIBRARY_ENTRY, SETTINGS_ENTRY)
|
|
7
|
+
# Both files are small JSON documents: refuse anything bigger before decompressing it.
|
|
8
|
+
MAX_ENTRY_SIZE_BYTES = 10 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvalidBackupArchiveError(ValueError):
|
|
12
|
+
"""Raised when an uploaded backup archive does not hold exactly the expected files."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_backup_archive(library: bytes, settings: bytes) -> bytes:
|
|
16
|
+
buffer = io.BytesIO()
|
|
17
|
+
with zipfile.ZipFile(buffer, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
|
18
|
+
archive.writestr(LIBRARY_ENTRY, library)
|
|
19
|
+
archive.writestr(SETTINGS_ENTRY, settings)
|
|
20
|
+
return buffer.getvalue()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_backup_archive(content: bytes) -> tuple[bytes, bytes]:
|
|
24
|
+
"""Return the library and settings files of a backup archive, as stored in it."""
|
|
25
|
+
try:
|
|
26
|
+
archive = zipfile.ZipFile(io.BytesIO(content))
|
|
27
|
+
except zipfile.BadZipFile as err:
|
|
28
|
+
raise InvalidBackupArchiveError("The file is not a zip archive.") from err
|
|
29
|
+
|
|
30
|
+
with archive:
|
|
31
|
+
names = archive.namelist()
|
|
32
|
+
missing = [name for name in EXPECTED_ENTRIES if name not in names]
|
|
33
|
+
extra = sorted({name for name in names if name not in EXPECTED_ENTRIES})
|
|
34
|
+
if missing or extra or len(names) != len(EXPECTED_ENTRIES):
|
|
35
|
+
problems = []
|
|
36
|
+
if missing:
|
|
37
|
+
problems.append(f"missing {', '.join(missing)}")
|
|
38
|
+
if extra:
|
|
39
|
+
problems.append(f"unexpected {', '.join(extra)}")
|
|
40
|
+
if not problems:
|
|
41
|
+
problems.append("duplicated entries")
|
|
42
|
+
raise InvalidBackupArchiveError(
|
|
43
|
+
f"The archive must hold exactly {LIBRARY_ENTRY} and {SETTINGS_ENTRY}: {'; '.join(problems)}."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
for name in EXPECTED_ENTRIES:
|
|
47
|
+
if archive.getinfo(name).file_size > MAX_ENTRY_SIZE_BYTES:
|
|
48
|
+
raise InvalidBackupArchiveError(f"{name} is too large.")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
return archive.read(LIBRARY_ENTRY), archive.read(SETTINGS_ENTRY)
|
|
52
|
+
except (zipfile.BadZipFile, OSError) as err:
|
|
53
|
+
raise InvalidBackupArchiveError(f"The archive is corrupted: {err}") from err
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from datetime import date
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from fastapi.responses import FileResponse, Response
|
|
8
|
+
|
|
9
|
+
from jukebox.adapters.inbound.admin.api.backup_archive import build_backup_archive
|
|
10
|
+
from jukebox.domain.entities import Library
|
|
11
|
+
from jukebox.settings.migration import CURRENT_SETTINGS_SCHEMA_VERSION
|
|
12
|
+
|
|
13
|
+
# A missing file is a normal state (settings never changed, no disc yet): the archive holds what the app reads then.
|
|
14
|
+
DEFAULT_LIBRARY = json.dumps(Library().model_dump(), indent=2).encode()
|
|
15
|
+
DEFAULT_SETTINGS = json.dumps({"schema_version": CURRENT_SETTINGS_SCHEMA_VERSION}, indent=2).encode()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _file_download(path: str, filename: str, missing_detail: str) -> FileResponse:
|
|
19
|
+
# Serve the bytes as stored on disk: a backup must not be rebuilt from parsed (or migrated) data.
|
|
20
|
+
if not os.path.isfile(path):
|
|
21
|
+
raise HTTPException(status_code=404, detail=missing_detail)
|
|
22
|
+
return FileResponse(path, media_type="application/json", filename=filename)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_file(path: str, default: bytes) -> bytes:
|
|
26
|
+
try:
|
|
27
|
+
with open(path, "rb") as file_obj:
|
|
28
|
+
return file_obj.read()
|
|
29
|
+
except FileNotFoundError:
|
|
30
|
+
return default
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_export_router(library_path: str, settings_path: str, today: Callable[[], date] = date.today) -> APIRouter:
|
|
34
|
+
router = APIRouter(prefix="/api/v1/export", tags=["export"])
|
|
35
|
+
|
|
36
|
+
@router.get("/library", summary="Download the library file")
|
|
37
|
+
def export_library() -> FileResponse:
|
|
38
|
+
return _file_download(library_path, f"library-{today().isoformat()}.json", "No library file to export.")
|
|
39
|
+
|
|
40
|
+
@router.get("/settings", summary="Download the persisted settings file")
|
|
41
|
+
def export_settings() -> FileResponse:
|
|
42
|
+
return _file_download(
|
|
43
|
+
settings_path, f"settings-{today().isoformat()}.json", "No persisted settings file to export."
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
@router.get("/all", summary="Download the library and the persisted settings files as one zip archive")
|
|
47
|
+
def export_all() -> Response:
|
|
48
|
+
archive = build_backup_archive(
|
|
49
|
+
_read_file(library_path, DEFAULT_LIBRARY),
|
|
50
|
+
_read_file(settings_path, DEFAULT_SETTINGS),
|
|
51
|
+
)
|
|
52
|
+
filename = f"jukebox-backup-{today().isoformat()}.zip"
|
|
53
|
+
return Response(
|
|
54
|
+
archive, media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
return router
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
7
|
+
|
|
8
|
+
from jukebox.adapters.inbound.admin.api.backup_archive import (
|
|
9
|
+
LIBRARY_ENTRY,
|
|
10
|
+
SETTINGS_ENTRY,
|
|
11
|
+
InvalidBackupArchiveError,
|
|
12
|
+
read_backup_archive,
|
|
13
|
+
)
|
|
14
|
+
from jukebox.domain.entities import Disc, Library, LibraryImportSummary
|
|
15
|
+
from jukebox.domain.repositories import LibraryRepository
|
|
16
|
+
from jukebox.domain.use_cases import ImportLibrary, PreviewLibraryImport
|
|
17
|
+
from jukebox.settings.errors import SettingsError
|
|
18
|
+
from jukebox.settings.service_protocols import SettingsService
|
|
19
|
+
from jukebox.settings.types import JsonObject, JsonValue
|
|
20
|
+
|
|
21
|
+
LOGGER = logging.getLogger("jukebox")
|
|
22
|
+
|
|
23
|
+
# The body is the uploaded file itself, read raw so that a file that is not JSON gets a clear 400.
|
|
24
|
+
_FILE_BODY: dict[str, Any] = {
|
|
25
|
+
"requestBody": {"required": True, "content": {"application/json": {"schema": {"type": "object"}}}}
|
|
26
|
+
}
|
|
27
|
+
_ARCHIVE_BODY: dict[str, Any] = {
|
|
28
|
+
"requestBody": {
|
|
29
|
+
"required": True,
|
|
30
|
+
"content": {"application/zip": {"schema": {"type": "string", "format": "binary"}}},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class _LibraryFile(BaseModel):
|
|
36
|
+
"""An uploaded library file: unlike a stored library, it must hold `discs` and nothing else.
|
|
37
|
+
|
|
38
|
+
This keeps a settings file, picked by mistake, from replacing the library with an empty one.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(strict=True, extra="forbid")
|
|
42
|
+
discs: dict[str, Disc]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_json(content: bytes, source: str = "") -> JsonValue:
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(content)
|
|
48
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
|
49
|
+
raise HTTPException(status_code=400, detail=f"{source}The file is not valid JSON: {err}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_library(content: bytes, source: str = "") -> Library:
|
|
53
|
+
try:
|
|
54
|
+
return Library(discs=_LibraryFile.model_validate(_parse_json(content, source)).discs)
|
|
55
|
+
except ValidationError as err:
|
|
56
|
+
raise HTTPException(status_code=400, detail=f"{source}Invalid library file: {err}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _read_library_file(request: Request) -> Library:
|
|
60
|
+
return _parse_library(await request.body())
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_import_router(
|
|
64
|
+
library_repository: LibraryRepository,
|
|
65
|
+
preview_library_import: PreviewLibraryImport,
|
|
66
|
+
import_library: ImportLibrary,
|
|
67
|
+
settings_service: SettingsService,
|
|
68
|
+
) -> APIRouter:
|
|
69
|
+
router = APIRouter(prefix="/api/v1/import", tags=["import"])
|
|
70
|
+
|
|
71
|
+
@router.post(
|
|
72
|
+
"/library/preview",
|
|
73
|
+
response_model=LibraryImportSummary,
|
|
74
|
+
summary="Validate a library file and summarize what importing it would change",
|
|
75
|
+
openapi_extra=_FILE_BODY,
|
|
76
|
+
)
|
|
77
|
+
async def preview_library(request: Request) -> LibraryImportSummary:
|
|
78
|
+
library = await _read_library_file(request)
|
|
79
|
+
try:
|
|
80
|
+
return preview_library_import.execute(library)
|
|
81
|
+
except Exception as err:
|
|
82
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
83
|
+
|
|
84
|
+
@router.post(
|
|
85
|
+
"/library",
|
|
86
|
+
response_model=LibraryImportSummary,
|
|
87
|
+
summary="Replace the library with a library file",
|
|
88
|
+
openapi_extra=_FILE_BODY,
|
|
89
|
+
)
|
|
90
|
+
async def import_library_file(request: Request) -> LibraryImportSummary:
|
|
91
|
+
library = await _read_library_file(request)
|
|
92
|
+
try:
|
|
93
|
+
summary = preview_library_import.execute(library)
|
|
94
|
+
import_library.execute(library)
|
|
95
|
+
return summary
|
|
96
|
+
except Exception as err:
|
|
97
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
98
|
+
|
|
99
|
+
@router.post(
|
|
100
|
+
"/settings/preview",
|
|
101
|
+
response_model=dict[str, Any],
|
|
102
|
+
summary="Validate a settings file and list the settings importing it would change",
|
|
103
|
+
openapi_extra=_FILE_BODY,
|
|
104
|
+
)
|
|
105
|
+
async def preview_settings(request: Request) -> JsonObject:
|
|
106
|
+
raw_data = _parse_json(await request.body())
|
|
107
|
+
try:
|
|
108
|
+
return settings_service.preview_settings_import(raw_data)
|
|
109
|
+
except SettingsError as err:
|
|
110
|
+
raise HTTPException(status_code=400, detail=str(err))
|
|
111
|
+
except Exception as err:
|
|
112
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
113
|
+
|
|
114
|
+
@router.post(
|
|
115
|
+
"/settings",
|
|
116
|
+
response_model=dict[str, Any],
|
|
117
|
+
summary="Replace the persisted settings with a settings file",
|
|
118
|
+
openapi_extra=_FILE_BODY,
|
|
119
|
+
)
|
|
120
|
+
async def import_settings(request: Request) -> JsonObject:
|
|
121
|
+
raw_data = _parse_json(await request.body())
|
|
122
|
+
try:
|
|
123
|
+
return settings_service.import_persisted_settings(raw_data)
|
|
124
|
+
except SettingsError as err:
|
|
125
|
+
raise HTTPException(status_code=400, detail=str(err))
|
|
126
|
+
except Exception as err:
|
|
127
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
128
|
+
|
|
129
|
+
async def read_archive(request: Request) -> tuple[Library, JsonValue]:
|
|
130
|
+
try:
|
|
131
|
+
library_content, settings_content = read_backup_archive(await request.body())
|
|
132
|
+
except InvalidBackupArchiveError as err:
|
|
133
|
+
raise HTTPException(status_code=400, detail=str(err))
|
|
134
|
+
library = _parse_library(library_content, f"{LIBRARY_ENTRY}: ")
|
|
135
|
+
settings_data = _parse_json(settings_content, f"{SETTINGS_ENTRY}: ")
|
|
136
|
+
return library, settings_data
|
|
137
|
+
|
|
138
|
+
def preview_archive(library: Library, settings_data: JsonValue) -> JsonObject:
|
|
139
|
+
try:
|
|
140
|
+
settings_summary = settings_service.preview_settings_import(settings_data)
|
|
141
|
+
except SettingsError as err:
|
|
142
|
+
raise HTTPException(status_code=400, detail=f"{SETTINGS_ENTRY}: {err}")
|
|
143
|
+
return {
|
|
144
|
+
"library": preview_library_import.execute(library).model_dump(mode="json"),
|
|
145
|
+
"settings": settings_summary,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@router.post(
|
|
149
|
+
"/all/preview",
|
|
150
|
+
response_model=dict[str, Any],
|
|
151
|
+
summary="Validate a backup archive and summarize what importing it would change",
|
|
152
|
+
openapi_extra=_ARCHIVE_BODY,
|
|
153
|
+
)
|
|
154
|
+
async def preview_all(request: Request) -> JsonObject:
|
|
155
|
+
library, settings_data = await read_archive(request)
|
|
156
|
+
try:
|
|
157
|
+
return preview_archive(library, settings_data)
|
|
158
|
+
except HTTPException:
|
|
159
|
+
raise
|
|
160
|
+
except Exception as err:
|
|
161
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
162
|
+
|
|
163
|
+
@router.post(
|
|
164
|
+
"/all",
|
|
165
|
+
response_model=dict[str, Any],
|
|
166
|
+
summary="Replace the library and the persisted settings with a backup archive",
|
|
167
|
+
openapi_extra=_ARCHIVE_BODY,
|
|
168
|
+
)
|
|
169
|
+
async def import_all(request: Request) -> JsonObject:
|
|
170
|
+
library, settings_data = await read_archive(request)
|
|
171
|
+
try:
|
|
172
|
+
# Validate both files before writing any of them.
|
|
173
|
+
summary = preview_archive(library, settings_data)
|
|
174
|
+
# Write both files or none: keep the library file as is, even corrupted, to put it back.
|
|
175
|
+
previous_library = library_repository.snapshot()
|
|
176
|
+
import_library.execute(library)
|
|
177
|
+
try:
|
|
178
|
+
summary["settings"] = settings_service.import_persisted_settings(settings_data)
|
|
179
|
+
except Exception as err:
|
|
180
|
+
try:
|
|
181
|
+
library_repository.restore(previous_library)
|
|
182
|
+
except Exception as rollback_err:
|
|
183
|
+
LOGGER.exception("Could not put the previous library back")
|
|
184
|
+
raise HTTPException(
|
|
185
|
+
status_code=500,
|
|
186
|
+
detail=(
|
|
187
|
+
f"The library was replaced but the settings were not: {err!s}. "
|
|
188
|
+
f"Putting the previous library back failed: {rollback_err!s}"
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
raise
|
|
192
|
+
return summary
|
|
193
|
+
except HTTPException:
|
|
194
|
+
raise
|
|
195
|
+
except SettingsError as err:
|
|
196
|
+
raise HTTPException(status_code=400, detail=f"{SETTINGS_ENTRY}: {err}")
|
|
197
|
+
except Exception as err:
|
|
198
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
199
|
+
|
|
200
|
+
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
|