gukebox 1.0.0.dev4__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 (112) hide show
  1. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/PKG-INFO +10 -9
  2. {gukebox-1.0.0.dev4 → 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.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/ui_controller.py +11 -1
  9. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/app.py +25 -12
  10. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/inbound/config.py +3 -3
  11. gukebox-1.0.0.dev4/jukebox/adapters/outbound/readers/nfc_reader_adapter.py → gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +4 -4
  12. gukebox-1.0.0.dev6/jukebox/adapters/outbound/sonos_discovery_adapter.py +199 -0
  13. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/admin/app.py +81 -14
  14. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/admin/cli_presentation.py +60 -5
  15. gukebox-1.0.0.dev6/jukebox/admin/command_handlers.py +197 -0
  16. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/admin/commands.py +23 -0
  17. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/admin/di_container.py +27 -4
  18. gukebox-1.0.0.dev6/jukebox/admin/services.py +10 -0
  19. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/app.py +9 -1
  20. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/di_container.py +3 -3
  21. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/__init__.py +0 -3
  22. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/definitions.py +5 -5
  23. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/entities.py +8 -8
  24. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/resolve.py +8 -61
  25. gukebox-1.0.0.dev6/jukebox/settings/runtime_resolver.py +59 -0
  26. gukebox-1.0.0.dev6/jukebox/settings/selected_sonos_group_repository.py +60 -0
  27. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/service_protocols.py +8 -2
  28. gukebox-1.0.0.dev6/jukebox/shared/__init__.py +0 -0
  29. gukebox-1.0.0.dev6/jukebox/sonos/__init__.py +32 -0
  30. gukebox-1.0.0.dev6/jukebox/sonos/discovery.py +25 -0
  31. gukebox-1.0.0.dev6/jukebox/sonos/selection.py +144 -0
  32. gukebox-1.0.0.dev6/jukebox/sonos/service.py +73 -0
  33. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/pn532/pn532.py +2 -2
  34. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/pn532/spi.py +1 -1
  35. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/pyproject.toml +3 -2
  36. gukebox-1.0.0.dev4/discstore/adapters/inbound/api_controller.py +0 -138
  37. gukebox-1.0.0.dev4/jukebox/admin/command_handlers.py +0 -115
  38. gukebox-1.0.0.dev4/jukebox/settings/sonos_runtime.py +0 -175
  39. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/LICENSE +0 -0
  40. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/__init__.py +0 -0
  41. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/__init__.py +0 -0
  42. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/__init__.py +0 -0
  43. {gukebox-1.0.0.dev4/discstore/adapters/outbound → gukebox-1.0.0.dev6/discstore/adapters/inbound/api}/__init__.py +0 -0
  44. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/cli_controller.py +0 -0
  45. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/cli_display.py +0 -0
  46. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/config.py +0 -0
  47. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  48. {gukebox-1.0.0.dev4/discstore/domain → gukebox-1.0.0.dev6/discstore/adapters/outbound}/__init__.py +0 -0
  49. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  50. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  51. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/command_handlers.py +0 -0
  52. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/commands.py +0 -0
  53. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/di_container.py +0 -0
  54. {gukebox-1.0.0.dev4/jukebox → gukebox-1.0.0.dev6/discstore/domain}/__init__.py +0 -0
  55. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/entities/__init__.py +0 -0
  56. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/entities/current_tag_status.py +0 -0
  57. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/repositories/__init__.py +0 -0
  58. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/__init__.py +0 -0
  59. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/add_disc.py +0 -0
  60. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/edit_disc.py +0 -0
  61. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  62. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/get_disc.py +0 -0
  63. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/list_discs.py +0 -0
  64. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/remove_disc.py +0 -0
  65. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  66. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/discstore/domain/use_cases/search_discs.py +0 -0
  67. {gukebox-1.0.0.dev4/jukebox/adapters → gukebox-1.0.0.dev6/jukebox}/__init__.py +0 -0
  68. {gukebox-1.0.0.dev4/jukebox/adapters/inbound → gukebox-1.0.0.dev6/jukebox/adapters}/__init__.py +0 -0
  69. {gukebox-1.0.0.dev4/jukebox/adapters/outbound → gukebox-1.0.0.dev6/jukebox/adapters/inbound}/__init__.py +0 -0
  70. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/inbound/cli_controller.py +0 -0
  71. {gukebox-1.0.0.dev4/jukebox/adapters/outbound/players → gukebox-1.0.0.dev6/jukebox/adapters/outbound}/__init__.py +0 -0
  72. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  73. {gukebox-1.0.0.dev4/jukebox/adapters/outbound/readers → gukebox-1.0.0.dev6/jukebox/adapters/outbound/players}/__init__.py +0 -0
  74. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  75. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  76. {gukebox-1.0.0.dev4/jukebox/domain → gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers}/__init__.py +0 -0
  77. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  78. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  79. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/admin/__init__.py +0 -0
  80. {gukebox-1.0.0.dev4/jukebox/shared → gukebox-1.0.0.dev6/jukebox/domain}/__init__.py +0 -0
  81. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/__init__.py +0 -0
  82. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/current_tag_action.py +0 -0
  83. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/disc.py +0 -0
  84. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/library.py +0 -0
  85. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/playback_action.py +0 -0
  86. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/playback_session.py +0 -0
  87. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/entities/tag_event.py +0 -0
  88. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/ports/__init__.py +0 -0
  89. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/ports/player_port.py +0 -0
  90. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/ports/reader_port.py +0 -0
  91. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/__init__.py +0 -0
  92. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  93. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/repositories/library_repository.py +0 -0
  94. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/__init__.py +0 -0
  95. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/determine_action.py +0 -0
  96. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  97. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  98. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/dict_utils.py +0 -0
  99. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/errors.py +0 -0
  100. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/file_settings_repository.py +0 -0
  101. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/migration.py +0 -0
  102. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/repositories.py +0 -0
  103. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/runtime_validation.py +0 -0
  104. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/timing_validation.py +0 -0
  105. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/types.py +0 -0
  106. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/validation_rules.py +0 -0
  107. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/settings/view_utils.py +0 -0
  108. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/shared/config_utils.py +0 -0
  109. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/shared/dependency_messages.py +0 -0
  110. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/shared/logger.py +0 -0
  111. {gukebox-1.0.0.dev4 → gukebox-1.0.0.dev6}/jukebox/shared/timing.py +0 -0
  112. {gukebox-1.0.0.dev4 → 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.dev4
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)}")
@@ -42,6 +42,7 @@ from jukebox.settings.definitions import (
42
42
  from jukebox.settings.errors import SettingsError
43
43
  from jukebox.settings.service_protocols import SettingsService
44
44
  from jukebox.settings.types import JsonObject
45
+ from jukebox.sonos.service import SonosService
45
46
 
46
47
  _MISSING = object()
47
48
 
@@ -74,9 +75,18 @@ class UIController(APIController):
74
75
  get_disc: GetDisc,
75
76
  get_current_tag_status: GetCurrentTagStatus,
76
77
  settings_service: SettingsService,
78
+ sonos_service: SonosService,
77
79
  ):
