gukebox 1.0.0.dev8__tar.gz → 1.0.0.dev9__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 (113) hide show
  1. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/PKG-INFO +8 -23
  2. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/README.md +6 -21
  3. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api_controller.py +3 -3
  4. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/ui_controller.py +146 -2
  5. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/ui_pages/library.py +6 -1
  6. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/ui_pages/settings.py +7 -3
  7. gukebox-1.0.0.dev9/discstore/adapters/inbound/ui_pages/sonos.py +502 -0
  8. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/commands.py +1 -27
  9. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/app.py +0 -1
  10. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/cli_presentation.py +0 -40
  11. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/command_handlers.py +0 -11
  12. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/resolve.py +1 -1
  13. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/pyproject.toml +2 -3
  14. gukebox-1.0.0.dev8/discstore/adapters/inbound/config.py +0 -250
  15. gukebox-1.0.0.dev8/discstore/app.py +0 -103
  16. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/LICENSE +0 -0
  17. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/__init__.py +0 -0
  18. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/__init__.py +0 -0
  19. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/__init__.py +0 -0
  20. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api/__init__.py +0 -0
  21. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api/current_tag_router.py +0 -0
  22. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api/discs_router.py +0 -0
  23. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api/models.py +0 -0
  24. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/api/settings_router.py +0 -0
  25. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/cli_controller.py +0 -0
  26. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/cli_display.py +0 -0
  27. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  28. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/inbound/ui_pages/__init__.py +0 -0
  29. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/outbound/__init__.py +0 -0
  30. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  31. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  32. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/command_handlers.py +0 -0
  33. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/di_container.py +0 -0
  34. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/__init__.py +0 -0
  35. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/entities/__init__.py +0 -0
  36. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/entities/current_tag_status.py +0 -0
  37. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/repositories/__init__.py +0 -0
  38. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/__init__.py +0 -0
  39. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/add_disc.py +0 -0
  40. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/edit_disc.py +0 -0
  41. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  42. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/get_disc.py +0 -0
  43. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/list_discs.py +0 -0
  44. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/remove_disc.py +0 -0
  45. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  46. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/discstore/domain/use_cases/search_discs.py +0 -0
  47. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/__init__.py +0 -0
  48. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/__init__.py +0 -0
  49. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/inbound/__init__.py +0 -0
  50. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/inbound/cli_controller.py +0 -0
  51. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/inbound/config.py +0 -0
  52. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/__init__.py +0 -0
  53. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  54. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/players/__init__.py +0 -0
  55. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  56. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  57. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  58. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  59. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  60. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  61. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  62. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/__init__.py +0 -0
  63. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/commands.py +0 -0
  64. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/di_container.py +0 -0
  65. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/admin/services.py +0 -0
  66. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/app.py +0 -0
  67. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/di_container.py +0 -0
  68. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/__init__.py +0 -0
  69. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/__init__.py +0 -0
  70. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/current_tag_action.py +0 -0
  71. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/disc.py +0 -0
  72. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/library.py +0 -0
  73. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/playback_action.py +0 -0
  74. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/playback_session.py +0 -0
  75. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/entities/tag_event.py +0 -0
  76. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/ports/__init__.py +0 -0
  77. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/ports/player_port.py +0 -0
  78. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/ports/reader_port.py +0 -0
  79. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/repositories/__init__.py +0 -0
  80. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  81. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/repositories/library_repository.py +0 -0
  82. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/use_cases/__init__.py +0 -0
  83. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/use_cases/determine_action.py +0 -0
  84. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  85. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  86. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/__init__.py +0 -0
  87. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/definitions.py +0 -0
  88. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/dict_utils.py +0 -0
  89. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/entities.py +0 -0
  90. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/errors.py +0 -0
  91. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/file_settings_repository.py +0 -0
  92. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/migration.py +0 -0
  93. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/repositories.py +0 -0
  94. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/runtime_resolver.py +0 -0
  95. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/runtime_validation.py +0 -0
  96. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  97. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/service_protocols.py +0 -0
  98. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/timing_validation.py +0 -0
  99. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/types.py +0 -0
  100. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/validation_rules.py +0 -0
  101. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/settings/view_utils.py +0 -0
  102. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/shared/__init__.py +0 -0
  103. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/shared/config_utils.py +0 -0
  104. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/shared/dependency_messages.py +0 -0
  105. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/shared/logger.py +0 -0
  106. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/shared/timing.py +0 -0
  107. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/sonos/__init__.py +0 -0
  108. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/sonos/discovery.py +0 -0
  109. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/sonos/selection.py +0 -0
  110. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/jukebox/sonos/service.py +0 -0
  111. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/pn532/__init__.py +0 -0
  112. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/pn532/pn532.py +0 -0
  113. {gukebox-1.0.0.dev8 → gukebox-1.0.0.dev9}/pn532/spi.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev8
