gukebox 1.0.0.dev6__tar.gz → 1.0.0.dev8__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 (118) hide show
  1. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/PKG-INFO +25 -36
  2. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/README.md +24 -35
  3. gukebox-1.0.0.dev8/discstore/adapters/inbound/api/__init__.py +25 -0
  4. gukebox-1.0.0.dev8/discstore/adapters/inbound/api/current_tag_router.py +164 -0
  5. gukebox-1.0.0.dev8/discstore/adapters/inbound/api/discs_router.py +76 -0
  6. gukebox-1.0.0.dev8/discstore/adapters/inbound/api/models.py +48 -0
  7. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/api_controller.py +35 -11
  8. gukebox-1.0.0.dev8/discstore/adapters/inbound/ui_controller.py +367 -0
  9. gukebox-1.0.0.dev8/discstore/adapters/inbound/ui_pages/library.py +370 -0
  10. gukebox-1.0.0.dev8/discstore/adapters/inbound/ui_pages/settings.py +519 -0
  11. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/app.py +0 -2
  12. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/commands.py +1 -6
  13. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/add_disc.py +2 -1
  14. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/edit_disc.py +4 -3
  15. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/inbound/config.py +5 -20
  16. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/sonos_discovery_adapter.py +1 -4
  17. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/app.py +41 -9
  18. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/cli_presentation.py +83 -61
  19. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/command_handlers.py +26 -14
  20. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/commands.py +9 -7
  21. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/di_container.py +3 -5
  22. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/app.py +1 -1
  23. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/entities.py +5 -7
  24. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/resolve.py +5 -15
  25. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/shared/config_utils.py +0 -13
  26. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/sonos/__init__.py +0 -4
  27. gukebox-1.0.0.dev8/jukebox/sonos/selection.py +163 -0
  28. gukebox-1.0.0.dev8/jukebox/sonos/service.py +125 -0
  29. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/pyproject.toml +2 -1
  30. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/current_tag_router.py +0 -25
  31. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/discs_router.py +0 -48
  32. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/models.py +0 -25
  33. gukebox-1.0.0.dev6/discstore/adapters/inbound/ui_controller.py +0 -1083
  34. gukebox-1.0.0.dev6/jukebox/sonos/selection.py +0 -144
  35. gukebox-1.0.0.dev6/jukebox/sonos/service.py +0 -73
  36. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/LICENSE +0 -0
  37. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/__init__.py +0 -0
  38. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/__init__.py +0 -0
  39. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/__init__.py +0 -0
  40. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/api/settings_router.py +0 -0
  41. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/cli_controller.py +0 -0
  42. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/cli_display.py +0 -0
  43. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/config.py +0 -0
  44. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  45. {gukebox-1.0.0.dev6/discstore/adapters/inbound/api → gukebox-1.0.0.dev8/discstore/adapters/inbound/ui_pages}/__init__.py +0 -0
  46. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/outbound/__init__.py +0 -0
  47. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  48. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  49. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/command_handlers.py +0 -0
  50. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/di_container.py +0 -0
  51. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/__init__.py +0 -0
  52. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/entities/__init__.py +0 -0
  53. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/entities/current_tag_status.py +0 -0
  54. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/repositories/__init__.py +0 -0
  55. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/__init__.py +0 -0
  56. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  57. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/get_disc.py +0 -0
  58. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/list_discs.py +0 -0
  59. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/remove_disc.py +0 -0
  60. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  61. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/discstore/domain/use_cases/search_discs.py +0 -0
  62. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/__init__.py +0 -0
  63. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/__init__.py +0 -0
  64. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/inbound/__init__.py +0 -0
  65. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/inbound/cli_controller.py +0 -0
  66. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/__init__.py +0 -0
  67. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  68. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/players/__init__.py +0 -0
  69. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  70. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  71. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  72. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  73. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  74. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  75. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/__init__.py +0 -0
  76. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/admin/services.py +0 -0
  77. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/di_container.py +0 -0
  78. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/__init__.py +0 -0
  79. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/__init__.py +0 -0
  80. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/current_tag_action.py +0 -0
  81. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/disc.py +0 -0
  82. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/library.py +0 -0
  83. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/playback_action.py +0 -0
  84. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/playback_session.py +0 -0
  85. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/entities/tag_event.py +0 -0
  86. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/ports/__init__.py +0 -0
  87. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/ports/player_port.py +0 -0
  88. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/ports/reader_port.py +0 -0
  89. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/repositories/__init__.py +0 -0
  90. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  91. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/repositories/library_repository.py +0 -0
  92. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/use_cases/__init__.py +0 -0
  93. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/use_cases/determine_action.py +0 -0
  94. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  95. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  96. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/__init__.py +0 -0
  97. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/definitions.py +0 -0
  98. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/dict_utils.py +0 -0
  99. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/errors.py +0 -0
  100. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/file_settings_repository.py +0 -0
  101. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/migration.py +0 -0
  102. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/repositories.py +0 -0
  103. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/runtime_resolver.py +0 -0
  104. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/runtime_validation.py +0 -0
  105. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  106. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/service_protocols.py +0 -0
  107. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/timing_validation.py +0 -0
  108. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/types.py +0 -0
  109. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/validation_rules.py +0 -0
  110. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/settings/view_utils.py +0 -0
  111. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/shared/__init__.py +0 -0
  112. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/shared/dependency_messages.py +0 -0
  113. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/shared/logger.py +0 -0
  114. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/shared/timing.py +0 -0
  115. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/jukebox/sonos/discovery.py +0 -0
  116. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/pn532/__init__.py +0 -0
  117. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/pn532/pn532.py +0 -0
  118. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev8}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.0.0.dev6
