gukebox 1.10.0.dev1__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.10.0.dev1 → gukebox-1.10.0.dev2}/PKG-INFO +2 -2
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/README.md +1 -1
- 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.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api_controller.py +23 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +34 -17
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-34dQf4uL.js → gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-BPFMVFmr.js +4 -4
- gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-FlN0CmqN.css +1 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/index.html +2 -2
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/json_library_adapter.py +31 -2
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/di_container.py +8 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/__init__.py +2 -0
- gukebox-1.10.0.dev2/jukebox/domain/entities/library_import_summary.py +10 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/library_repository.py +13 -1
- {gukebox-1.10.0.dev1 → 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.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/definitions.py +9 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/file_settings_repository.py +4 -9
- gukebox-1.10.0.dev2/jukebox/settings/persisted_validation.py +16 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/resolve.py +96 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/service_protocols.py +5 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pyproject.toml +2 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pyproject.toml.orig +2 -1
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/export_router.py +0 -29
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-CioYPxa4.css +0 -1
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/LICENSE +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/models.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/pn532_router.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/sonos_router.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_controller.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_display.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/ui_controller.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/app.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/cli_presentation.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/command_handlers.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/commands.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/library_command_handlers.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/library_commands.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_command_handlers.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_commands.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/services.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/sonos_households.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/app.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/di_container.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_state.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/disc.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/playback_state.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/errors.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/add_disc.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_disc.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/list_discs.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/resolve_tag_id.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/search_discs.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_playback.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/pn532/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/pn532/profiles.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/entities.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/errors.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/migration.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/pn532_configuration.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_resolver.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/types.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/errors.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/logger.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/terminal_ui.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/timing.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/discovery.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/selection.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/service.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pn532/__init__.py +0 -0
- {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pn532/pn532.py +0 -0
- {gukebox-1.10.0.dev1 → 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.10.0.
|
|
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
|
|
@@ -59,7 +59,7 @@ Pick an album, place it on the reader, and the music starts playing. Remove it,
|
|
|
59
59
|
|
|
60
60
|
[](https://pypi.python.org/pypi/gukebox)
|
|
61
61
|
[](https://pypi.python.org/pypi/gukebox)
|
|
62
|
-
[](https://github.com/gudsfile/jukebox/actions)
|
|
63
63
|
|
|
64
64
|

|
|
65
65
|
[](https://pypi.python.org/pypi/gukebox) [](https://github.com/astral-sh/uv)
|
|
@@ -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)
|
|
@@ -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
|
{gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api_controller.py
RENAMED
|
@@ -6,6 +6,7 @@ try:
|
|
|
6
6
|
from jukebox.adapters.inbound.admin.api.current_tag_router import build_current_tag_router
|
|
7
7
|
from jukebox.adapters.inbound.admin.api.discs_router import build_discs_router
|
|
8
8
|
from jukebox.adapters.inbound.admin.api.export_router import build_export_router
|
|
9
|
+
from jukebox.adapters.inbound.admin.api.import_router import build_import_router
|
|
9
10
|
from jukebox.adapters.inbound.admin.api.models import (
|
|
10
11
|
CurrentTagDiscOutput,
|
|
11
12
|
CurrentTagStatusOutput,
|
|
@@ -23,7 +24,17 @@ except ModuleNotFoundError as e:
|
|
|
23
24
|
if e.name != "fastapi":
|
|
24
25
|
raise
|
|
25
26
|
raise MissingOptionalDependencyError("The `api_controller` module", "api", "jukebox-admin api") from e
|
|
26
|
-
from jukebox.domain.
|
|
27
|
+
from jukebox.domain.repositories import LibraryRepository
|
|
28
|
+
from jukebox.domain.use_cases import (
|
|
29
|
+
AddDisc,
|
|
30
|
+
EditDisc,
|
|
31
|
+
GetCurrentTagStatus,
|
|
32
|
+
GetDisc,
|
|
33
|
+
ImportLibrary,
|
|
34
|
+
ListDiscs,
|
|
35
|
+
PreviewLibraryImport,
|
|
36
|
+
RemoveDisc,
|
|
37
|
+
)
|
|
27
38
|
from jukebox.settings.service_protocols import SettingsService
|
|
28
39
|
from jukebox.sonos.service import SonosService
|
|
29
40
|
|
|
@@ -53,6 +64,9 @@ class APIController:
|
|
|
53
64
|
sonos_service: SonosService,
|
|
54
65
|
library_path: str,
|
|
55
66
|
settings_path: str,
|
|
67
|
+
preview_library_import: PreviewLibraryImport,
|
|
68
|
+
import_library: ImportLibrary,
|
|
69
|
+
library_repository: LibraryRepository,
|
|
56
70
|
):
|
|
57
71
|
self.add_disc = add_disc
|
|
58
72
|
self.list_discs = list_discs
|
|
@@ -64,6 +78,9 @@ class APIController:
|
|
|
64
78
|
self.sonos_service = sonos_service
|
|
65
79
|
self.library_path = library_path
|
|
66
80
|
self.settings_path = settings_path
|
|
81
|
+
self.preview_library_import = preview_library_import
|
|
82
|
+
self.import_library = import_library
|
|
83
|
+
self.library_repository = library_repository
|
|
67
84
|
self.app = FastAPI(
|
|
68
85
|
title="Jukebox Admin API",
|
|
69
86
|
description="API for managing Jukebox disc library and settings",
|
|
@@ -97,3 +114,8 @@ class APIController:
|
|
|
97
114
|
)
|
|
98
115
|
self.app.include_router(build_pn532_router(self.settings_service))
|
|
99
116
|
self.app.include_router(build_export_router(self.library_path, self.settings_path))
|
|
117
|
+
self.app.include_router(
|
|
118
|
+
build_import_router(
|
|
119
|
+
self.library_repository, self.preview_library_import, self.import_library, self.settings_service
|
|
120
|
+
)
|
|
121
|
+
)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Literal
|
|
1
|
+
from typing import Literal
|
|
2
2
|
|
|
3
3
|
import typer
|
|
4
4
|
|
|
@@ -69,7 +69,9 @@ class InteractiveCLIController:
|
|
|
69
69
|
current_tag_status = self.get_current_tag_status.execute()
|
|
70
70
|
tag = self._prompt_for_tag(current_tag_status, action="add")
|
|
71
71
|
uri = self._prompt_for_uri()
|
|
72
|
-
|
|
72
|
+
metadata = self._prompt_for_metadata()
|
|
73
|
+
option = self._prompt_for_option()
|
|
74
|
+
disc = Disc(uri=uri, metadata=metadata, option=option)
|
|
73
75
|
self.add_disc.execute(tag, disc)
|
|
74
76
|
print("✅ Disc successfully added")
|
|
75
77
|
|
|
@@ -105,8 +107,11 @@ class InteractiveCLIController:
|
|
|
105
107
|
print("\n-- Edit a disc --")
|
|
106
108
|
current_tag_status = self.get_current_tag_status.execute()
|
|
107
109
|
tag = self._prompt_for_tag(current_tag_status, action="edit")
|
|
108
|
-
|
|
109
|
-
self.
|
|
110
|
+
current_disc = self.get_disc.execute(tag)
|
|
111
|
+
uri = self._prompt_for_uri(current_disc.uri)
|
|
112
|
+
metadata = self._prompt_for_metadata(current_disc.metadata)
|
|
113
|
+
option = self._prompt_for_option(current_disc.option)
|
|
114
|
+
self.edit_disc.execute(tag, uri, metadata, option)
|
|
110
115
|
print("✅ Disc successfully edited")
|
|
111
116
|
|
|
112
117
|
def get_disc_flow(self) -> None:
|
|
@@ -164,20 +169,32 @@ class InteractiveCLIController:
|
|
|
164
169
|
raise ValueError("A tag ID is required.")
|
|
165
170
|
return tag
|
|
166
171
|
|
|
167
|
-
|
|
168
|
-
|
|
172
|
+
def _prompt_for_uri(self, current: str | None = None) -> str:
|
|
173
|
+
import questionary
|
|
169
174
|
|
|
170
|
-
|
|
171
|
-
|
|
175
|
+
entered_uri = questionary.text("URI:", default=current or "").unsafe_ask()
|
|
176
|
+
uri = (entered_uri or "").strip() or current
|
|
177
|
+
if not uri:
|
|
178
|
+
raise ValueError("A URI is required.")
|
|
179
|
+
return uri
|
|
172
180
|
|
|
173
|
-
def
|
|
181
|
+
def _prompt_for_metadata(self, current: DiscMetadata | None = None) -> DiscMetadata:
|
|
182
|
+
"""Ask for each metadata field, prefilled with the current value; an empty answer means no value."""
|
|
174
183
|
import questionary
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
185
|
+
hint = " (optional)" if current is None else " (empty to clear)"
|
|
186
|
+
current = current or DiscMetadata()
|
|
187
|
+
values = {}
|
|
188
|
+
for field in ("artist", "album", "track", "playlist"):
|
|
189
|
+
entered = questionary.text(
|
|
190
|
+
f"{field.capitalize()}{hint}:", default=getattr(current, field) or ""
|
|
191
|
+
).unsafe_ask()
|
|
192
|
+
values[field] = (entered or "").strip() or None
|
|
193
|
+
return DiscMetadata(**values)
|
|
194
|
+
|
|
195
|
+
def _prompt_for_option(self, current: DiscOption | None = None) -> DiscOption:
|
|
196
|
+
import questionary
|
|
197
|
+
|
|
198
|
+
current = current or DiscOption()
|
|
199
|
+
shuffle = questionary.confirm("Shuffle?", default=current.shuffle).unsafe_ask()
|
|
200
|
+
return DiscOption(shuffle=shuffle)
|