78
80
  self.get_disc = get_disc
79
- super().__init__(add_disc, list_discs, remove_disc, edit_disc, get_current_tag_status, settings_service)
81
+ super().__init__(
82
+ add_disc,
83
+ list_discs,
84
+ remove_disc,
85
+ edit_disc,
86
+ get_current_tag_status,
87
+ settings_service,
88
+ sonos_service,
89
+ )
80
90
 
81
91
  def register_routes(self):
82
92
  super().register_routes()
@@ -10,10 +10,11 @@ from discstore.command_handlers import execute_library_command
10
10
  from discstore.commands import is_library_command
11
11
  from discstore.di_container import build_cli_controller, build_interactive_cli_controller
12
12
  from jukebox.admin.cli_presentation import render_cli_error
13
- from jukebox.admin.command_handlers import execute_admin_command
14
- from jukebox.admin.commands import is_admin_command
13
+ from jukebox.admin.command_handlers import execute_server_command, execute_settings_command
14
+ from jukebox.admin.commands import is_admin_command, is_settings_command
15
15
  from jukebox.admin.di_container import (
16
16
  build_admin_api_app,
17
+ build_admin_services,
17
18
  build_admin_ui_app,
18
19
  )
19
20
  from jukebox.admin.di_container import (
@@ -37,24 +38,36 @@ def main():
37
38
  config = parse_config()
38
39
  set_logger("discstore", config.verbose)
39
40
  try:
40
- settings_service = _build_settings_service(config)
41
41
  if is_admin_command(config.command):
42
+ services = build_admin_services(
43
+ library=config.library,
44
+ command=config.command,
45
+ logger_warning=LOGGER.warning,
46
+ )
42
47
  try:
43
- execute_admin_command(
44
- verbose=config.verbose,
45
- command=config.command,
46
- settings_service=settings_service,
47
- build_api_app=build_admin_api_app,
48
- build_ui_app=build_admin_ui_app,
49
- source_command="discstore",
50
- library=config.library,
51
- )
48
+ if is_settings_command(config.command):
49
+ execute_settings_command(
50
+ command=config.command,
51
+ settings_service=services.settings,
52
+ source_command="discstore",
53
+ library=config.library,
54
+ )
55
+ else:
56
+ execute_server_command(
57
+ verbose=config.verbose,
58
+ command=config.command,
59
+ services=services,
60
+ build_api_app=build_admin_api_app,
61
+ build_ui_app=build_admin_ui_app,
62
+ source_command="discstore",
63
+ )
52
64
  except RuntimeError as err:
53
65
  print(str(err), file=sys.stderr)
54
66
  raise SystemExit(1) from err
55
67
  return
56
68
 
57
69
  if is_library_command(config.command):
70
+ settings_service = _build_settings_service(config)
58
71
  try:
59
72
  execute_library_command(
60
73
  verbose=config.verbose,
@@ -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)