gukebox 1.0.0.dev5__tar.gz → 1.0.0.dev7__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.dev7}/PKG-INFO +10 -9
  2. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/README.md +4 -4
  3. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/__init__.py +25 -0
  4. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/current_tag_router.py +161 -0
  5. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/discs_router.py +73 -0
  6. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/models.py +48 -0
  7. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/settings_router.py +46 -0
  8. gukebox-1.0.0.dev7/discstore/adapters/inbound/api_controller.py +190 -0
  9. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/ui_controller.py +1 -0
  10. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/add_disc.py +2 -1
  11. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/edit_disc.py +2 -1
  12. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/config.py +3 -3
  13. gukebox-1.0.0.dev5/jukebox/adapters/outbound/readers/nfc_reader_adapter.py → gukebox-1.0.0.dev7/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +4 -4
  14. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/sonos_discovery_adapter.py +1 -4
  15. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/app.py +82 -3
  16. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/cli_presentation.py +106 -50
  17. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/command_handlers.py +61 -0
  18. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/commands.py +22 -2
  19. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/di_container.py +1 -0
  20. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/di_container.py +3 -3
  21. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/definitions.py +5 -5
  22. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/entities.py +12 -9
  23. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_resolver.py +1 -1
  24. gukebox-1.0.0.dev7/jukebox/settings/selected_sonos_group_repository.py +60 -0
  25. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/sonos/__init__.py +12 -0
  26. gukebox-1.0.0.dev7/jukebox/sonos/selection.py +168 -0
  27. gukebox-1.0.0.dev7/jukebox/sonos/service.py +125 -0
  28. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/pn532/pn532.py +2 -2
  29. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/pn532/spi.py +1 -1
  30. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/pyproject.toml +4 -2
  31. gukebox-1.0.0.dev5/discstore/adapters/inbound/api_controller.py +0 -155
  32. gukebox-1.0.0.dev5/jukebox/sonos/service.py +0 -73
  33. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/LICENSE +0 -0
  34. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/__init__.py +0 -0
  35. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/__init__.py +0 -0
  36. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/__init__.py +0 -0
  37. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_controller.py +0 -0
  38. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_display.py +0 -0
  39. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/config.py +0 -0
  40. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  41. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/__init__.py +0 -0
  42. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  43. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  44. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/app.py +0 -0
  45. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/command_handlers.py +0 -0
  46. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/commands.py +0 -0
  47. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/di_container.py +0 -0
  48. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/__init__.py +0 -0
  49. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/entities/__init__.py +0 -0
  50. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/entities/current_tag_status.py +0 -0
  51. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/repositories/__init__.py +0 -0
  52. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/__init__.py +0 -0
  53. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  54. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_disc.py +0 -0
  55. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/list_discs.py +0 -0
  56. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/remove_disc.py +0 -0
  57. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  58. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/search_discs.py +0 -0
  59. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/__init__.py +0 -0
  60. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/__init__.py +0 -0
  61. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/__init__.py +0 -0
  62. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/cli_controller.py +0 -0
  63. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/__init__.py +0 -0
  64. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  65. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/__init__.py +0 -0
  66. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  67. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  68. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  69. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  70. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  71. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/__init__.py +0 -0
  72. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/admin/services.py +0 -0
  73. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/app.py +0 -0
  74. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/__init__.py +0 -0
  75. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/__init__.py +0 -0
  76. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/current_tag_action.py +0 -0
  77. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/disc.py +0 -0
  78. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/library.py +0 -0
  79. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_action.py +0 -0
  80. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_session.py +0 -0
  81. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/entities/tag_event.py +0 -0
  82. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/ports/__init__.py +0 -0
  83. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/ports/player_port.py +0 -0
  84. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/ports/reader_port.py +0 -0
  85. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/__init__.py +0 -0
  86. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  87. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/library_repository.py +0 -0
  88. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/__init__.py +0 -0
  89. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_action.py +0 -0
  90. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  91. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  92. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/__init__.py +0 -0
  93. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/dict_utils.py +0 -0
  94. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/errors.py +0 -0
  95. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/file_settings_repository.py +0 -0
  96. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/migration.py +0 -0
  97. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/repositories.py +0 -0
  98. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/resolve.py +0 -0
  99. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_validation.py +0 -0
  100. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/service_protocols.py +0 -0
  101. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/timing_validation.py +0 -0
  102. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/types.py +0 -0
  103. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/validation_rules.py +0 -0
  104. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/settings/view_utils.py +0 -0
  105. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/shared/__init__.py +0 -0
  106. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/shared/config_utils.py +0 -0
  107. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/shared/dependency_messages.py +0 -0
  108. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/shared/logger.py +0 -0
  109. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/shared/timing.py +0 -0
  110. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/jukebox/sonos/discovery.py +0 -0
  111. {gukebox-1.0.0.dev5 → gukebox-1.0.0.dev7}/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.dev7
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 discstore.adapters.inbound.api.current_tag_router import build_current_tag_router
2
+ from discstore.adapters.inbound.api.discs_router import build_discs_router
3
+ from discstore.adapters.inbound.api.models import (
4
+ CurrentTagDiscOutput,
5
+ CurrentTagStatusOutput,
6
+ DiscInput,
7
+ DiscOutput,
8
+ DiscPatchInput,
9
+ SettingsPatchInput,
10
+ SettingsResetInput,
11
+ )
12
+ from discstore.adapters.inbound.api.settings_router import build_settings_router
13
+
14
+ __all__ = [
15
+ "CurrentTagDiscOutput",
16
+ "CurrentTagStatusOutput",
17
+ "DiscInput",
18
+ "DiscOutput",
19
+ "DiscPatchInput",
20
+ "SettingsPatchInput",
21
+ "SettingsResetInput",
22
+ "build_current_tag_router",
23
+ "build_discs_router",
24
+ "build_settings_router",
25
+ ]
@@ -0,0 +1,161 @@
1
+ from typing import Any, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException, Response, status
4
+
5
+ from discstore.adapters.inbound.api.models import (
6
+ CurrentTagDiscOutput,
7
+ CurrentTagStatusOutput,
8
+ DiscInput,
9
+ DiscOutput,
10
+ DiscPatchInput,
11
+ )
12
+ from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption
13
+ from discstore.domain.use_cases.add_disc import AddDisc
14
+ from discstore.domain.use_cases.edit_disc import EditDisc
15
+ from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
16
+ from discstore.domain.use_cases.get_disc import GetDisc
17
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
18
+
19
+
20
+ def build_current_tag_router(
21
+ get_current_tag_status: GetCurrentTagStatus,
22
+ add_disc: AddDisc,
23
+ edit_disc: EditDisc,
24
+ get_disc: GetDisc,
25
+ remove_disc: RemoveDisc,
26
+ ) -> APIRouter:
27
+ router = APIRouter(prefix="/api/v1", tags=["current-tag"])
28
+
29
+ def read_current_tag_status() -> Optional[CurrentTagStatus]:
30
+ return get_current_tag_status.execute()
31
+
32
+ def ensure_expected_tag_id_matches(
33
+ expected_tag_id: Optional[str], current_tag_status: Optional[CurrentTagStatus]
34
+ ) -> None:
35
+ if expected_tag_id is None:
36
+ return
37
+
38
+ actual_tag_id = None if current_tag_status is None else current_tag_status.tag_id
39
+ if actual_tag_id != expected_tag_id:
40
+ raise HTTPException(
41
+ status_code=409,
42
+ detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={repr(actual_tag_id)}",
43
+ )
44
+
45
+ def build_current_tag_disc_output(tag_id: str, disc: Disc) -> CurrentTagDiscOutput:
46
+ return CurrentTagDiscOutput(tag_id=tag_id, disc=DiscOutput(**disc.model_dump()))
47
+
48
+ @router.get(
49
+ "/current-tag",
50
+ response_model=CurrentTagStatusOutput,
51
+ responses={204: {"description": "No current tag"}},
52
+ summary="Get the current NFC tag status",
53
+ )
54
+ def get_current_tag() -> Any:
55
+ current_tag_status = read_current_tag_status()
56
+ if current_tag_status is None:
57
+ return Response(status_code=204)
58
+
59
+ return CurrentTagStatusOutput(**current_tag_status.model_dump())
60
+
61
+ @router.get(
62
+ "/current-tag/disc",
63
+ response_model=CurrentTagDiscOutput,
64
+ responses={204: {"description": "No current tag"}, 404: {"description": "Current tag disc not found"}},
65
+ summary="Get the current tag disc",
66
+ )
67
+ def get_current_tag_disc() -> Any:
68
+ current_tag_status = read_current_tag_status()
69
+ if current_tag_status is None:
70
+ return Response(status_code=204)
71
+
72
+ if not current_tag_status.known_in_library:
73
+ raise HTTPException(status_code=404, detail=f"Tag does not exist: tag_id='{current_tag_status.tag_id}'")
74
+
75
+ return build_current_tag_disc_output(current_tag_status.tag_id, get_disc.execute(current_tag_status.tag_id))
76
+
77
+ @router.post(
78
+ "/current-tag/disc",
79
+ response_model=CurrentTagDiscOutput,
80
+ status_code=201,
81
+ responses={204: {"description": "No current tag"}, 409: {"description": "Current tag changed or disc exists"}},
82
+ summary="Create a disc for the current tag",
83
+ )
84
+ def create_current_tag_disc(
85
+ disc: DiscInput,
86
+ expected_tag_id: Optional[str] = None,
87
+ ) -> Any:
88
+ current_tag_status = read_current_tag_status()
89
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
90
+ if current_tag_status is None:
91
+ return Response(status_code=204)
92
+
93
+ try:
94
+ new_disc = Disc(**disc.model_dump())
95
+ add_disc.execute(current_tag_status.tag_id, new_disc)
96
+ return build_current_tag_disc_output(current_tag_status.tag_id, new_disc)
97
+ except ValueError as value_err:
98
+ raise HTTPException(status_code=409, detail=str(value_err))
99
+ except Exception as err:
100
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
101
+
102
+ @router.patch(
103
+ "/current-tag/disc",
104
+ response_model=CurrentTagDiscOutput,
105
+ responses={
106
+ 204: {"description": "No current tag"},
107
+ 404: {"description": "Current tag disc not found"},
108
+ 409: {"description": "Current tag changed"},
109
+ },
110
+ summary="Update the current tag disc",
111
+ )
112
+ def update_current_tag_disc(
113
+ disc_patch: DiscPatchInput,
114
+ expected_tag_id: Optional[str] = None,
115
+ ) -> Any:
116
+ current_tag_status = read_current_tag_status()
117
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
118
+ if current_tag_status is None:
119
+ return Response(status_code=204)
120
+
121
+ try:
122
+ metadata = None
123
+ if disc_patch.metadata is not None:
124
+ metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True, exclude_none=True))
125
+
126
+ option = None
127
+ if disc_patch.option is not None:
128
+ option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True, exclude_none=True))
129
+
130
+ edit_disc.execute(current_tag_status.tag_id, disc_patch.uri, metadata, option)
131
+ return build_current_tag_disc_output(current_tag_status.tag_id, get_disc.execute(current_tag_status.tag_id))
132
+ except ValueError as value_err:
133
+ raise HTTPException(status_code=404, detail=str(value_err))
134
+ except Exception as err:
135
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
136
+
137
+ @router.delete(
138
+ "/current-tag/disc",
139
+ status_code=204,
140
+ responses={
141
+ 204: {"description": "No current tag or disc deleted"},
142
+ 404: {"description": "Current tag disc not found"},
143
+ 409: {"description": "Current tag changed"},
144
+ },
145
+ summary="Delete the current tag disc",
146
+ )
147
+ def delete_current_tag_disc(expected_tag_id: Optional[str] = None) -> Response:
148
+ current_tag_status = read_current_tag_status()
149
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
150
+ if current_tag_status is None:
151
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
152
+
153
+ try:
154
+ remove_disc.execute(current_tag_status.tag_id)
155
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
156
+ except ValueError as value_err:
157
+ raise HTTPException(status_code=404, detail=str(value_err))
158
+ except Exception as err:
159
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
160
+
161
+ return router
@@ -0,0 +1,73 @@
1
+ from typing import Dict
2
+
3
+ from fastapi import APIRouter, HTTPException, Response, status
4
+
5
+ from discstore.adapters.inbound.api.models import DiscInput, DiscOutput, DiscPatchInput
6
+ from discstore.domain.entities import Disc, DiscMetadata, DiscOption
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.get_disc import GetDisc
10
+ from discstore.domain.use_cases.list_discs import ListDiscs
11
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
12
+
13
+
14
+ def build_discs_router(
15
+ add_disc: AddDisc,
16
+ list_discs: ListDiscs,
17
+ remove_disc: RemoveDisc,
18
+ edit_disc: EditDisc,
19
+ get_disc: GetDisc,
20
+ ) -> APIRouter:
21
+ router = APIRouter(prefix="/api/v1", tags=["discs"])
22
+
23
+ @router.get("/discs", response_model=Dict[str, DiscOutput], summary="List discs")
24
+ def list_discs_route() -> Dict[str, Disc]:
25
+ return list_discs.execute()
26
+
27
+ @router.get("/discs/{tag_id}", response_model=DiscOutput, summary="Get a disc")
28
+ def get_disc_route(tag_id: str) -> Disc:
29
+ try:
30
+ return get_disc.execute(tag_id)
31
+ except ValueError as value_err:
32
+ raise HTTPException(status_code=404, detail=str(value_err))
33
+ except Exception as err:
34
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
35
+
36
+ @router.post("/discs/{tag_id}", response_model=DiscOutput, status_code=201, summary="Create a disc")
37
+ def create_disc_route(tag_id: str, disc: DiscInput) -> Disc:
38
+ try:
39
+ new_disc = Disc(**disc.model_dump())
40
+ return add_disc.execute(tag_id, new_disc)
41
+ except ValueError as value_err:
42
+ raise HTTPException(status_code=409, detail=str(value_err))
43
+ except Exception as err:
44
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
45
+
46
+ @router.patch("/discs/{tag_id}", response_model=DiscOutput, summary="Update a disc")
47
+ def update_disc_route(tag_id: str, disc_patch: DiscPatchInput) -> Disc:
48
+ try:
49
+ metadata = None
50
+ if disc_patch.metadata is not None:
51
+ metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True, exclude_none=True))
52
+
53
+ option = None
54
+ if disc_patch.option is not None:
55
+ option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True, exclude_none=True))
56
+
57
+ return edit_disc.execute(tag_id, disc_patch.uri, metadata, option)
58
+ except ValueError as value_err:
59
+ raise HTTPException(status_code=404, detail=str(value_err))
60
+ except Exception as err:
61
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
62
+
63
+ @router.delete("/discs/{tag_id}", status_code=204, summary="Delete a disc")
64
+ def remove_disc_route(tag_id: str) -> Response:
65
+ try:
66
+ remove_disc.execute(tag_id)
67
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
68
+ except ValueError as value_err:
69
+ raise HTTPException(status_code=404, detail=str(value_err))
70
+ except Exception as err:
71
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
72
+
73
+ return router
@@ -0,0 +1,48 @@
1
+ from typing import Any, Dict, Optional
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 DiscPatchMetadataInput(BaseModel):
17
+ artist: Optional[str] = None
18
+ album: Optional[str] = None
19
+ track: Optional[str] = None
20
+ playlist: Optional[str] = None
21
+
22
+
23
+ class DiscPatchOptionInput(BaseModel):
24
+ shuffle: Optional[bool] = None
25
+ is_test: Optional[bool] = None
26
+
27
+
28
+ class DiscPatchInput(BaseModel):
29
+ uri: Optional[str] = None
30
+ metadata: Optional[DiscPatchMetadataInput] = None
31
+ option: Optional[DiscPatchOptionInput] = None
32
+
33
+
34
+ class CurrentTagStatusOutput(CurrentTagStatus):
35
+ pass
36
+
37
+
38
+ class CurrentTagDiscOutput(BaseModel):
39
+ tag_id: str
40
+ disc: DiscOutput
41
+
42
+
43
+ class SettingsResetInput(BaseModel):
44
+ path: str
45
+
46
+
47
+ class SettingsPatchInput(RootModel[Dict[str, Any]]):
48
+ 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,190 @@
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
+ CurrentTagDiscOutput,
14
+ CurrentTagStatusOutput,
15
+ DiscInput,
16
+ DiscOutput,
17
+ DiscPatchInput,
18
+ SettingsPatchInput,
19
+ SettingsResetInput,
20
+ )
21
+ from discstore.adapters.inbound.api.settings_router import build_settings_router
22
+ except ModuleNotFoundError as e:
23
+ if e.name != "fastapi":
24
+ raise
25
+ raise ModuleNotFoundError(
26
+ optional_extra_dependency_message("The `api_controller` module", "api", "discstore api")
27
+ ) from e
28
+ from discstore.domain.use_cases.add_disc import AddDisc
29
+ from discstore.domain.use_cases.edit_disc import EditDisc
30
+ from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
31
+ from discstore.domain.use_cases.get_disc import GetDisc
32
+ from discstore.domain.use_cases.list_discs import ListDiscs
33
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
34
+ from jukebox.settings.entities import SelectedSonosGroupSettings
35
+ from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
36
+ from jukebox.settings.service_protocols import SettingsService
37
+ from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError
38
+ from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection
39
+ from jukebox.sonos.service import SonosService
40
+
41
+ __all__ = [
42
+ "APIController",
43
+ "CurrentTagDiscOutput",
44
+ "CurrentTagStatusOutput",
45
+ "DiscInput",
46
+ "DiscOutput",
47
+ "DiscPatchInput",
48
+ "SettingsPatchInput",
49
+ "SettingsResetInput",
50
+ "SonosSelectionInput",
51
+ ]
52
+
53
+
54
+ class SonosSpeakerOutput(DiscoveredSonosSpeaker):
55
+ pass
56
+
57
+
58
+ class SelectedSonosGroupOutput(SelectedSonosGroupSettings):
59
+ pass
60
+
61
+
62
+ class SonosSelectionMemberAvailabilityOutput(BaseModel):
63
+ uid: str
64
+ status: str
65
+ speaker: Optional[SonosSpeakerOutput] = None
66
+
67
+
68
+ class SonosSelectionAvailabilityOutput(BaseModel):
69
+ status: str
70
+ members: list[SonosSelectionMemberAvailabilityOutput]
71
+
72
+
73
+ class SonosSelectionOutput(BaseModel):
74
+ selected_group: Optional[SelectedSonosGroupOutput] = None
75
+ availability: SonosSelectionAvailabilityOutput
76
+
77
+
78
+ class SonosSelectionInput(BaseModel):
79
+ uids: list[str]
80
+ coordinator_uid: Optional[str] = None
81
+
82
+
83
+ class SonosSelectionUpdateOutput(BaseModel):
84
+ selected_group: SelectedSonosGroupOutput
85
+ availability: SonosSelectionAvailabilityOutput
86
+ message: str
87
+ restart_required: bool
88
+
89
+
90
+ class APIController:
91
+ def __init__(
92
+ self,
93
+ add_disc: AddDisc,
94
+ list_discs: ListDiscs,
95
+ remove_disc: RemoveDisc,
96
+ edit_disc: EditDisc,
97
+ get_disc: GetDisc,
98
+ get_current_tag_status: GetCurrentTagStatus,
99
+ settings_service: SettingsService,
100
+ sonos_service: SonosService,
101
+ ):
102
+ self.add_disc = add_disc
103
+ self.list_discs = list_discs
104
+ self.remove_disc = remove_disc
105
+ self.edit_disc = edit_disc
106
+ self.get_disc = get_disc
107
+ self.get_current_tag_status = get_current_tag_status
108
+ self.settings_service = settings_service
109
+ self.sonos_service = sonos_service
110
+ self.app = FastAPI(
111
+ title="DiscStore API",
112
+ description="API for managing Jukebox disc library",
113
+ docs_url="/docs",
114
+ redoc_url="/redoc",
115
+ )
116
+ self.register_routes()
117
+
118
+ def register_routes(self):
119
+ self.app.include_router(
120
+ build_discs_router(
121
+ add_disc=self.add_disc,
122
+ list_discs=self.list_discs,
123
+ remove_disc=self.remove_disc,
124
+ edit_disc=self.edit_disc,
125
+ get_disc=self.get_disc,
126
+ )
127
+ )
128
+ self.app.include_router(
129
+ build_current_tag_router(
130
+ get_current_tag_status=self.get_current_tag_status,
131
+ add_disc=self.add_disc,
132
+ edit_disc=self.edit_disc,
133
+ get_disc=self.get_disc,
134
+ remove_disc=self.remove_disc,
135
+ )
136
+ )
137
+ self.app.include_router(build_settings_router(self.settings_service))
138
+
139
+ @self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput])
140
+ def get_sonos_speakers():
141
+ try:
142
+ return self.sonos_service.list_available_speakers()
143
+ except SonosDiscoveryError as err:
144
+ raise HTTPException(status_code=502, detail=str(err))
145
+ except Exception as err:
146
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
147
+
148
+ @self.app.get("/api/v1/sonos/selection", response_model=SonosSelectionOutput)
149
+ def get_sonos_selection():
150
+ try:
151
+ return GetSonosSelectionStatus(
152
+ SettingsSelectedSonosGroupRepository(self.settings_service),
153
+ self.sonos_service,
154
+ ).execute()
155
+ except SonosDiscoveryError as err:
156
+ raise HTTPException(status_code=502, detail=str(err))
157
+ except Exception as err:
158
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
159
+
160
+ @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput)
161
+ def put_sonos_selection(payload: SonosSelectionInput):
162
+ try:
163
+ result = SaveSonosSelection(
164
+ SettingsSelectedSonosGroupRepository(self.settings_service),
165
+ self.sonos_service,
166
+ ).execute(payload.uids, coordinator_uid=payload.coordinator_uid)
167
+ return SonosSelectionUpdateOutput(
168
+ selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()),
169
+ availability=SonosSelectionAvailabilityOutput(
170
+ status="available",
171
+ members=[
172
+ SonosSelectionMemberAvailabilityOutput(
173
+ uid=member.uid,
174
+ status="available",
175
+ speaker=SonosSpeakerOutput(**member.model_dump()),
176
+ )
177
+ for member in result.members
178
+ ],
179
+ ),
180
+ message=result.settings_message,
181
+ restart_required=result.restart_required,
182
+ )
183
+ except SonosDiscoveryError as err:
184
+ raise HTTPException(status_code=502, detail=str(err))
185
+ except ValueError as err:
186
+ raise HTTPException(status_code=400, detail=str(err))
187
+ except HTTPException:
188
+ raise
189
+ except Exception as err:
190
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
@@ -83,6 +83,7 @@ class UIController(APIController):
83
83
  list_discs,
84
84
  remove_disc,
85
85
  edit_disc,
86
+ get_disc,
86
87
  get_current_tag_status,
87
88
  settings_service,
88
89
  sonos_service,
@@ -6,5 +6,6 @@ class AddDisc:
6
6
  def __init__(self, repository: LibraryRepository):
7
7
  self.repository = repository
8
8
 
9
- def execute(self, tag_id: str, disc: Disc) -> None:
9
+ def execute(self, tag_id: str, disc: Disc) -> Disc:
10
10
  self.repository.add_disc(tag_id, disc)
11
+ return disc