gukebox 1.0.0.dev5__tar.gz → 1.0.0.dev6__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 (111) hide show
  1. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/PKG-INFO +10 -9
  2. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/README.md +4 -4
  3. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/current_tag_router.py +25 -0
  4. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/discs_router.py +48 -0
  5. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/models.py +25 -0
  6. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/settings_router.py +46 -0
  7. gukebox-1.0.0.dev6/discstore/adapters/inbound/api_controller.py +166 -0
  8. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/inbound/config.py +3 -3
  9. gukebox-1.0.0.dev5/jukebox/adapters/outbound/readers/nfc_reader_adapter.py → gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +4 -4
  10. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/app.py +45 -3
  11. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/cli_presentation.py +34 -0
  12. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/command_handlers.py +49 -0
  13. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/commands.py +14 -1
  14. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/di_container.py +3 -3
  15. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/definitions.py +5 -5
  16. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/entities.py +8 -8
  17. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/runtime_resolver.py +1 -1
  18. gukebox-1.0.0.dev6/jukebox/settings/selected_sonos_group_repository.py +60 -0
  19. gukebox-1.0.0.dev6/jukebox/shared/__init__.py +0 -0
  20. gukebox-1.0.0.dev6/jukebox/sonos/__init__.py +32 -0
  21. gukebox-1.0.0.dev6/jukebox/sonos/selection.py +144 -0
  22. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/pn532/pn532.py +2 -2
  23. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/pn532/spi.py +1 -1
  24. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/pyproject.toml +3 -2
  25. gukebox-1.0.0.dev5/discstore/adapters/inbound/api_controller.py +0 -155
  26. gukebox-1.0.0.dev5/jukebox/sonos/__init__.py +0 -16
  27. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/LICENSE +0 -0
  28. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/__init__.py +0 -0
  29. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/__init__.py +0 -0
  30. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/__init__.py +0 -0
  31. {gukebox-1.0.0.dev5/discstore/adapters/outbound → gukebox-1.0.0.dev6/discstore/adapters/inbound/api}/__init__.py +0 -0
  32. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/cli_controller.py +0 -0
  33. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/cli_display.py +0 -0
  34. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/config.py +0 -0
  35. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  36. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/ui_controller.py +0 -0
  37. {gukebox-1.0.0.dev5/discstore/domain → gukebox-1.0.0.dev6/discstore/adapters/outbound}/__init__.py +0 -0
  38. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  39. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  40. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/app.py +0 -0
  41. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/command_handlers.py +0 -0
  42. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/commands.py +0 -0
  43. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/di_container.py +0 -0
  44. {gukebox-1.0.0.dev5/jukebox → gukebox-1.0.0.dev6/discstore/domain}/__init__.py +0 -0
  45. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/entities/__init__.py +0 -0
  46. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/entities/current_tag_status.py +0 -0
  47. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/repositories/__init__.py +0 -0
  48. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/__init__.py +0 -0
  49. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/add_disc.py +0 -0
  50. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/edit_disc.py +0 -0
  51. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  52. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/get_disc.py +0 -0
  53. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/list_discs.py +0 -0
  54. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/remove_disc.py +0 -0
  55. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  56. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/search_discs.py +0 -0
  57. {gukebox-1.0.0.dev5/jukebox/adapters → gukebox-1.0.0.dev6/jukebox}/__init__.py +0 -0
  58. {gukebox-1.0.0.dev5/jukebox/adapters/inbound → gukebox-1.0.0.dev6/jukebox/adapters}/__init__.py +0 -0
  59. {gukebox-1.0.0.dev5/jukebox/adapters/outbound → gukebox-1.0.0.dev6/jukebox/adapters/inbound}/__init__.py +0 -0
  60. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/inbound/cli_controller.py +0 -0
  61. {gukebox-1.0.0.dev5/jukebox/adapters/outbound/players → gukebox-1.0.0.dev6/jukebox/adapters/outbound}/__init__.py +0 -0
  62. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  63. {gukebox-1.0.0.dev5/jukebox/adapters/outbound/readers → gukebox-1.0.0.dev6/jukebox/adapters/outbound/players}/__init__.py +0 -0
  64. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  65. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  66. {gukebox-1.0.0.dev5/jukebox/domain → gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers}/__init__.py +0 -0
  67. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  68. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  69. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  70. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/__init__.py +0 -0
  71. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/di_container.py +0 -0
  72. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/admin/services.py +0 -0
  73. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/app.py +0 -0
  74. {gukebox-1.0.0.dev5/jukebox/shared → gukebox-1.0.0.dev6/jukebox/domain}/__init__.py +0 -0
  75. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/__init__.py +0 -0
  76. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/current_tag_action.py +0 -0
  77. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/disc.py +0 -0
  78. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/library.py +0 -0
  79. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/playback_action.py +0 -0
  80. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/playback_session.py +0 -0
  81. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/entities/tag_event.py +0 -0
  82. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/ports/__init__.py +0 -0
  83. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/ports/player_port.py +0 -0
  84. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/ports/reader_port.py +0 -0
  85. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/__init__.py +0 -0
  86. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  87. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/library_repository.py +0 -0
  88. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/__init__.py +0 -0
  89. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/determine_action.py +0 -0
  90. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  91. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  92. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/__init__.py +0 -0
  93. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/dict_utils.py +0 -0
  94. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/errors.py +0 -0
  95. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/file_settings_repository.py +0 -0
  96. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/migration.py +0 -0
  97. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/repositories.py +0 -0
  98. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/resolve.py +0 -0
  99. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/runtime_validation.py +0 -0
  100. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/service_protocols.py +0 -0
  101. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/timing_validation.py +0 -0
  102. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/types.py +0 -0
  103. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/validation_rules.py +0 -0
  104. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/settings/view_utils.py +0 -0
  105. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/shared/config_utils.py +0 -0
  106. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/shared/dependency_messages.py +0 -0
  107. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/shared/logger.py +0 -0
  108. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/shared/timing.py +0 -0
  109. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/sonos/discovery.py +0 -0
  110. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/jukebox/sonos/service.py +0 -0
  111. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev6}/pn532/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev5
