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.
Files changed (128) hide show
  1. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/PKG-INFO +8 -10
  2. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/README.md +4 -4
  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.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/models.py +38 -1
  7. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/pn532_router.py +102 -0
  8. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api/sonos_router.py +75 -0
  9. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/api_controller.py +121 -0
  10. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/cli_controller.py +21 -10
  11. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/cli_display.py +112 -0
  12. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/interactive_cli_controller.py +200 -0
  13. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-BPFMVFmr.js +5 -0
  14. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/static/assets/index-FlN0CmqN.css +1 -0
  15. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/index.html +3 -3
  16. gukebox-1.10.0.dev2/jukebox/adapters/inbound/admin/ui_controller.py +31 -0
  17. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/json_library_adapter.py +31 -2
  18. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/app.py +23 -2
  19. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/command_handlers.py +1 -1
  20. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/di_container.py +20 -3
  21. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/library_commands.py +4 -0
  22. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_command_handlers.py +8 -18
  23. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/services.py +1 -0
  24. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/__init__.py +2 -0
  25. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/disc.py +13 -2
  26. gukebox-1.10.0.dev2/jukebox/domain/entities/library_import_summary.py +10 -0
  27. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/library_repository.py +13 -1
  28. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/__init__.py +3 -0
  29. gukebox-1.10.0.dev2/jukebox/domain/use_cases/library/import_library.py +27 -0
  30. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/resolve_tag_id.py +8 -1
  31. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/pn532/profiles.py +1 -1
  32. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/definitions.py +9 -1
  33. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/entities.py +1 -1
  34. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/file_settings_repository.py +10 -14
  35. gukebox-1.10.0.dev2/jukebox/settings/persisted_validation.py +16 -0
  36. gukebox-1.10.0.dev2/jukebox/settings/pn532_configuration.py +39 -0
  37. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/resolve.py +96 -1
  38. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/service_protocols.py +5 -1
  39. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pyproject.toml +13 -16
  40. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pyproject.toml.orig +12 -13
  41. gukebox-1.9.1/jukebox/adapters/inbound/admin/api_controller.py +0 -194
  42. gukebox-1.9.1/jukebox/adapters/inbound/admin/cli_display.py +0 -63
  43. gukebox-1.9.1/jukebox/adapters/inbound/admin/interactive_cli_controller.py +0 -136
  44. gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-BzFrC-C3.js +0 -4
  45. gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-pWJnAHA9.css +0 -1
  46. gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_controller.py +0 -362
  47. gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/library.py +0 -381
  48. gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/settings.py +0 -525
  49. gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -501
  50. gukebox-1.9.1/jukebox/shared/__init__.py +0 -0
  51. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/LICENSE +0 -0
  52. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/__init__.py +0 -0
  53. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/__init__.py +0 -0
  54. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/__init__.py +0 -0
  55. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/__init__.py +0 -0
  56. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
  57. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
  58. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
  59. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
  60. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
  61. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/inbound/cli_controller.py +0 -0
  62. {gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages → gukebox-1.10.0.dev2/jukebox/adapters/outbound}/__init__.py +0 -0
  63. {gukebox-1.9.1/jukebox/adapters/outbound → gukebox-1.10.0.dev2/jukebox/adapters/outbound/players}/__init__.py +0 -0
  64. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  65. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  66. {gukebox-1.9.1/jukebox/adapters/outbound/players → gukebox-1.10.0.dev2/jukebox/adapters/outbound/readers}/__init__.py +0 -0
  67. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  68. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  69. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  70. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  71. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/__init__.py +0 -0
  72. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/cli_presentation.py +0 -0
  73. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/commands.py +0 -0
  74. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/library_command_handlers.py +0 -0
  75. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/pn532_commands.py +0 -0
  76. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/admin/sonos_households.py +0 -0
  77. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/app.py +0 -0
  78. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/di_container.py +0 -0
  79. {gukebox-1.9.1/jukebox/adapters/outbound/readers → gukebox-1.10.0.dev2/jukebox/domain}/__init__.py +0 -0
  80. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_state.py +0 -0
  81. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/current_tag_status.py +0 -0
  82. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/library.py +0 -0
  83. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/playback_state.py +0 -0
  84. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/entities/tag_event.py +0 -0
  85. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/errors.py +0 -0
  86. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/__init__.py +0 -0
  87. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/player_port.py +0 -0
  88. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/ports/reader_port.py +0 -0
  89. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/__init__.py +0 -0
  90. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  91. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  92. {gukebox-1.9.1/jukebox/domain → gukebox-1.10.0.dev2/jukebox/domain/use_cases/library}/__init__.py +0 -0
  93. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/add_disc.py +0 -0
  94. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
  95. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
  96. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/get_disc.py +0 -0
  97. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/list_discs.py +0 -0
  98. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
  99. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/library/search_discs.py +0 -0
  100. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
  101. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
  102. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/domain/use_cases/transition_playback.py +0 -0
  103. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/pn532/__init__.py +0 -0
  104. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/__init__.py +0 -0
  105. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/dict_utils.py +0 -0
  106. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/errors.py +0 -0
  107. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/migration.py +0 -0
  108. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/repositories.py +0 -0
  109. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_resolver.py +0 -0
  110. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/runtime_validation.py +0 -0
  111. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  112. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/timing_validation.py +0 -0
  113. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/types.py +0 -0
  114. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/validation_rules.py +0 -0
  115. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/settings/view_utils.py +0 -0
  116. {gukebox-1.9.1/jukebox/domain/use_cases/library → gukebox-1.10.0.dev2/jukebox/shared}/__init__.py +0 -0
  117. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/config_utils.py +0 -0
  118. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/errors.py +0 -0
  119. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/logger.py +0 -0
  120. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/terminal_ui.py +0 -0
  121. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/shared/timing.py +0 -0
  122. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/__init__.py +0 -0
  123. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/discovery.py +0 -0
  124. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/selection.py +0 -0
  125. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/jukebox/sonos/service.py +0 -0
  126. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pn532/__init__.py +0 -0
  127. {gukebox-1.9.1 → gukebox-1.10.0.dev2}/pn532/pn532.py +0 -0
  128. {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.9.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.0
35
+ Requires-Dist: pydantic==2.14.1
36
36
  Requires-Dist: questionary==2.1.1
37
- Requires-Dist: soco==0.31.5
37
+ Requires-Dist: soco==0.32.0
38
38
  Requires-Dist: typer==0.27.3
39
- Requires-Dist: fastapi==0.143.0 ; extra == 'api'
39
+ Requires-Dist: fastapi==0.162.0 ; extra == 'api'
40
40
  Requires-Dist: uvicorn==0.54.0 ; extra == 'api'
41
41
  Requires-Dist: pyserial==3.5 ; extra == 'pn532'
42
42
  Requires-Dist: spidev==3.8 ; extra == 'pn532'
43
43
  Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'pn532'
44
44
  Requires-Dist: gukebox[api] ; extra == 'ui'
45
- Requires-Dist: fastui==0.9.0 ; extra == 'ui'
46
- Requires-Dist: python-multipart==0.0.32 ; extra == 'ui'
47
45
  Requires-Python: >=3.11, <3.14
48
46
  Project-URL: Repository, https://github.com/Gudsfile/jukebox
49
47
  Provides-Extra: api
@@ -61,7 +59,7 @@ Pick an album, place it on the reader, and the music starts playing. Remove it,
61
59
 
62
60
  [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg?logo=pypi)](https://pypi.python.org/pypi/gukebox)
63
61
  [![license](https://img.shields.io/pypi/l/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
64
- [![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)
65
63
 
66
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)
67
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)
@@ -168,9 +166,9 @@ Launch the web UI (requires the `ui` extra):
168
166
  jukebox-admin ui
169
167
  ```
170
168
 
171
- ![Jukebox Admin UI showing a disc library table with three entries and a yellow warning banner indicating an unknown NFC tag is on the reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
169
+ ![Jukebox Admin UI showing the disc library table, with the floating Reader panel reporting an unknown disc on the NFC reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
172
170
 
173
- ![Add disc form with the NFC tag ID pre-filled, and empty fields for URI, artist, album, track and shuffle](docs/img/ui-add-disc-form.png)
171
+ ![Add disc form with the Tag ID pre-filled from the reader, and empty fields for URI / Path, artist, album, track, playlist and shuffle](docs/img/ui-add-disc-form.png)
174
172
 
175
173
  ## Usage
176
174
 
@@ -242,7 +240,7 @@ uv run --extra ui jukebox-admin ui
242
240
  ```
243
241
 
244
242
  > [!NOTE]
245
- > The admin UI is being migrated from FastUI to a Svelte SPA (see `admin-ui/`). Both currently coexist within the `jukebox-admin ui` process: FastUI serves `/`, while the new SPA is mounted at `/ui`.
243
+ > The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
246
244
 
247
245
  #### `library`
248
246
 
@@ -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)
@@ -115,9 +115,9 @@ Launch the web UI (requires the `ui` extra):
115
115
  jukebox-admin ui
116
116
  ```
117
117
 
118
- ![Jukebox Admin UI showing a disc library table with three entries and a yellow warning banner indicating an unknown NFC tag is on the reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
118
+ ![Jukebox Admin UI showing the disc library table, with the floating Reader panel reporting an unknown disc on the NFC reader, ready to be added](docs/img/ui-library-index-unknown-nfc-tag-banner.png)
119
119
 
120
- ![Add disc form with the NFC tag ID pre-filled, and empty fields for URI, artist, album, track and shuffle](docs/img/ui-add-disc-form.png)
120
+ ![Add disc form with the Tag ID pre-filled from the reader, and empty fields for URI / Path, artist, album, track, playlist and shuffle](docs/img/ui-add-disc-form.png)
121
121
 
122
122
  ## Usage
123
123
 
@@ -189,7 +189,7 @@ uv run --extra ui jukebox-admin ui
189
189
  ```
190
190
 
191
191
  > [!NOTE]
192
- > The admin UI is being migrated from FastUI to a Svelte SPA (see `admin-ui/`). Both currently coexist within the `jukebox-admin ui` process: FastUI serves `/`, while the new SPA is mounted at `/ui`.
192
+ > The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
193
193
 
194
194
  #### `library`
195
195
 
@@ -0,0 +1,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