3
+ Version: 1.0.0.dev9
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
- Keywords: jukebox,discstore,music,nfc
5
+ Keywords: jukebox,music,nfc
6
6
  Author: Gudsfile
7
7
  License: MIT License
8
8
 
@@ -71,7 +71,7 @@ Description-Content-Type: text/markdown
71
71
 
72
72
  🚧 At the moment:
73
73
 
74
- - NFC tags - CDs must be pre-populated in a JSON file (`discstore` included with `jukebox` may be of help to you)
74
+ - NFC tags - CDs must be pre-populated in a JSON file (`jukebox-admin` included with `jukebox` may be of help to you)
75
75
  - supports many music providers (Spotify, Apple Music, etc.), just add the URIs to the JSON file
76
76
  - only works with Sonos speakers (there is a "dryrun" player for development), but code is designed to **add new ones**
77
77
  - **as soon as** the NFC tag is removed, the music pauses, then resumes when the NFC tag is replaced
@@ -161,18 +161,18 @@ uv sync
161
161
 
162
162
  ## First steps
163
163
 
164
- Initialize the library file with `discstore` or manually create it at `~/.config/jukebox/library.json`.
164
+ Initialize the library file with `jukebox-admin` or manually create it at `~/.config/jukebox/library.json`.
165
165
 
166
- ### Manage the library with the discstore
166
+ ### Manage the library with the Admin CLI
167
167
 
168
168
  To associate an URI with an NFC tag:
169
169
 
170
170
  ```shell
171
- discstore add tag_id --uri /path/to/media.mp3
171
+ jukebox-admin library add tag_id --uri /path/to/media.mp3
172
172
  ```
173
173
  or to pull the `tag_id` currently on the reader:
174
174
  ```shell
175
- discstore add --from-current --uri /path/to/media.mp3
175
+ jukebox-admin library add --from-current --uri /path/to/media.mp3
176
176
  ```
177
177
 
178
178
  Other commands are available, use `--help` to see them.
@@ -204,8 +204,6 @@ uv run --extra api jukebox-admin api
204
204
  uv run --extra ui jukebox-admin ui
205
205
  ```
206
206
 
207
- `discstore settings ...`, `discstore api`, and `discstore ui` remain available as compatibility commands, but `jukebox-admin` is the preferred CLI for admin flows.
208
-
209
207
  ### Manage the library manually
210
208
 
211
209
  Complete your `~/.config/jukebox/library.json` file with each tag id and the expected media URI.
@@ -258,7 +256,7 @@ jukebox --player PLAYER --reader READER
258
256
 
259
257
  The `library.json` file is a JSON file that contains the artists, albums and tags.
260
258
  It is used by the `jukebox` command to find the corresponding metadata for each tag.
261
- And the `discstore` command help you to managed this file with a CLI, an interactive CLI, an API or an UI (see `discstore --help`).
259
+ And the `jukebox-admin library` command help you to managed this file with a CLI, an interactive CLI, an API or an UI (see `jukebox-admin --help`).
262
260
 
263
261
  By default, this file should be placed at `~/.config/jukebox/library.json`. But you can use another path by creating a `JUKEBOX_LIBRARY_PATH` environment variable or with the `--library` argument.
264
262
 
@@ -346,11 +344,6 @@ Start the jukebox with `uv` and use `--help` to show help message
346
344
  uv run jukebox --player PLAYER_TO_USE --reader READER_TO_USE
347
345
  ```
348
346
 
349
- Start the discstore `uv` and use `--help` to show help message
350
- ```shell
351
- uv run discstore --help
352
- ```
353
-
354
347
  Use `jukebox-admin` for admin commands:
355
348
 