3
+ Version: 1.0.0.dev6
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,discstore,music,nfc
6
6
  Author: Gudsfile
@@ -35,6 +35,7 @@ Classifier: Programming Language :: Python :: 3.11
35
35
  Classifier: Programming Language :: Python :: 3.12
36
36
  Classifier: Programming Language :: Python :: 3.13
37
37
  Requires-Dist: pydantic==2.12.5
38
+ Requires-Dist: questionary==2.1.1
38
39
  Requires-Dist: soco==0.30.14
39
40
  Requires-Dist: typer==0.23.2 ; python_full_version < '3.10'
40
41
  Requires-Dist: typer==0.24.1 ; python_full_version >= '3.10'
@@ -42,16 +43,16 @@ Requires-Dist: fastapi==0.128.7 ; python_full_version < '3.10' and extra == 'api
42
43
  Requires-Dist: fastapi==0.135.1 ; python_full_version >= '3.10' and extra == 'api'
43
44
  Requires-Dist: uvicorn==0.39.0 ; python_full_version < '3.10' and extra == 'api'
44
45
  Requires-Dist: uvicorn==0.41.0 ; python_full_version >= '3.10' and extra == 'api'
45
- Requires-Dist: pyserial==3.5 ; extra == 'nfc'
46
- Requires-Dist: spidev==3.8 ; extra == 'nfc'
47
- Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'nfc'
46
+ Requires-Dist: pyserial==3.5 ; extra == 'pn532'
47
+ Requires-Dist: spidev==3.8 ; extra == 'pn532'
48
+ Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'pn532'
48
49
  Requires-Dist: gukebox[api] ; extra == 'ui'
49
50
  Requires-Dist: fastui==0.9.0 ; python_full_version >= '3.10' and extra == 'ui'
50
51
  Requires-Dist: python-multipart==0.0.22 ; python_full_version >= '3.10' and extra == 'ui'
51
52
  Requires-Python: >=3.9, <3.14
52
53
  Project-URL: Repository, https://github.com/Gudsfile/jukebox
53
54
  Provides-Extra: api
54
- Provides-Extra: nfc
55
+ Provides-Extra: pn532
55
56
  Provides-Extra: ui
56
57
  Description-Content-Type: text/markdown
57
58
 
