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.
Files changed (120) hide show
  1. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/PKG-INFO +2 -2
  2. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/README.md +1 -1
  3. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/backup_archive.py +53 -0
  4. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/export_router.py +57 -0
  5. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/import_router.py +200 -0
  6. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api_controller.py +23 -1
  7. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +34 -17
  8. 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
  9. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-FlN0CmqN.css +1 -0
  10. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/index.html +2 -2
  11. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/json_library_adapter.py +31 -2
  12. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/di_container.py +8 -0
  13. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/__init__.py +2 -0
  14. gukebox-1.10.0.dev2/jukebox/domain/entities/library_import_summary.py +10 -0
  15. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/library_repository.py +13 -1
  16. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/__init__.py +3 -0
  17. gukebox-1.10.0.dev2/jukebox/domain/use_cases/library/import_library.py +27 -0
  18. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/definitions.py +9 -1
  19. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/file_settings_repository.py +4 -9
  20. gukebox-1.10.0.dev2/jukebox/settings/persisted_validation.py +16 -0
  21. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/resolve.py +96 -1
  22. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/service_protocols.py +5 -1
  23. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pyproject.toml +2 -1
  24. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pyproject.toml.orig +2 -1
  25. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/export_router.py +0 -29
  26. gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-CioYPxa4.css +0 -1
  27. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/LICENSE +0 -0
  28. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/__init__.py +0 -0
  29. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/__init__.py +0 -0
  30. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/__init__.py +0 -0
  31. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/__init__.py +0 -0
  32. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
  33. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
  34. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
  35. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/models.py +0 -0
  36. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/pn532_router.py +0 -0
  37. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
  38. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/sonos_router.py +0 -0
  39. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_controller.py +0 -0
  40. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_display.py +0 -0
  41. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
  42. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/ui_controller.py +0 -0
  43. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/cli_controller.py +0 -0
  44. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/__init__.py +0 -0
  45. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/__init__.py +0 -0
  46. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  47. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  48. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  49. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  50. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  51. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  52. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  53. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/__init__.py +0 -0
  54. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/app.py +0 -0
  55. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/cli_presentation.py +0 -0
  56. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/command_handlers.py +0 -0
  57. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/commands.py +0 -0
  58. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/library_command_handlers.py +0 -0
  59. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/library_commands.py +0 -0
  60. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_command_handlers.py +0 -0
  61. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_commands.py +0 -0
  62. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/services.py +0 -0
  63. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/admin/sonos_households.py +0 -0
  64. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/app.py +0 -0
  65. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/di_container.py +0 -0
  66. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/__init__.py +0 -0
  67. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_state.py +0 -0
  68. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_status.py +0 -0
  69. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/disc.py +0 -0
  70. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/library.py +0 -0
  71. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/playback_state.py +0 -0
  72. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/tag_event.py +0 -0
  73. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/errors.py +0 -0
  74. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/__init__.py +0 -0
  75. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/player_port.py +0 -0
  76. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/reader_port.py +0 -0
  77. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/__init__.py +0 -0
  78. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  79. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  80. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/__init__.py +0 -0
  81. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/add_disc.py +0 -0
  82. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
  83. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
  84. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_disc.py +0 -0
  85. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/list_discs.py +0 -0
  86. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
  87. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/resolve_tag_id.py +0 -0
  88. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/search_discs.py +0 -0
  89. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
  90. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
  91. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_playback.py +0 -0
  92. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/pn532/__init__.py +0 -0
  93. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/pn532/profiles.py +0 -0
  94. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/__init__.py +0 -0
  95. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/dict_utils.py +0 -0
  96. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/entities.py +0 -0
  97. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/errors.py +0 -0
  98. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/migration.py +0 -0
  99. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/pn532_configuration.py +0 -0
  100. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/repositories.py +0 -0
  101. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_resolver.py +0 -0
  102. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_validation.py +0 -0
  103. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  104. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/timing_validation.py +0 -0
  105. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/types.py +0 -0
  106. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/validation_rules.py +0 -0
  107. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/settings/view_utils.py +0 -0
  108. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/__init__.py +0 -0
  109. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/config_utils.py +0 -0
  110. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/errors.py +0 -0
  111. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/logger.py +0 -0
  112. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/terminal_ui.py +0 -0
  113. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/shared/timing.py +0 -0
  114. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/__init__.py +0 -0
  115. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/discovery.py +0 -0
  116. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/selection.py +0 -0
  117. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/jukebox/sonos/service.py +0 -0
  118. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pn532/__init__.py +0 -0
  119. {gukebox-1.10.0.dev1 → gukebox-1.10.0.dev2}/pn532/pn532.py +0 -0
  120. {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.dev1
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
  [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg?logo=pypi)](https://pypi.python.org/pypi/gukebox)
61
61
  [![license](https://img.shields.io/pypi/l/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
62
- [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/python.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
62
+ [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/ci.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
63
63
 
64
64
  ![rpi](https://img.shields.io/badge/-Zero%202W%20%7C%203%20%7C%205-C51A4A?logo=raspberry-pi&label=RPi&logoColor=C51A4A&labcolor=C51A4A)
65
65
  [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg?logo=python)](https://pypi.python.org/pypi/gukebox) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](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
  [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg?logo=pypi)](https://pypi.python.org/pypi/gukebox)
10
10
  [![license](https://img.shields.io/pypi/l/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
11
- [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/python.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
11
+ [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/ci.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
12
12
 
13
13
  ![rpi](https://img.shields.io/badge/-Zero%202W%20%7C%203%20%7C%205-C51A4A?logo=raspberry-pi&label=RPi&logoColor=C51A4A&labcolor=C51A4A)
14
14
  [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg?logo=python)](https://pypi.python.org/pypi/gukebox) [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](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
@@ -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.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, ListDiscs, RemoveDisc
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, overload
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
- disc = Disc(uri=uri, metadata=DiscMetadata(), option=DiscOption())
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
- uri = self._prompt_for_uri(required=False)
109
- self.edit_disc.execute(tag, uri, DiscMetadata(), DiscOption())
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
- @overload
168
- def _prompt_for_uri(self, required: Literal[True] = True) -> str: ...
172
+ def _prompt_for_uri(self, current: str | None = None) -> str:
173
+ import questionary
169
174
 
170
- @overload
171
- def _prompt_for_uri(self, required: Literal[False]) -> str | None: ...
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 _prompt_for_uri(self, required: bool = True) -> str | None:
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
- label = "URI:" if required else "URI (leave empty to keep the current one):"
177
- entered_uri = questionary.text(label).unsafe_ask()
178
- uri = (entered_uri or "").strip()
179
- if uri:
180
- return uri
181
- if required:
182
- raise ValueError("A URI is required.")
183
- return None
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)