356
349
  ```shell
@@ -364,14 +357,6 @@ uv run --extra api jukebox-admin api
364
357
  uv run --extra ui jukebox-admin ui
365
358
  ```
366
359
 
367
- Legacy compatibility commands remain available during the transition:
368
-
369
- ```shell
370
- uv run discstore settings show
371
- uv run --extra api discstore api
372
- uv run --extra ui discstore ui
373
- ```
374
-
375
360
  ### Development commands
376
361
 
377
362
  | Command | Description |
@@ -13,7 +13,7 @@
13
13
 
14
14
  🚧 At the moment:
15
15
 
16
- - NFC tags - CDs must be pre-populated in a JSON file (`discstore` included with `jukebox` may be of help to you)
16
+ - NFC tags - CDs must be pre-populated in a JSON file (`jukebox-admin` included with `jukebox` may be of help to you)
17
17
  - supports many music providers (Spotify, Apple Music, etc.), just add the URIs to the JSON file
18
18
  - only works with Sonos speakers (there is a "dryrun" player for development), but code is designed to **add new ones**
19
19
  - **as soon as** the NFC tag is removed, the music pauses, then resumes when the NFC tag is replaced
@@ -103,18 +103,18 @@ uv sync
103
103
 
104
104
  ## First steps
105
105
 
106
- Initialize the library file with `discstore` or manually create it at `~/.config/jukebox/library.json`.
106
+ Initialize the library file with `jukebox-admin` or manually create it at `~/.config/jukebox/library.json`.
107
107
 
108
- ### Manage the library with the discstore
108
+ ### Manage the library with the Admin CLI
109
109
 
110
110
  To associate an URI with an NFC tag:
111
111
 
112
112
  ```shell
113
- discstore add tag_id --uri /path/to/media.mp3
113
+ jukebox-admin library add tag_id --uri /path/to/media.mp3
114
114
  ```
115
115
  or to pull the `tag_id` currently on the reader:
116
116
  ```shell
117
- discstore add --from-current --uri /path/to/media.mp3
117
+ jukebox-admin library add --from-current --uri /path/to/media.mp3
118
118
  ```
119
119
 
120
120
  Other commands are available, use `--help` to see them.
@@ -146,8 +146,6 @@ uv run --extra api jukebox-admin api
146
146
  uv run --extra ui jukebox-admin ui
147
147
  ```
148
148
 
149
- `discstore settings ...`, `discstore api`, and `discstore ui` remain available as compatibility commands, but `jukebox-admin` is the preferred CLI for admin flows.
150
-
151
149
  ### Manage the library manually
152
150
 
153
151
  Complete your `~/.config/jukebox/library.json` file with each tag id and the expected media URI.
@@ -200,7 +198,7 @@ jukebox --player PLAYER --reader READER
200
198
 
201
199
  The `library.json` file is a JSON file that contains the artists, albums and tags.
202
200
  It is used by the `jukebox` command to find the corresponding metadata for each tag.
203
- And the `discstore` command help you to managed this file with a CLI, an interactive CLI, an API or an UI (see `discstore --help`).
201
+ And the `jukebox-admin library` command help you to managed this file with a CLI, an interactive CLI, an API or an UI (see `jukebox-admin --help`).
204
202
 
205
203
  By default, this file should be placed at `~/.config/jukebox/library.json`. But you can use another path by creating a `JUKEBOX_LIBRARY_PATH` environment variable or with the `--library` argument.
206
204
 
@@ -288,11 +286,6 @@ Start the jukebox with `uv` and use `--help` to show help message
288
286
  uv run jukebox --player PLAYER_TO_USE --reader READER_TO_USE
289
287
  ```
290
288
 
291
- Start the discstore `uv` and use `--help` to show help message
292
- ```shell
293
- uv run discstore --help
294
- ```
295
-
296
289
  Use `jukebox-admin` for admin commands:
297
290
 
298
291
  ```shell
@@ -306,14 +299,6 @@ uv run --extra api jukebox-admin api
306
299
  uv run --extra ui jukebox-admin ui
307
300
  ```
308
301
 
309
- Legacy compatibility commands remain available during the transition:
310
-
311
- ```shell
312
- uv run discstore settings show
313
- uv run --extra api discstore api
314
- uv run --extra ui discstore ui
315
- ```
316
-
317
302
  ### Development commands
318
303
 