@@ -106,7 +107,7 @@ Install the package from [PyPI](https://pypi.org/project/gukebox/).
106
107
  > The package name is `gukebox` with `g` instead of a `j` (due to a name already taken).
107
108
 
108
109
  > [!NOTE]
109
- > The `nfc` extra is optional but required for NFC reading, [check compatibility](#readers).
110
+ > The `pn532` extra is optional but required for NFC reading, [check compatibility](#readers).
110
111
 
111
112
  ### Recommended installation
112
113
 
@@ -131,7 +132,7 @@ source jukebox/bin/activate
131
132
 
132
133
  3. Install `gukebox` into the virtual environment:
133
134
  ```shell
134
- pip install "gukebox[nfc]"
135
+ pip install "gukebox[pn532]"
135
136
  ```
136
137
 
137
138
  > [!IMPORTANT]
@@ -218,7 +219,7 @@ Start the jukebox with the `jukebox` command (show help message with `--help`)
218
219
  jukebox PLAYER_TO_USE READER_TO_USE
219
220
  ```
220
221
 
221
- 🎉 With choosing the `sonos` player and `nfc` reader, by approaching a NFC tag stored in the `library.json` file, you should hear the associated music begins.
222
+ 🎉 With choosing the `sonos` player and `pn532` reader, by approaching a NFC tag stored in the `library.json` file, you should hear the associated music begins.
222
223
 
223
224
  Optional Parameters
224
225
 
@@ -241,7 +242,7 @@ Expected syntax: `tag_id` or `tag_id duration_seconds`.
241
242
  - duration_seconds: a non-negative number of seconds used to simulate how long the tag remains in place. Fractional values are allowed.
242
243
  Complete example: `your:tag:uid 2.5`
243
244
 
244
- **NFC** (`nfc`)
245
+ **NFC Pn532** (`pn532`)
245
246
  Read an NFC tag and get its UID.
246
247
  This project works with an NFC reader like the **PN532** and NFC tags like the **NTAG2xx**.
247
248
  It is configured according to the [Waveshare PN532 wiki](https://www.waveshare.com/wiki/PN532_NFC_HAT).
@@ -49,7 +49,7 @@ Install the package from [PyPI](https://pypi.org/project/gukebox/).
49
49
  > The package name is `gukebox` with `g` instead of a `j` (due to a name already taken).
50
50
 
51
51
  > [!NOTE]
52
- > The `nfc` extra is optional but required for NFC reading, [check compatibility](#readers).
52
+ > The `pn532` extra is optional but required for NFC reading, [check compatibility](#readers).
53
53
 
54
54
  ### Recommended installation
55
55
 
@@ -74,7 +74,7 @@ source jukebox/bin/activate
74
74
 
75
75
  3. Install `gukebox` into the virtual environment:
76
76
  ```shell
77
- pip install "gukebox[nfc]"
77
+ pip install "gukebox[pn532]"
78
78
  ```
79
79
 
80
80
  > [!IMPORTANT]
@@ -161,7 +161,7 @@ Start the jukebox with the `jukebox` command (show help message with `--help`)
161
161
  jukebox PLAYER_TO_USE READER_TO_USE
162
162
  ```
163
163
 
164
- 🎉 With choosing the `sonos` player and `nfc` reader, by approaching a NFC tag stored in the `library.json` file, you should hear the associated music begins.
164
+ 🎉 With choosing the `sonos` player and `pn532` reader, by approaching a NFC tag stored in the `library.json` file, you should hear the associated music begins.
165
165
 
166
166
  Optional Parameters
167
167
 
@@ -184,7 +184,7 @@ Expected syntax: `tag_id` or `tag_id duration_seconds`.
184
184
  - duration_seconds: a non-negative number of seconds used to simulate how long the tag remains in place. Fractional values are allowed.
185
185
  Complete example: `your:tag:uid 2.5`
186
186
 
187
- **NFC** (`nfc`)
187
+ **NFC Pn532** (`pn532`)
188
188
  Read an NFC tag and get its UID.
189
189
  This project works with an NFC reader like the **PN532** and NFC tags like the **NTAG2xx**.
190
190
  It is configured according to the [Waveshare PN532 wiki](https://www.waveshare.com/wiki/PN532_NFC_HAT).
@@ -0,0 +1,25 @@
1
+ from typing import Any
2
+
3
+ from fastapi import APIRouter, Response
4
+
5
+ from discstore.adapters.inbound.api.models import CurrentTagStatusOutput
6
+ from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
7
+
8
+
9
+ def build_current_tag_router(get_current_tag_status: GetCurrentTagStatus) -> APIRouter:
10
+ router = APIRouter(prefix="/api/v1", tags=["current-tag"])
11
+
12
+ @router.get(
13
+ "/current-tag",
14
+ response_model=CurrentTagStatusOutput,
15
+ responses={204: {"description": "No current tag"}},
16
+ summary="Get the current NFC tag status",
17
+ )
18
+ def get_current_tag() -> Any:
19
+ current_tag_status = get_current_tag_status.execute()
20
+ if current_tag_status is None:
21
+ return Response(status_code=204)
22
+
23
+ return CurrentTagStatusOutput(**current_tag_status.model_dump())
24
+
25
+ return router
@@ -0,0 +1,48 @@
1
+ from typing import Dict
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from discstore.adapters.inbound.api.models import DiscInput, DiscOutput
6
+ from discstore.domain.entities import Disc
7
+ from discstore.domain.use_cases.add_disc import AddDisc
8
+ from discstore.domain.use_cases.edit_disc import EditDisc
9
+ from discstore.domain.use_cases.list_discs import ListDiscs
10
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
11
+
12
+
13
+ def build_discs_router(
14
+ add_disc: AddDisc,
15
+ list_discs: ListDiscs,
16
+ remove_disc: RemoveDisc,
17
+ edit_disc: EditDisc,
18
+ ) -> APIRouter:
19
+ router = APIRouter(prefix="/api/v1", tags=["discs"])
20
+
21
+ @router.get("/discs", response_model=Dict[str, DiscOutput], summary="List discs")
22
+ def list_discs_route() -> Dict[str, Disc]:
23
+ return list_discs.execute()
24
+
25
+ @router.post("/disc", status_code=201, summary="Create or update a disc")
26
+ def add_or_edit_disc(tag_id: str, disc: DiscInput) -> Dict[str, str]:
27
+ try:
28
+ self_disc = Disc(**disc.model_dump())
29
+ add_disc.execute(tag_id, self_disc)
30
+ return {"message": "Disc added"}
31
+ except ValueError:
32
+ new_disc = Disc(**disc.model_dump())
33
+ edit_disc.execute(tag_id, new_disc.uri, new_disc.metadata, new_disc.option)
34
+ return {"message": "Disc edited"}
35
+ except Exception as err:
36
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
37
+
38
+ @router.delete("/disc", status_code=200, summary="Delete a disc")
39
+ def remove_disc_route(tag_id: str) -> Dict[str, str]:
40
+ try:
41
+ remove_disc.execute(tag_id)
42
+ return {"message": "Disc removed"}
43
+ except ValueError as value_err:
44
+ raise HTTPException(status_code=404, detail=str(value_err))
45
+ except Exception as err:
46
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
47
+
48
+ return router
@@ -0,0 +1,25 @@
1
+ from typing import Any, Dict
2
+
3
+ from pydantic import BaseModel, RootModel
4
+
5
+ from discstore.domain.entities import CurrentTagStatus, Disc
6
+
7
+
8
+ class DiscInput(Disc):
9
+ pass
10
+
11
+
12
+ class DiscOutput(Disc):
13
+ pass
14
+
15
+
16
+ class CurrentTagStatusOutput(CurrentTagStatus):
17
+ pass
18
+
19
+
20
+ class SettingsResetInput(BaseModel):
21
+ path: str
22
+
23
+
24
+ class SettingsPatchInput(RootModel[Dict[str, Any]]):
25
+ pass
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, cast
2
+
3
+ from fastapi import APIRouter, HTTPException
4
+
5
+ from discstore.adapters.inbound.api.models import SettingsPatchInput, SettingsResetInput
6
+ from jukebox.settings.errors import SettingsError
7
+ from jukebox.settings.service_protocols import SettingsService
8
+ from jukebox.settings.types import JsonObject
9
+
10
+
11
+ def build_settings_router(settings_service: SettingsService) -> APIRouter:
12
+ router = APIRouter(prefix="/api/v1", tags=["settings"])
13
+
14
+ @router.get("/settings", response_model=Dict[str, Any], summary="Get persisted settings")
15
+ def get_settings() -> JsonObject:
16
+ try:
17
+ return settings_service.get_persisted_settings_view()
18
+ except Exception as err:
19
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
20
+
21
+ @router.get("/settings/effective", response_model=Dict[str, Any], summary="Get effective settings")
22
+ def get_effective_settings() -> JsonObject:
23
+ try:
24
+ return settings_service.get_effective_settings_view()
25
+ except Exception as err:
26
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
27
+
28
+ @router.patch("/settings", response_model=Dict[str, Any], summary="Patch persisted settings")
29
+ def patch_settings(patch: SettingsPatchInput) -> JsonObject:
30
+ try:
31
+ return settings_service.patch_persisted_settings(cast(JsonObject, patch.root))
32
+ except SettingsError as err:
33
+ raise HTTPException(status_code=400, detail=str(err))
34
+ except Exception as err:
35
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
36
+
37
+ @router.post("/settings/reset", response_model=Dict[str, Any], summary="Reset a persisted setting")
38
+ def reset_settings(payload: SettingsResetInput) -> JsonObject:
39
+ try:
40
+ return settings_service.reset_persisted_value(payload.path)
41
+ except SettingsError as err:
42
+ raise HTTPException(status_code=400, detail=str(err))
43
+ except Exception as err:
44
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
45
+
46
+ return router
@@ -0,0 +1,166 @@
1
+ from typing import Optional
2
+
3
+ from pydantic import BaseModel
4
+
5
+ from jukebox.shared.dependency_messages import optional_extra_dependency_message
6
+
7
+ try:
8
+ from fastapi import FastAPI, HTTPException
9
+
10
+ from discstore.adapters.inbound.api.current_tag_router import build_current_tag_router
11
+ from discstore.adapters.inbound.api.discs_router import build_discs_router
12
+ from discstore.adapters.inbound.api.models import (
13
+ CurrentTagStatusOutput,
14
+ DiscInput,
15
+ DiscOutput,
16
+ SettingsPatchInput,
17
+ SettingsResetInput,
18
+ )
19
+ from discstore.adapters.inbound.api.settings_router import build_settings_router
20
+ except ModuleNotFoundError as e:
21
+ if e.name != "fastapi":
22
+ raise
23
+ raise ModuleNotFoundError(
24
+ optional_extra_dependency_message("The `api_controller` module", "api", "discstore api")
25
+ ) from e
26
+ from discstore.domain.use_cases.add_disc import AddDisc
27
+ from discstore.domain.use_cases.edit_disc import EditDisc
28
+ from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
29
+ from discstore.domain.use_cases.list_discs import ListDiscs
30
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
31
+ from jukebox.settings.entities import SelectedSonosGroupSettings
32
+ from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
33
+ from jukebox.settings.service_protocols import SettingsService
34
+ from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError
35
+ from jukebox.sonos.selection import GetSonosSelectionStatus, PlanSonosSelection, SaveSonosSelection
36
+ from jukebox.sonos.service import SonosService
37
+
38
+ __all__ = [
39
+ "APIController",
40
+ "CurrentTagStatusOutput",
41
+ "DiscInput",
42
+ "DiscOutput",
43
+ "SettingsPatchInput",
44
+ "SettingsResetInput",
45
+ "SonosSelectionInput",
46
+ ]
47
+
48
+
49
+ class SonosSpeakerOutput(DiscoveredSonosSpeaker):
50
+ pass
51
+
52
+
53
+ class SelectedSonosGroupOutput(SelectedSonosGroupSettings):
54
+ pass
55
+
56
+
57
+ class SonosSelectionAvailabilityOutput(BaseModel):
58
+ status: str
59
+ speaker: Optional[SonosSpeakerOutput] = None
60
+
61
+
62
+ class SonosSelectionOutput(BaseModel):
63
+ selected_group: Optional[SelectedSonosGroupOutput] = None
64
+ availability: SonosSelectionAvailabilityOutput
65
+
66
+
67
+ class SonosSelectionInput(BaseModel):
68
+ uids: list[str]
69
+
70
+
71
+ class SonosSelectionUpdateOutput(BaseModel):
72
+ selected_group: SelectedSonosGroupOutput
73
+ availability: SonosSelectionAvailabilityOutput
74
+ message: str
75
+ restart_required: bool
76
+
77
+
78
+ class APIController:
79
+ def __init__(
80
+ self,
81
+ add_disc: AddDisc,
82
+ list_discs: ListDiscs,
83
+ remove_disc: RemoveDisc,
84
+ edit_disc: EditDisc,
85
+ get_current_tag_status: GetCurrentTagStatus,
86
+ settings_service: SettingsService,
87
+ sonos_service: SonosService,
88
+ ):
89
+ self.add_disc = add_disc
90
+ self.list_discs = list_discs
91
+ self.remove_disc = remove_disc
92
+ self.edit_disc = edit_disc
93
+ self.get_current_tag_status = get_current_tag_status
94
+ self.settings_service = settings_service
95
+ self.sonos_service = sonos_service
96
+ self.app = FastAPI(
97
+ title="DiscStore API",
98
+ description="API for managing Jukebox disc library",
99
+ docs_url="/docs",
100
+ redoc_url="/redoc",
101
+ )
102
+ self.register_routes()
103
+
104
+ def register_routes(self):
105
+ self.app.include_router(
106
+ build_discs_router(
107
+ add_disc=self.add_disc,
108
+ list_discs=self.list_discs,
109
+ remove_disc=self.remove_disc,
110
+ edit_disc=self.edit_disc,
111
+ )
112
+ )
113
+ self.app.include_router(build_current_tag_router(self.get_current_tag_status))
114
+ self.app.include_router(build_settings_router(self.settings_service))
115
+
116
+ @self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput])
117
+ def get_sonos_speakers():
118
+ try:
119
+ return self.sonos_service.list_available_speakers()
120
+ except SonosDiscoveryError as err:
121
+ raise HTTPException(status_code=502, detail=str(err))
122
+ except Exception as err:
123
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
124
+
125
+ @self.app.get("/api/v1/sonos/selection", response_model=SonosSelectionOutput)
126
+ def get_sonos_selection():
127
+ try:
128
+ return GetSonosSelectionStatus(
129
+ SettingsSelectedSonosGroupRepository(self.settings_service),
130
+ self.sonos_service,
131
+ ).execute()
132
+ except SonosDiscoveryError as err:
133
+ raise HTTPException(status_code=502, detail=str(err))
134
+ except Exception as err:
135
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
136
+
137
+ @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput)
138
+ def put_sonos_selection(payload: SonosSelectionInput):
139
+ try:
140
+ plan = PlanSonosSelection(self.sonos_service).execute(requested_uids=payload.uids)
141
+ if plan.status in {"invalid_request", "none_available"}:
142
+ raise HTTPException(status_code=400, detail=str(plan.error_message))
143
+ if plan.status == "needs_choice" or plan.selected_uid is None:
144
+ raise HTTPException(status_code=400, detail="No Sonos speaker selection was made.")
145
+
146
+ result = SaveSonosSelection(
147
+ SettingsSelectedSonosGroupRepository(self.settings_service),
148
+ self.sonos_service,
149
+ ).execute(plan.selected_uid)
150
+ return SonosSelectionUpdateOutput(
151
+ selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()),
152
+ availability=SonosSelectionAvailabilityOutput(
153
+ status="available",
154
+ speaker=SonosSpeakerOutput(**result.speaker.model_dump()),
155
+ ),
156
+ message=result.settings_message,
157
+ restart_required=result.restart_required,
158
+ )
159
+ except SonosDiscoveryError as err:
160
+ raise HTTPException(status_code=502, detail=str(err))
161
+ except ValueError as err:
162
+ raise HTTPException(status_code=400, detail=str(err))
163
+ except HTTPException:
164
+ raise
165
+ except Exception as err:
166
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
@@ -16,7 +16,7 @@ class JukeboxCliConfig(BaseModel):
16
16
  library: Optional[str] = None