3
+ Version: 1.0.0.dev8
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
@@ -58,8 +58,9 @@ Description-Content-Type: text/markdown
58
58
 
59
59
  # Jukebox \[gukebox\]
60
60
 
61
- [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
62
- [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
61
+ ![rpi](https://img.shields.io/badge/-Zero%202W%20%7C%203%20%7C%205-C51A4A?logo=raspberry-pi&label=RPi&logoColor=C51A4A&labcolor=C51A4A)
62
+ [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg?logo=python)](https://pypi.python.org/pypi/gukebox)
63
+ [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg?logo=pypi)](https://pypi.python.org/pypi/gukebox)
63
64
  [![license](https://img.shields.io/pypi/l/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
64
65
  [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/python.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
65
66
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
@@ -84,10 +85,9 @@ Description-Content-Type: text/markdown
84
85
 
85
86
  - [Install](#install)
86
87
  - [First steps](#first-steps)
87
- - [Discstore](#manage-the-library-with-the-discstore)
88
88
  - [Usage](#usage)
89
- - [Readers](#readers)
90
- - [Players](#players)
89
+ - [Readers](#readers)
90
+ - [Players](#players)
91
91
  - [The library file](#the-library-file)
92
92
  - [Developer setup](#developer-setup)
93
93
 
@@ -216,16 +216,18 @@ Take a look at `library.example.json` and the [The library file](#the-library-fi
216
216
  Start the jukebox with the `jukebox` command (show help message with `--help`)
217
217
 
218
218
  ```shell
219
- jukebox PLAYER_TO_USE READER_TO_USE
219
+ jukebox --player PLAYER --reader READER
220
220
  ```
221
221
 
222
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.
223
223
 
224
- Optional Parameters
224
+ **Optional Parameters**
225
225
 
226
226
  | Parameter | Description |
227
227
  | --- | --- |
228
228
  | `--help` | Show help message. |
229
+ | `--player PLAYER` | Player to use (`sonos`, `dryrun`). |
230
+ | `--reader READER` | Reader to use (`pn532`, `dryrun`). |
229
231
  | `--library` | Path to the library file, default: `~/.config/jukebox/library.json`. |
230
232
  | `--pause-delay SECONDS` | Grace period before pausing when the NFC tag is removed. Fractional values such as `0.5` or `0.2` are supported, with a minimum of `0.2` seconds to avoid pausing on brief missed reads. Default: 0.25 seconds. |
231
233
  | `--pause-duration SECONDS` | Maximum duration of a pause before resetting the queue. Default: 900 seconds (15 minutes). |
@@ -234,34 +236,23 @@ Optional Parameters
234
236
 
235
237
  ### Readers
236
238
 
237
- **Dry run** (`dryrun`)
238
- Read a text entry.
239
- Allows you to simulate reading an NFC tag by writting the tag id in the console.
240
- Expected syntax: `tag_id` or `tag_id duration_seconds`.
241
- - tag_id: the full identifier of the tag, in the format required by the system
242
- - duration_seconds: a non-negative number of seconds used to simulate how long the tag remains in place. Fractional values are allowed.
243
- Complete example: `your:tag:uid 2.5`
239
+ | Name | Description |
240
+ | --- | --- |
241
+ | Dry Run (`dryrun`) | Simulates NFC tag reading via stdin. Input format: `tag_id` or `tag_id duration_seconds`. |
242
+ | Pn532 NFC (`pn532`) | Reads physical NFC tags. Works with a **PN532** reader and **NTAG2xx** tags. Requires the `pn532` extra and SPI enabled on the Raspberry Pi. |
244
243
 
245
- **NFC Pn532** (`pn532`)
246
- Read an NFC tag and get its UID.
247
- This project works with an NFC reader like the **PN532** and NFC tags like the **NTAG2xx**.
248
- It is configured according to the [Waveshare PN532 wiki](https://www.waveshare.com/wiki/PN532_NFC_HAT).
249
- Don't forget to enable the SPI interface using the command `sudo raspi-config`, then go to: `Interface Options > SPI > Enable > Yes`.
244
+ > [!NOTE]
245
+ > See [docs/readers.md](docs/readers.md) for full setup, hardware requirements, and settings reference.
250
246
 
251
247
  ### Players
252
248
 
253
- **Dry run** (`dryrun`)
254
- Displays the events that a real speaker would have performed (`playing …`, `pause`, etc.).
255
-
256
- **Sonos** (`sonos`) [![SoCo](https://img.shields.io/badge/based%20on-SoCo-000)](https://github.com/SoCo/SoCo)
257
- Play music through a Sonos speaker.
258
- Three ways to select the speaker (mutually exclusive):
249
+ | Name | Description |
250
+ | --- | --- |
251
+ | Dry Run (`dryrun`) | Displays the events that a real speaker would have performed (`playing …`, `pause`, etc.). |
252
+ | Sonos (`sonos`) | [![SoCo](https://img.shields.io/badge/based%20on-SoCo-000)](https://github.com/SoCo/SoCo) Plays music through a Sonos speaker. Select by IP (`--sonos-host`), by name (`--sonos-name`), or let it auto-discover. |
259
253
 
260
- | Option | CLI flag | Environment variable | Behaviour |
261
- | --- | --- | --- | --- |
262
- | By IP | `--sonos-host 192.168.0.x` | `JUKEBOX_SONOS_HOST` | Connect directly, no discovery |
263
- | By name | `--sonos-name "Living Room"` | `JUKEBOX_SONOS_NAME` | Discover, then filter by name (case-sensitive) |
264
- | Auto | *(omit both)* | *(omit both)* | Discover, pick the first speaker alphabetically |
254
+ > [!NOTE]
255
+ > See [docs/players.md](docs/players.md) for the full configuration reference.
265
256
 
266
257
  ## The library file
267
258
 
@@ -341,9 +332,7 @@ uv sync
341
332
 
342
333
  Add `--all-extras` to install dependencies for all extras (`api` and `ui`).
343
334
 
344
- If needed, set `JUKEBOX_SONOS_HOST` (IP) or `JUKEBOX_SONOS_NAME` (speaker name) to select your Sonos speaker (see [Players](#players)).
345
- If neither is set, the jukebox will auto-discover a speaker on the network.
346
- To do this you can use a `.env` file and `uv run --env-file .env <command to run>`.
335
+ If needed, you can use a `.env` file and `uv run --env-file .env <command to run>`.
347
336
  A `.env.example` file is available, you can copy it and modify it to use it.
348
337
 
349
338
  Create a `library.json` file and complete it with the desired NFC tags and CDs.
@@ -354,7 +343,7 @@ Take a look at `library.example.json` and the [The library file](#the-library-fi
354
343
  Start the jukebox with `uv` and use `--help` to show help message
355
344
 
356
345
  ```shell
357
- uv run jukebox PLAYER_TO_USE READER_TO_USE
346
+ uv run jukebox --player PLAYER_TO_USE --reader READER_TO_USE
358
347
  ```
359
348
 
360
349
  Start the discstore `uv` and use `--help` to show help message
@@ -383,7 +372,7 @@ uv run --extra api discstore api
383
372
  uv run --extra ui discstore ui
384
373
  ```
385
374
 
386
- Other commands are available:
375
+ ### Development commands
387
376
 
388
377
  | Command | Description |
389
378
  | --- | --- |
@@ -1,7 +1,8 @@
1
1
  # Jukebox \[gukebox\]
2
2
 
3
- [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
4
- [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
3
+ ![rpi](https://img.shields.io/badge/-Zero%202W%20%7C%203%20%7C%205-C51A4A?logo=raspberry-pi&label=RPi&logoColor=C51A4A&labcolor=C51A4A)
4
+ [![python versions](https://img.shields.io/pypi/pyversions/gukebox.svg?logo=python)](https://pypi.python.org/pypi/gukebox)
5
+ [![gukebox last version](https://img.shields.io/pypi/v/gukebox.svg?logo=pypi)](https://pypi.python.org/pypi/gukebox)
5
6
  [![license](https://img.shields.io/pypi/l/gukebox.svg)](https://pypi.python.org/pypi/gukebox)
6
7
  [![actions status](https://github.com/gudsfile/jukebox/actions/workflows/python.yml/badge.svg)](https://github.com/gudsfile/jukebox/actions)
7
8
  [![uv](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/uv/main/assets/badge/v0.json)](https://github.com/astral-sh/uv)
@@ -26,10 +27,9 @@
26
27
 
27
28
  - [Install](#install)
28
29
  - [First steps](#first-steps)
29
- - [Discstore](#manage-the-library-with-the-discstore)
30
30
  - [Usage](#usage)
31
- - [Readers](#readers)
32
- - [Players](#players)
31
+ - [Readers](#readers)
32
+ - [Players](#players)
33
33
  - [The library file](#the-library-file)
34
34
  - [Developer setup](#developer-setup)
35
35
 
@@ -158,16 +158,18 @@ Take a look at `library.example.json` and the [The library file](#the-library-fi
158
158
  Start the jukebox with the `jukebox` command (show help message with `--help`)
159
159
 
160
160
  ```shell
161
- jukebox PLAYER_TO_USE READER_TO_USE
161
+ jukebox --player PLAYER --reader READER
162
162
  ```
163
163
 
164
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
- Optional Parameters
166
+ **Optional Parameters**
167
167
 
168
168
  | Parameter | Description |
169
169
  | --- | --- |
170
170
  | `--help` | Show help message. |
171
+ | `--player PLAYER` | Player to use (`sonos`, `dryrun`). |
172
+ | `--reader READER` | Reader to use (`pn532`, `dryrun`). |
171
173
  | `--library` | Path to the library file, default: `~/.config/jukebox/library.json`. |
172
174
  | `--pause-delay SECONDS` | Grace period before pausing when the NFC tag is removed. Fractional values such as `0.5` or `0.2` are supported, with a minimum of `0.2` seconds to avoid pausing on brief missed reads. Default: 0.25 seconds. |
173
175
  | `--pause-duration SECONDS` | Maximum duration of a pause before resetting the queue. Default: 900 seconds (15 minutes). |
@@ -176,34 +178,23 @@ Optional Parameters
176
178
 
177
179
  ### Readers
178
180
 
179
- **Dry run** (`dryrun`)
180
- Read a text entry.
181
- Allows you to simulate reading an NFC tag by writting the tag id in the console.
182
- Expected syntax: `tag_id` or `tag_id duration_seconds`.
183
- - tag_id: the full identifier of the tag, in the format required by the system
184
- - duration_seconds: a non-negative number of seconds used to simulate how long the tag remains in place. Fractional values are allowed.
185
- Complete example: `your:tag:uid 2.5`
181
+ | Name | Description |
182
+ | --- | --- |
183
+ | Dry Run (`dryrun`) | Simulates NFC tag reading via stdin. Input format: `tag_id` or `tag_id duration_seconds`. |
184
+ | Pn532 NFC (`pn532`) | Reads physical NFC tags. Works with a **PN532** reader and **NTAG2xx** tags. Requires the `pn532` extra and SPI enabled on the Raspberry Pi. |
186
185
 
187
- **NFC Pn532** (`pn532`)
188
- Read an NFC tag and get its UID.
189
- This project works with an NFC reader like the **PN532** and NFC tags like the **NTAG2xx**.
190
- It is configured according to the [Waveshare PN532 wiki](https://www.waveshare.com/wiki/PN532_NFC_HAT).
191
- Don't forget to enable the SPI interface using the command `sudo raspi-config`, then go to: `Interface Options > SPI > Enable > Yes`.
186
+ > [!NOTE]
187
+ > See [docs/readers.md](docs/readers.md) for full setup, hardware requirements, and settings reference.
192
188
 
193
189
  ### Players
194
190
 
195
- **Dry run** (`dryrun`)
196
- Displays the events that a real speaker would have performed (`playing …`, `pause`, etc.).
197
-
198
- **Sonos** (`sonos`) [![SoCo](https://img.shields.io/badge/based%20on-SoCo-000)](https://github.com/SoCo/SoCo)
199
- Play music through a Sonos speaker.
200
- Three ways to select the speaker (mutually exclusive):
191
+ | Name | Description |
192
+ | --- | --- |
193
+ | Dry Run (`dryrun`) | Displays the events that a real speaker would have performed (`playing …`, `pause`, etc.). |
194
+ | Sonos (`sonos`) | [![SoCo](https://img.shields.io/badge/based%20on-SoCo-000)](https://github.com/SoCo/SoCo) Plays music through a Sonos speaker. Select by IP (`--sonos-host`), by name (`--sonos-name`), or let it auto-discover. |
201
195
 
202
- | Option | CLI flag | Environment variable | Behaviour |
203
- | --- | --- | --- | --- |
204
- | By IP | `--sonos-host 192.168.0.x` | `JUKEBOX_SONOS_HOST` | Connect directly, no discovery |
205
- | By name | `--sonos-name "Living Room"` | `JUKEBOX_SONOS_NAME` | Discover, then filter by name (case-sensitive) |
206
- | Auto | *(omit both)* | *(omit both)* | Discover, pick the first speaker alphabetically |
196
+ > [!NOTE]
197
+ > See [docs/players.md](docs/players.md) for the full configuration reference.
207
198
 
208
199
  ## The library file
209
200
 
@@ -283,9 +274,7 @@ uv sync
283
274
 
284
275
  Add `--all-extras` to install dependencies for all extras (`api` and `ui`).
285
276
 
286
- If needed, set `JUKEBOX_SONOS_HOST` (IP) or `JUKEBOX_SONOS_NAME` (speaker name) to select your Sonos speaker (see [Players](#players)).
287
- If neither is set, the jukebox will auto-discover a speaker on the network.
288
- To do this you can use a `.env` file and `uv run --env-file .env <command to run>`.
277
+ If needed, you can use a `.env` file and `uv run --env-file .env <command to run>`.
289
278
  A `.env.example` file is available, you can copy it and modify it to use it.
290
279
 
291
280
  Create a `library.json` file and complete it with the desired NFC tags and CDs.
@@ -296,7 +285,7 @@ Take a look at `library.example.json` and the [The library file](#the-library-fi
296
285
  Start the jukebox with `uv` and use `--help` to show help message
297
286
 
298
287
  ```shell
299
- uv run jukebox PLAYER_TO_USE READER_TO_USE
288
+ uv run jukebox --player PLAYER_TO_USE --reader READER_TO_USE
300
289
  ```
301
290
 
302
291
  Start the discstore `uv` and use `--help` to show help message
@@ -325,7 +314,7 @@ uv run --extra api discstore api
325
314
  uv run --extra ui discstore ui
326
315
  ```
327
316
 
328
- Other commands are available:
317
+ ### Development commands
329
318
 
330
319
  | Command | Description |
331
320
  | --- | --- |
@@ -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,164 @@
1
+ from typing import Any, Optional
2
+
3
+ from fastapi import APIRouter, HTTPException, Response, status
4
+ from pydantic import ValidationError
5
+
6
+ from discstore.adapters.inbound.api.models import (
7
+ CurrentTagDiscOutput,
8
+ CurrentTagStatusOutput,
9
+ DiscInput,
10
+ DiscOutput,
11
+ DiscPatchInput,
12
+ )
13
+ from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption
14
+ from discstore.domain.use_cases.add_disc import AddDisc
15
+ from discstore.domain.use_cases.edit_disc import EditDisc
16
+ from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
17
+ from discstore.domain.use_cases.get_disc import GetDisc
18
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
19
+
20
+
21
+ def build_current_tag_router(
22
+ get_current_tag_status: GetCurrentTagStatus,
23
+ add_disc: AddDisc,
24
+ edit_disc: EditDisc,
25
+ get_disc: GetDisc,
26
+ remove_disc: RemoveDisc,
27
+ ) -> APIRouter:
28
+ router = APIRouter(prefix="/api/v1", tags=["current-tag"])
29
+
30
+ def read_current_tag_status() -> Optional[CurrentTagStatus]:
31
+ return get_current_tag_status.execute()
32
+
33
+ def ensure_expected_tag_id_matches(
34
+ expected_tag_id: Optional[str], current_tag_status: Optional[CurrentTagStatus]
35
+ ) -> None:
36
+ if expected_tag_id is None:
37
+ return
38
+
39
+ actual_tag_id = None if current_tag_status is None else current_tag_status.tag_id
40
+ if actual_tag_id != expected_tag_id:
41
+ raise HTTPException(
42
+ status_code=409,
43
+ detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={repr(actual_tag_id)}",
44
+ )
45
+
46
+ def build_current_tag_disc_output(tag_id: str, disc: Disc) -> CurrentTagDiscOutput:
47
+ return CurrentTagDiscOutput(tag_id=tag_id, disc=DiscOutput(**disc.model_dump()))
48
+
49
+ @router.get(
50
+ "/current-tag",
51
+ response_model=CurrentTagStatusOutput,
52
+ responses={204: {"description": "No current tag"}},
53
+ summary="Get the current NFC tag status",
54
+ )
55
+ def get_current_tag() -> Any:
56
+ current_tag_status = read_current_tag_status()
57
+ if current_tag_status is None:
58
+ return Response(status_code=204)
59
+
60
+ return CurrentTagStatusOutput(**current_tag_status.model_dump())
61
+
62
+ @router.get(
63
+ "/current-tag/disc",
64
+ response_model=CurrentTagDiscOutput,
65
+ responses={204: {"description": "No current tag"}, 404: {"description": "Current tag disc not found"}},
66
+ summary="Get the current tag disc",
67
+ )
68
+ def get_current_tag_disc() -> Any:
69
+ current_tag_status = read_current_tag_status()
70
+ if current_tag_status is None:
71
+ return Response(status_code=204)
72
+
73
+ if not current_tag_status.known_in_library:
74
+ raise HTTPException(status_code=404, detail=f"Tag does not exist: tag_id='{current_tag_status.tag_id}'")
75
+
76
+ return build_current_tag_disc_output(current_tag_status.tag_id, get_disc.execute(current_tag_status.tag_id))
77
+
78
+ @router.post(
79
+ "/current-tag/disc",
80
+ response_model=CurrentTagDiscOutput,
81
+ status_code=201,
82
+ responses={204: {"description": "No current tag"}, 409: {"description": "Current tag changed or disc exists"}},
83
+ summary="Create a disc for the current tag",
84
+ )
85
+ def create_current_tag_disc(
86
+ disc: DiscInput,
87
+ expected_tag_id: Optional[str] = None,
88
+ ) -> Any:
89
+ current_tag_status = read_current_tag_status()
90
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
91
+ if current_tag_status is None:
92
+ return Response(status_code=204)
93
+
94
+ try:
95
+ new_disc = Disc(**disc.model_dump())
96
+ created_disc = add_disc.execute(current_tag_status.tag_id, new_disc)
97
+ return build_current_tag_disc_output(current_tag_status.tag_id, created_disc)
98
+ except ValueError as value_err:
99
+ raise HTTPException(status_code=409, detail=str(value_err))
100
+ except Exception as err:
101
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
102
+
103
+ @router.patch(
104
+ "/current-tag/disc",
105
+ response_model=CurrentTagDiscOutput,
106
+ responses={
107
+ 204: {"description": "No current tag"},
108
+ 404: {"description": "Current tag disc not found"},
109
+ 409: {"description": "Current tag changed"},
110
+ },
111
+ summary="Update the current tag disc",
112
+ )
113
+ def update_current_tag_disc(
114
+ disc_patch: DiscPatchInput,
115
+ expected_tag_id: Optional[str] = None,
116
+ ) -> Any:
117
+ current_tag_status = read_current_tag_status()
118
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
119
+ if current_tag_status is None:
120
+ return Response(status_code=204)
121
+
122
+ try:
123
+ metadata = None
124
+ if disc_patch.metadata is not None:
125
+ metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True))
126
+
127
+ option = None
128
+ if disc_patch.option is not None:
129
+ option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True))
130
+
131
+ updated_disc = edit_disc.execute(current_tag_status.tag_id, disc_patch.uri, metadata, option)
132
+ return build_current_tag_disc_output(current_tag_status.tag_id, updated_disc)
133
+ except ValidationError as err:
134
+ raise HTTPException(status_code=422, detail=err.errors())
135
+ except ValueError as value_err:
136
+ raise HTTPException(status_code=404, detail=str(value_err))
137
+ except Exception as err:
138
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
139
+
140
+ @router.delete(
141
+ "/current-tag/disc",
142
+ status_code=204,
143
+ responses={
144
+ 204: {"description": "No current tag or disc deleted"},
145
+ 404: {"description": "Current tag disc not found"},
146
+ 409: {"description": "Current tag changed"},
147
+ },
148
+ summary="Delete the current tag disc",
149
+ )
150
+ def delete_current_tag_disc(expected_tag_id: Optional[str] = None) -> Response:
151
+ current_tag_status = read_current_tag_status()
152
+ ensure_expected_tag_id_matches(expected_tag_id, current_tag_status)
153
+ if current_tag_status is None:
154
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
155
+
156
+ try:
157
+ remove_disc.execute(current_tag_status.tag_id)
158
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
159
+ except ValueError as value_err:
160
+ raise HTTPException(status_code=404, detail=str(value_err))
161
+ except Exception as err:
162
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
163
+
164
+ return router
@@ -0,0 +1,76 @@
1
+ from typing import Dict
2
+
3
+ from fastapi import APIRouter, HTTPException, Response, status
4
+ from pydantic import ValidationError
5
+
6
+ from discstore.adapters.inbound.api.models import DiscInput, DiscOutput, DiscPatchInput
7
+ from discstore.domain.entities import Disc, DiscMetadata, DiscOption
8
+ from discstore.domain.use_cases.add_disc import AddDisc
9
+ from discstore.domain.use_cases.edit_disc import EditDisc
10
+ from discstore.domain.use_cases.get_disc import GetDisc
11
+ from discstore.domain.use_cases.list_discs import ListDiscs
12
+ from discstore.domain.use_cases.remove_disc import RemoveDisc
13
+
14
+
15
+ def build_discs_router(
16
+ add_disc: AddDisc,
17
+ list_discs: ListDiscs,
18
+ remove_disc: RemoveDisc,
19
+ edit_disc: EditDisc,
20
+ get_disc: GetDisc,
21
+ ) -> APIRouter:
22
+ router = APIRouter(prefix="/api/v1", tags=["discs"])
23
+
24
+ @router.get("/discs", response_model=Dict[str, DiscOutput], summary="List discs")
25
+ def list_discs_route() -> Dict[str, Disc]:
26
+ return list_discs.execute()
27
+
28
+ @router.get("/discs/{tag_id}", response_model=DiscOutput, summary="Get a disc")
29
+ def get_disc_route(tag_id: str) -> Disc:
30
+ try:
31
+ return get_disc.execute(tag_id)
32
+ except ValueError as value_err:
33
+ raise HTTPException(status_code=404, detail=str(value_err))
34
+ except Exception as err:
35
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
36
+
37
+ @router.post("/discs/{tag_id}", response_model=DiscOutput, status_code=201, summary="Create a disc")
38
+ def create_disc_route(tag_id: str, disc: DiscInput) -> Disc:
39
+ try:
40
+ new_disc = Disc(**disc.model_dump())
41
+ return add_disc.execute(tag_id, new_disc)
42
+ except ValueError as value_err:
43
+ raise HTTPException(status_code=409, detail=str(value_err))
44
+ except Exception as err:
45
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
46
+
47
+ @router.patch("/discs/{tag_id}", response_model=DiscOutput, summary="Update a disc")
48
+ def update_disc_route(tag_id: str, disc_patch: DiscPatchInput) -> Disc:
49
+ try:
50
+ metadata = None
51
+ if disc_patch.metadata is not None:
52
+ metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True))
53
+
54
+ option = None
55
+ if disc_patch.option is not None:
56
+ option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True))
57
+
58
+ return edit_disc.execute(tag_id, disc_patch.uri, metadata, option)
59
+ except ValidationError as err:
60
+ raise HTTPException(status_code=422, detail=err.errors())
61
+ except ValueError as value_err:
62
+ raise HTTPException(status_code=404, detail=str(value_err))
63
+ except Exception as err:
64
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
65
+
66
+ @router.delete("/discs/{tag_id}", status_code=204, summary="Delete a disc")
67
+ def remove_disc_route(tag_id: str) -> Response:
68
+ try:
69
+ remove_disc.execute(tag_id)
70
+ return Response(status_code=status.HTTP_204_NO_CONTENT)
71
+ except ValueError as value_err:
72
+ raise HTTPException(status_code=404, detail=str(value_err))
73
+ except Exception as err:
74
+ raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
75
+
76
+ 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