319
304
  | Command | Description |
@@ -23,7 +23,7 @@ except ModuleNotFoundError as e:
23
23
  if e.name != "fastapi":
24
24
  raise
25
25
  raise ModuleNotFoundError(
26
- optional_extra_dependency_message("The `api_controller` module", "api", "discstore api")
26
+ optional_extra_dependency_message("The `api_controller` module", "api", "jukebox-admin api")
27
27
  ) from e
28
28
  from discstore.domain.use_cases.add_disc import AddDisc
29
29
  from discstore.domain.use_cases.edit_disc import EditDisc
@@ -108,8 +108,8 @@ class APIController:
108
108
  self.settings_service = settings_service
109
109
  self.sonos_service = sonos_service
110
110
  self.app = FastAPI(
111
- title="DiscStore API",
112
- description="API for managing Jukebox disc library",
111
+ title="Jukebox Admin API",
112
+ description="API for managing Jukebox disc library and settings",
113
113
  docs_url="/docs",
114
114
  redoc_url="/redoc",
115
115
  )
@@ -16,7 +16,7 @@ try:
16
16
  from fastui.forms import fastui_form
17
17
  except ModuleNotFoundError as e:
18
18
  raise ModuleNotFoundError(
19
- optional_extra_dependency_message("The `ui_controller` module", "ui", "discstore ui")
19
+ optional_extra_dependency_message("The `ui_controller` module", "ui", "jukebox-admin ui")
20
20
  ) from e
21
21
 
22
22
  from pydantic import BaseModel, Field
@@ -24,6 +24,7 @@ from pydantic import BaseModel, Field
24
24
  from discstore.adapters.inbound.api_controller import APIController
25
25
  from discstore.adapters.inbound.ui_pages.library import DiscForm, DiscTable, LibraryUIPageBuilder
26
26
  from discstore.adapters.inbound.ui_pages.settings import SettingsUIPageBuilder
27
+ from discstore.adapters.inbound.ui_pages.sonos import SonosSelectionForm, SonosUIPageBuilder
27
28
  from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption
28
29
  from discstore.domain.use_cases.add_disc import AddDisc
29
30
  from discstore.domain.use_cases.edit_disc import EditDisc
@@ -36,8 +37,11 @@ from jukebox.settings.definitions import (
36
37
  get_setting_definition,
37
38
  )
38
39
  from jukebox.settings.errors import SettingsError
40
+ from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
39
41
  from jukebox.settings.service_protocols import SettingsService
40
42
  from jukebox.settings.types import JsonObject
43
+ from jukebox.sonos.discovery import SonosDiscoveryError
44
+ from jukebox.sonos.selection import SaveSonosSelection
41
45
  from jukebox.sonos.service import SonosService
42
46
 
43
47
 
@@ -63,6 +67,7 @@ class UIController(APIController):
63
67
  get_disc=get_disc,
64
68
  get_current_tag_status=get_current_tag_status,
65
69
  )
70
+ self.sonos_pages = SonosUIPageBuilder(settings_service=settings_service, sonos_service=sonos_service)
66
71
  self.settings_pages = SettingsUIPageBuilder(settings_service=settings_service)