17
17
  verbose: bool = False
18
18
  player: Optional[Literal["dryrun", "sonos"]] = None
19
- reader: Optional[Literal["dryrun", "nfc"]] = None
19
+ reader: Optional[Literal["dryrun", "pn532"]] = None
20
20
  sonos_host: Optional[str] = None
21
21
  sonos_name: Optional[str] = None
22
22
  pause_duration_seconds: Optional[int] = None
@@ -40,7 +40,7 @@ def parse_config() -> JukeboxCliConfig:
40
40
  add_version_arg(parser)
41
41
 
42
42
  parser.add_argument("positional_player", nargs="?", choices=["dryrun", "sonos"], help="override the player type")
43
- parser.add_argument("positional_reader", nargs="?", choices=["dryrun", "nfc"], help="override the reader type")
43
+ parser.add_argument("positional_reader", nargs="?", choices=["dryrun", "pn532"], help="override the reader type")
44
44
 
45
45
  parser.add_argument(
46
46
  "--player",
@@ -50,7 +50,7 @@ def parse_config() -> JukeboxCliConfig:
50
50
  )
51
51
  parser.add_argument(
52
52
  "--reader",
53
- choices=["dryrun", "nfc"],
53
+ choices=["dryrun", "pn532"],
54
54
  default=None,
55
55
  help="override the reader type without providing both positional type arguments",
56
56
  )
@@ -9,7 +9,7 @@ from jukebox.shared.dependency_messages import optional_extra_dependency_message
9
9
  try:
10
10
  from pn532 import PN532_SPI
11
11
  except ModuleNotFoundError as err:
12
- raise ModuleNotFoundError(optional_extra_dependency_message("The `nfc` reader", "nfc", "jukebox ...")) from err
12
+ raise ModuleNotFoundError(optional_extra_dependency_message("The `pn532` reader", "pn532", "jukebox ...")) from err
13
13
 
14
14
  from jukebox.domain.ports import ReaderPort
15
15
  from jukebox.shared.timing import DEFAULT_NFC_READ_TIMEOUT_SECONDS
@@ -25,13 +25,13 @@ def spi_active():
25
25
  return any(dev.startswith("spidev") for dev in os.listdir("/dev"))
26
26
 
27
27
 
28
- class NfcReaderAdapter(ReaderPort):
29
- """Adapter for NFC reader implementing ReaderPort."""
28
+ class Pn532ReaderAdapter(ReaderPort):
29
+ """Adapter for Pn532 NFC reader implementing ReaderPort."""
30
30
 
31
31
  def __init__(self, read_timeout_seconds: float = DEFAULT_NFC_READ_TIMEOUT_SECONDS):
32
32
  if not spi_active():