67
72
  super().__init__(
68
73
  add_disc,
@@ -222,10 +227,69 @@ class UIController(APIController):
222
227
  async def reset_setting(setting_path: str) -> list[AnyComponent]:
223
228
  return self._reset_setting(setting_path)
224
229
 
230
+ @self.app.get("/api/ui/sonos", response_model=FastUI, response_model_exclude_none=True)
231
+ def sonos_page(toast: Optional[str] = None, toast_message: Optional[str] = None) -> List[AnyComponent]:
232
+ return self._build_sonos_page_components(toast=toast, toast_message=toast_message)
233
+
234
+ @self.app.get("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True)
235
+ def edit_sonos_form(
236
+ error_message: Optional[str] = None,
237
+ uids: Optional[List[str]] = None,
238
+ coordinator_uid: Optional[str] = None,
239
+ ) -> List[AnyComponent]:
240
+ field_errors = None
241
+ if error_message:
242
+ field_errors = {self._sonos_field_name_for_error(error_message): error_message}
243
+ return self._build_sonos_edit_page_components(
244
+ error_message=error_message,
245
+ field_errors=field_errors,
246
+ submitted_uids=uids,
247
+ submitted_coordinator_uid=coordinator_uid,
248
+ )
249
+
250
+ @self.app.post("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True)
251
+ async def update_sonos_selection(
252
+ form: Annotated[SonosSelectionForm, fastui_form(SonosSelectionForm)],
253
+ ) -> list[AnyComponent]:
254
+ try:
255
+ result = SaveSonosSelection(
256
+ selected_group_repository=SettingsSelectedSonosGroupRepository(self.settings_service),
257
+ sonos_service=self.sonos_service,
258
+ ).execute(form.uids, coordinator_uid=form.coordinator_uid)
259
+ except SonosDiscoveryError as err:
260
+ raise HTTPException(status_code=502, detail=str(err))
261
+ except SettingsError as err:
262
+ display_message = self._build_sonos_error_message(str(err), form.coordinator_uid)
263
+ if self._persisted_sonos_selection_matches(form.uids, form.coordinator_uid):
264
+ return self.sonos_pages.build_sonos_success_response(
265
+ "Sonos selection saved, but effective settings are still unavailable."
266
+ )
267
+ return self.sonos_pages.build_sonos_edit_error_response(
268
+ display_message,
269
+ form.uids,
270
+ form.coordinator_uid,
271
+ )
272
+ except ValueError as err:
273
+ return self.sonos_pages.build_sonos_edit_error_response(
274
+ self._build_sonos_error_message(str(err), form.coordinator_uid),
275
+ form.uids,
276
+ form.coordinator_uid,
277
+ )
278
+ except HTTPException:
279
+ raise
280
+ except Exception as err:
281
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
282
+
283
+ return self.sonos_pages.build_sonos_success_response(str(result.settings_message))
284
+
285
+ @self.app.post("/api/ui/sonos/reset", response_model=FastUI, response_model_exclude_none=True)
286
+ async def reset_sonos_selection() -> list[AnyComponent]:
287
+ return self._reset_sonos_selection()
288
+
225
289
  @self.app.get("/{path:path}")
226
290
  def html_landing(path: str) -> HTMLResponse:
227
291
  del path
228
- return HTMLResponse(prebuilt_html(title="DiscStore for Jukebox", api_root_url="/api/ui"))
292
+ return HTMLResponse(prebuilt_html(title="Jukebox Admin", api_root_url="/api/ui"))
229
293
 
230
294
  def _build_success_response(self, toast_event_name: str) -> list[AnyComponent]:
231
295
  return [
@@ -238,6 +302,80 @@ class UIController(APIController):
238
302
  def _reset_setting(self, setting_path: str) -> list[AnyComponent]:
239
303
  return self.settings_pages.reset_setting(setting_path)
240
304
 
305
+ def _build_sonos_page_components(
306
+ self,
307
+ toast: Optional[str] = None,
308
+ toast_message: Optional[str] = None,
309
+ error_message: Optional[str] = None,
310
+ ) -> List[AnyComponent]:
311
+ return self.sonos_pages.build_sonos_page_components(
312
+ toast=toast,
313
+ toast_message=toast_message,
314
+ error_message=error_message,
315
+ )
316
+
317
+ def _build_sonos_edit_page_components(
318
+ self,
319
+ error_message: Optional[str] = None,
320
+ field_errors: Optional[dict[str, str]] = None,
321
+ submitted_uids: Optional[List[str]] = None,
322
+ submitted_coordinator_uid: Optional[str] = None,
323
+ ) -> List[AnyComponent]:
324
+ return self.sonos_pages.build_sonos_edit_page_components(
325
+ error_message=error_message,
326
+ field_errors=field_errors,
327
+ submitted_uids=submitted_uids,
328
+ submitted_coordinator_uid=submitted_coordinator_uid,
329
+ )
330
+
331
+ def _reset_sonos_selection(self) -> list[AnyComponent]:
332
+ selected_group_repository = SettingsSelectedSonosGroupRepository(self.settings_service)
333
+ try:
334
+ result = self.settings_service.reset_persisted_value("jukebox.player.sonos.selected_group")
335
+ except SettingsError as err:
336
+ if selected_group_repository.get_selected_group() is None:
337
+ return self.sonos_pages.build_sonos_success_response(
338
+ "Sonos selection cleared, but effective settings are still unavailable."
339
+ )
340
+ return self._build_sonos_page_components(error_message=str(err))
341
+ except HTTPException:
342
+ raise
343
+ except Exception as err:
344
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
345
+
346
+ return self.sonos_pages.build_sonos_success_response(str(result.get("message", "Settings saved.")))
347
+
348
+ def _persisted_sonos_selection_matches(self, uids: List[str], coordinator_uid: Optional[str]) -> bool:
349
+ try:
350
+ selected_group = SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group()
351
+ except Exception:
352
+ return False
353
+
354
+ if selected_group is None:
355
+ return False
356
+
357
+ expected_coordinator_uid = coordinator_uid or (uids[0] if uids else None)
358
+ return (
359
+ selected_group.coordinator_uid == expected_coordinator_uid
360
+ and [member.uid for member in selected_group.members] == uids
361
+ )
362
+
363
+ def _build_sonos_error_message(self, message: str, coordinator_uid: Optional[str]) -> str:
364
+ prefix = "Selected Sonos coordinator must be one of the selected speakers: "
365
+ if coordinator_uid is None or not message.startswith(prefix):
366
+ return message
367
+
368
+ try:
369
+ speakers = self.sonos_service.list_available_speakers()
370
+ except Exception:
371
+ return message
372
+
373
+ speaker = next((speaker for speaker in speakers if speaker.uid == coordinator_uid), None)
374
+ if speaker is None:
375
+ return message
376
+
377
+ return f"{prefix}{speaker.name} [{speaker.uid}]"
378
+
241
379
  def _build_index_page_components(self, toast: Optional[str] = None) -> List[AnyComponent]:
242
380
  return self.library_pages.build_index_page_components(toast=toast)
243
381
 
@@ -350,6 +488,12 @@ class UIController(APIController):
350
488
  def _serialize_current_tag_components(self, components: List[AnyComponent]) -> str:
351
489
  return self.library_pages.serialize_current_tag_components(components)
352
490
 
491
+ @staticmethod
492
+ def _sonos_field_name_for_error(message: str) -> str:
493
+ if "coordinator" in message:
494
+ return "coordinator_uid"
495
+ return "uids"
496
+
353
497
  def _field_validation_error(self, field_name: str, message: str) -> HTTPException:
354
498
  return HTTPException(
355
499
  status_code=422,
@@ -47,7 +47,7 @@ class LibraryUIPageBuilder:
47
47
  ]
48
48
 
49
49
  components: list[AnyComponent] = [
50
- c.Heading(text="DiscStore for Jukebox", level=1),
50
+ c.Heading(text="Jukebox Admin", level=1),
51
51
  c.Paragraph(text=f"📀 {len(discs)} disc(s) in library"),
52
52
  c.ServerLoad(
53
53
  path="/current-tag-banner/events",
@@ -58,6 +58,11 @@ class LibraryUIPageBuilder:
58
58
  class_name="d-flex flex-wrap gap-2",
59
59
  components=[
60
60
  c.Button(text="➕ Add a new disc", on_click=GoToEvent(url="/discs/new")),
61
+ c.Button(
62
+ text="🔊 Sonos Speakers",
63
+ on_click=GoToEvent(url="/sonos"),
64
+ class_name="btn btn-secondary",
65
+ ),
61
66
  c.Button(text="⚙️ Settings", on_click=GoToEvent(url="/settings"), class_name="btn btn-secondary"),
62
67
  ],
63
68
  ),
@@ -142,9 +142,13 @@ class SettingsUIPageBuilder:
142
142
 
143
143
  action_components: list[AnyComponent] = [
144
144
  c.Button(
145
- text="Edit ✏️",
146
- on_click=GoToEvent(url=f"/settings/{setting.path}/edit"),
147
- class_name="btn btn-secondary",
145
+ text="Manage Speakers 🔊" if setting.path == "jukebox.player.sonos.selected_group" else "Edit ✏️",
146
+ on_click=(
147
+ GoToEvent(url="/sonos")
148
+ if setting.path == "jukebox.player.sonos.selected_group"
149
+ else GoToEvent(url=f"/settings/{setting.path}/edit")
150
+ ),
151
+ class_name="btn btn-secondary text-nowrap",
148
152
  )
149
153
  ]
150
154
  row_class_name = "px-3 py-3"