33
33
  error_message = (
34
- "The SPI interface is not enabled. Please enable it to use the NFC reader."
34
+ "The SPI interface is not enabled. Please enable it to use the PN532 NFC reader."
35
35
  "You can enable SPI using `sudo raspi-config` then navigate to: Interface Options > SPI > Enable > Yes."
36
36
  )
37
37
  LOGGER.error(error_message)
@@ -20,8 +20,9 @@ from discstore.di_container import build_cli_controller, build_interactive_cli_c
20
20
  from jukebox.settings.errors import SettingsError
21
21
  from jukebox.shared.config_utils import get_package_version
22
22
  from jukebox.shared.logger import set_logger
23
+ from jukebox.sonos.discovery import DiscoveredSonosSpeaker
23
24
 
24
- from .cli_presentation import render_cli_error
25
+ from .cli_presentation import build_sonos_speaker_choice_label, render_cli_error
25
26
  from .command_handlers import execute_server_command, execute_settings_command, execute_sonos_command
26
27
  from .commands import (
27
28
  ApiCommand,
@@ -29,6 +30,8 @@ from .commands import (
29
30
  SettingsSetCommand,
30
31
  SettingsShowCommand,
31
32
  SonosListCommand,
33
+ SonosSelectCommand,
34
+ SonosShowCommand,
32
35
  UiCommand,
33
36
  is_settings_command,
34
37
  is_sonos_command,
@@ -74,7 +77,12 @@ def _run_command(ctx: typer.Context, command: object) -> None:
74
77
  source_command="jukebox-admin",
75
78
  )
76
79
  elif is_sonos_command(command):
77
- execute_sonos_command(command=command, sonos_service=services.sonos)
80
+ execute_sonos_command(
81
+ command=command,
82
+ sonos_service=services.sonos,
83
+ settings_service=services.settings,
84
+ speaker_prompt_fn=_prompt_for_sonos_speaker_selection,
85
+ )
78
86
  else:
79
87
  execute_server_command(
80
88
  verbose=state.verbose,
@@ -146,10 +154,28 @@ def _exit_on_command_validation_error(err: ValidationError) -> None:
146
154
  raise SystemExit(str(err)) from err
147
155
 
148
156
 
157
+ def _prompt_for_sonos_speaker_selection(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
158
+ import questionary
159
+
160
+ try:
161
+ return questionary.select(
162
+ "Select a Sonos speaker",
163
+ choices=[
164
+ questionary.Choice(
165
+ title=build_sonos_speaker_choice_label(speaker),
166
+ value=speaker.uid,
167
+ )
168
+ for speaker in speakers
169
+ ],
170
+ ).ask()
171
+ except KeyboardInterrupt:
172
+ return None
173
+
174
+
149
175
  app = typer.Typer(help="Admin CLI for jukebox")
150
176
  settings_app = typer.Typer(help="Inspect and manage application settings")
151
177
  library_app = typer.Typer(help="Manage the library")
152
- sonos_app = typer.Typer(help="Inspect Sonos speakers discovered on the network")
178
+ sonos_app = typer.Typer(help="Inspect Sonos speakers and manage the saved Sonos selection")
153
179
  app.add_typer(settings_app, name="settings")
154
180
  app.add_typer(library_app, name="library")
155
181
  app.add_typer(sonos_app, name="sonos")
@@ -263,6 +289,22 @@ def sonos_list(ctx: typer.Context) -> None:
263
289
  _run_command(ctx, SonosListCommand(type="sonos_list"))
264
290
 
265
291
 
292
+ @sonos_app.command("select")
293
+ def sonos_select(
294
+ ctx: typer.Context,
295
+ uids: Annotated[
296
+ Optional[list[str]],
297
+ typer.Option("--uids", help="discover and persist exactly one Sonos speaker UID"),
298
+ ] = None,
299
+ ) -> None:
300
+ _run_command(ctx, SonosSelectCommand(type="sonos_select", uids=uids))
301
+
302
+
303
+ @sonos_app.command("show")
304
+ def sonos_show(ctx: typer.Context) -> None:
305
+ _run_command(ctx, SonosShowCommand(type="sonos_show"))
306
+
307
+
266
308
  @library_app.command("add")
267
309
  def library_add(
268
310
  ctx: typer.Context,
@@ -13,6 +13,7 @@ from jukebox.settings.errors import (
13
13
  from jukebox.settings.types import JsonObject, JsonValue
14
14
  from jukebox.settings.view_utils import MISSING, lookup_object, lookup_optional_dotted_path, lookup_provenance_label
15
15
  from jukebox.sonos.discovery import DiscoveredSonosSpeaker
16
+ from jukebox.sonos.selection import SonosSelectionResult, SonosSelectionStatus
16
17
 
17
18
  from .commands import SettingsResetCommand, SettingsSetCommand, SettingsShowCommand
18
19
 
@@ -73,6 +74,39 @@ def render_sonos_speakers_output(speakers: list[DiscoveredSonosSpeaker]) -> str:
73
74
  )
74
75
 
75
76
 
77
+ def build_sonos_speaker_choice_label(speaker: DiscoveredSonosSpeaker) -> str:
78
+ return "{} ({})".format(speaker.name, speaker.host)
79
+
80
+
81
+ def render_sonos_selection_saved_output(result: SonosSelectionResult) -> str:
82
+ return "\n".join(
83
+ [
84
+ "Selected Sonos speaker: {}".format(result.speaker.name),
85
+ "UID: {}".format(result.speaker.uid),
86
+ result.settings_message,
87
+ ]
88
+ )
89
+
90
+
91
+ def render_sonos_selection_status_output(status: SonosSelectionStatus) -> str:
92
+ lines = ["Selected Sonos Speaker", ""]
93
+
94
+ if status.selected_group is None:
95
+ lines.append("- Status: not selected")
96
+ return "\n".join(lines)
97
+
98
+ lines.append("- UID: {}".format(status.selected_group.coordinator_uid))
99
+ lines.append("- Status: {}".format(status.availability.status))
100
+
101
+ speaker = status.availability.speaker
102
+ if speaker is not None:
103
+ lines.append("- Name: {}".format(speaker.name))
104
+ lines.append("- Host: {}".format(speaker.host))
105
+ lines.append("- Household: {}".format(speaker.household_id))
106
+
107
+ return "\n".join(lines)
108
+
109
+
76
110
  def _render_persisted_settings(payload: JsonObject) -> str:
77
111
  lines = ["Persisted Settings", "Schema Version: {}".format(payload.get("schema_version", "unknown"))]
78
112
  entries = list(_collect_persisted_entries(payload))