gukebox 1.4.0.dev1__tar.gz → 1.4.2__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 (109) hide show
  1. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/PKG-INFO +4 -4
  2. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api/current_tag_router.py +4 -4
  3. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api/discs_router.py +4 -4
  4. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api/settings_router.py +4 -4
  5. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api_controller.py +3 -3
  6. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/ui_controller.py +14 -9
  7. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/ui_pages/settings.py +1 -1
  8. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/players/sonos_player_adapter.py +1 -1
  9. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +1 -1
  10. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/sonos_discovery_adapter.py +17 -9
  11. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/app.py +1 -1
  12. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/cli_presentation.py +2 -2
  13. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/ports/player_port.py +0 -4
  14. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/ports/reader_port.py +0 -1
  15. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/repositories/__init__.py +1 -1
  16. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/sync_current_tag.py +1 -1
  17. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/pn532/profiles.py +1 -1
  18. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/terminal_ui.py +1 -1
  19. gukebox-1.4.2/pyproject.toml +102 -0
  20. gukebox-1.4.0.dev1/pyproject.toml → gukebox-1.4.2/pyproject.toml.orig +22 -8
  21. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/LICENSE +0 -0
  22. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/README.md +0 -0
  23. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/__init__.py +0 -0
  24. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/__init__.py +0 -0
  25. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/__init__.py +0 -0
  26. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/__init__.py +0 -0
  27. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
  28. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/api/models.py +0 -0
  29. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/cli_controller.py +0 -0
  30. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/cli_display.py +0 -0
  31. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +0 -0
  32. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/ui_pages/__init__.py +0 -0
  33. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/ui_pages/library.py +0 -0
  34. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -0
  35. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/inbound/cli_controller.py +0 -0
  36. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/__init__.py +0 -0
  37. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  38. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/players/__init__.py +0 -0
  39. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  40. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  41. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  42. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  43. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/__init__.py +0 -0
  44. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/command_handlers.py +0 -0
  45. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/commands.py +0 -0
  46. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/di_container.py +0 -0
  47. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/library_command_handlers.py +0 -0
  48. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/library_commands.py +0 -0
  49. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/pn532_command_handlers.py +0 -0
  50. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/pn532_commands.py +0 -0
  51. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/services.py +0 -0
  52. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/admin/sonos_households.py +0 -0
  53. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/app.py +0 -0
  54. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/di_container.py +0 -0
  55. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/__init__.py +0 -0
  56. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/__init__.py +10 -10
  57. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/current_tag_state.py +0 -0
  58. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/current_tag_status.py +0 -0
  59. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/disc.py +0 -0
  60. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/library.py +0 -0
  61. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/playback_state.py +0 -0
  62. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/entities/tag_event.py +0 -0
  63. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/errors.py +0 -0
  64. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/ports/__init__.py +0 -0
  65. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  66. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/repositories/library_repository.py +0 -0
  67. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/__init__.py +1 -1
  68. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  69. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/__init__.py +0 -0
  70. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/add_disc.py +0 -0
  71. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
  72. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
  73. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/get_disc.py +0 -0
  74. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/list_discs.py +0 -0
  75. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
  76. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/resolve_tag_id.py +0 -0
  77. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/library/search_discs.py +0 -0
  78. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
  79. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/domain/use_cases/transition_playback.py +0 -0
  80. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/pn532/__init__.py +0 -0
  81. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/__init__.py +0 -0
  82. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/definitions.py +0 -0
  83. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/dict_utils.py +0 -0
  84. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/entities.py +0 -0
  85. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/errors.py +0 -0
  86. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/file_settings_repository.py +0 -0
  87. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/migration.py +0 -0
  88. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/repositories.py +0 -0
  89. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/resolve.py +0 -0
  90. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/runtime_resolver.py +0 -0
  91. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/runtime_validation.py +0 -0
  92. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  93. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/service_protocols.py +0 -0
  94. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/timing_validation.py +0 -0
  95. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/types.py +0 -0
  96. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/validation_rules.py +0 -0
  97. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/settings/view_utils.py +0 -0
  98. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/__init__.py +0 -0
  99. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/config_utils.py +0 -0
  100. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/errors.py +0 -0
  101. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/logger.py +0 -0
  102. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/shared/timing.py +0 -0
  103. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/sonos/__init__.py +0 -0
  104. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/sonos/discovery.py +0 -0
  105. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/sonos/selection.py +0 -0
  106. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/jukebox/sonos/service.py +0 -0
  107. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/pn532/__init__.py +0 -0
  108. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/pn532/pn532.py +0 -0
  109. {gukebox-1.4.0.dev1 → gukebox-1.4.2}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.4.0.dev1
3
+ Version: 1.4.2
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,music,nfc
6
6
  Author: Gudsfile
@@ -35,9 +35,9 @@ Classifier: Programming Language :: Python :: 3.13
35
35
  Requires-Dist: pydantic==2.13.4
36
36
  Requires-Dist: questionary==2.1.1
37
37
  Requires-Dist: soco==0.31.1
38
- Requires-Dist: typer==0.26.7
39
- Requires-Dist: fastapi==0.136.3 ; extra == 'api'
40
- Requires-Dist: uvicorn==0.49.0 ; extra == 'api'
38
+ Requires-Dist: typer==0.27.0
39
+ Requires-Dist: fastapi==0.140.13 ; extra == 'api'
40
+ Requires-Dist: uvicorn==0.51.0 ; extra == 'api'
41
41
  Requires-Dist: pyserial==3.5 ; extra == 'pn532'
42
42
  Requires-Dist: spidev==3.8 ; extra == 'pn532'
43
43
  Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'pn532'
@@ -36,7 +36,7 @@ def build_current_tag_router(
36
36
  if actual_tag_id != expected_tag_id:
37
37
  raise HTTPException(
38
38
  status_code=409,
39
- detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={repr(actual_tag_id)}",
39
+ detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={actual_tag_id!r}",
40
40
  )
41
41
 
42
42
  def build_current_tag_disc_output(tag_id: str, disc: Disc) -> CurrentTagDiscOutput:
@@ -94,7 +94,7 @@ def build_current_tag_router(
94
94
  except ValueError as value_err:
95
95
  raise HTTPException(status_code=409, detail=str(value_err))
96
96
  except Exception as err:
97
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
97
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
98
98
 
99
99
  @router.patch(
100
100
  "/current-tag/disc",
@@ -131,7 +131,7 @@ def build_current_tag_router(
131
131
  except ValueError as value_err:
132
132
  raise HTTPException(status_code=404, detail=str(value_err))
133
133
  except Exception as err:
134
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
134
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
135
135
 
136
136
  @router.delete(
137
137
  "/current-tag/disc",
@@ -155,6 +155,6 @@ def build_current_tag_router(
155
155
  except ValueError as value_err:
156
156
  raise HTTPException(status_code=404, detail=str(value_err))
157
157
  except Exception as err:
158
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
158
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
159
159
 
160
160
  return router
@@ -26,7 +26,7 @@ def build_discs_router(
26
26
  except ValueError as value_err:
27
27
  raise HTTPException(status_code=404, detail=str(value_err))
28
28
  except Exception as err:
29
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
29
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
30
30
 
31
31
  @router.post("/discs/{tag_id}", response_model=DiscOutput, status_code=201, summary="Create a disc")
32
32
  def create_disc_route(tag_id: str, disc: DiscInput) -> Disc:
@@ -36,7 +36,7 @@ def build_discs_router(
36
36
  except ValueError as value_err:
37
37
  raise HTTPException(status_code=409, detail=str(value_err))
38
38
  except Exception as err:
39
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
39
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
40
40
 
41
41
  @router.patch("/discs/{tag_id}", response_model=DiscOutput, summary="Update a disc")
42
42
  def update_disc_route(tag_id: str, disc_patch: DiscPatchInput) -> Disc:
@@ -55,7 +55,7 @@ def build_discs_router(
55
55
  except ValueError as value_err:
56
56
  raise HTTPException(status_code=404, detail=str(value_err))
57
57
  except Exception as err:
58
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
58
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
59
59
 
60
60
  @router.delete("/discs/{tag_id}", status_code=204, summary="Delete a disc")
61
61
  def remove_disc_route(tag_id: str) -> Response:
@@ -65,6 +65,6 @@ def build_discs_router(
65
65
  except ValueError as value_err:
66
66
  raise HTTPException(status_code=404, detail=str(value_err))
67
67
  except Exception as err:
68
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
68
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
69
69
 
70
70
  return router
@@ -16,14 +16,14 @@ def build_settings_router(settings_service: SettingsService) -> APIRouter:
16
16
  try:
17
17
  return settings_service.get_persisted_settings_view()
18
18
  except Exception as err:
19
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
19
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
20
20
 
21
21
  @router.get("/settings/effective", response_model=dict[str, Any], summary="Get effective settings")
22
22
  def get_effective_settings() -> JsonObject:
23
23
  try:
24
24
  return settings_service.get_effective_settings_view()
25
25
  except Exception as err:
26
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
26
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
27
27
 
28
28
  @router.patch("/settings", response_model=dict[str, Any], summary="Patch persisted settings")
29
29
  def patch_settings(patch: SettingsPatchInput) -> JsonObject:
@@ -32,7 +32,7 @@ def build_settings_router(settings_service: SettingsService) -> APIRouter:
32
32
  except SettingsError as err:
33
33
  raise HTTPException(status_code=400, detail=str(err))
34
34
  except Exception as err:
35
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
35
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
36
36
 
37
37
  @router.post("/settings/reset", response_model=dict[str, Any], summary="Reset a persisted setting")
38
38
  def reset_settings(payload: SettingsResetInput) -> JsonObject:
@@ -41,6 +41,6 @@ def build_settings_router(settings_service: SettingsService) -> APIRouter:
41
41
  except SettingsError as err:
42
42
  raise HTTPException(status_code=400, detail=str(err))
43
43
  except Exception as err:
44
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
44
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
45
45
 
46
46
  return router
@@ -134,7 +134,7 @@ class APIController:
134
134
  except SonosDiscoveryError as err:
135
135
  raise HTTPException(status_code=502, detail=str(err))
136
136
  except Exception as err:
137
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
137
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
138
138
 
139
139
  @self.app.get("/api/v1/sonos/selection", response_model=SonosSelectionOutput)
140
140
  def get_sonos_selection():
@@ -146,7 +146,7 @@ class APIController:
146
146
  except SonosDiscoveryError as err:
147
147
  raise HTTPException(status_code=502, detail=str(err))
148
148
  except Exception as err:
149
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
149
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
150
150
 
151
151
  @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput)
152
152
  def put_sonos_selection(payload: SonosSelectionInput):
@@ -178,4 +178,4 @@ class APIController:
178
178
  except HTTPException:
179
179
  raise
180
180
  except Exception as err:
181
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
181
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
@@ -1,3 +1,4 @@
1
+ import logging
1
2
  from typing import Annotated
2
3
 
3
4
  from jukebox.shared.errors import MissingOptionalDependencyError
@@ -12,7 +13,7 @@ try:
12
13
  except ModuleNotFoundError as e:
13
14
  raise MissingOptionalDependencyError("The `ui_controller` module", "ui", "jukebox-admin ui") from e
14
15
 
15
- from pydantic import BaseModel, Field
16
+ from pydantic import BaseModel, Field, ValidationError
16
17
 
17
18
  from jukebox.adapters.inbound.admin.api_controller import APIController
18
19
  from jukebox.adapters.inbound.admin.ui_pages.library import DiscForm, LibraryUIPageBuilder
@@ -28,6 +29,8 @@ from jukebox.sonos.discovery import SonosDiscoveryError
28
29
  from jukebox.sonos.selection import SaveSonosSelection
29
30
  from jukebox.sonos.service import SonosService
30
31
 
32
+ LOGGER = logging.getLogger("jukebox")
33
+
31
34
 
32
35
  class SettingValueForm(BaseModel):
33
36
  value: str = Field(title="Value")
@@ -109,7 +112,7 @@ class UIController(APIController):
109
112
  except HTTPException:
110
113
  raise
111
114
  except Exception as err:
112
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
115
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
113
116
 
114
117
  return self._build_success_response("toast-add-disc-success")
115
118
 
@@ -152,7 +155,7 @@ class UIController(APIController):
152
155
  except HTTPException:
153
156
  raise
154
157
  except Exception as err:
155
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
158
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
156
159
 
157
160
  return self._build_success_response("toast-edit-disc-success")
158
161
 
@@ -172,7 +175,7 @@ class UIController(APIController):
172
175
  except ValueError as err:
173
176
  raise HTTPException(status_code=404, detail=str(err))
174
177
  except Exception as err:
175
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
178
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
176
179
 
177
180
  return self._build_success_response("toast-remove-disc-success")
178
181
 
@@ -209,7 +212,7 @@ class UIController(APIController):
209
212
  except HTTPException:
210
213
  raise
211
214
  except Exception as err:
212
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
215
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
213
216
 
214
217
  return self.settings_pages.build_settings_success_response(str(result["message"]))
215
218
 
@@ -268,7 +271,7 @@ class UIController(APIController):
268
271
  except HTTPException:
269
272
  raise
270
273
  except Exception as err:
271
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
274
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
272
275
 
273
276
  return self.sonos_pages.build_sonos_success_response(str(result.settings_message))
274
277
 
@@ -299,14 +302,15 @@ class UIController(APIController):
299
302
  except HTTPException:
300
303
  raise
301
304
  except Exception as err:
302
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
305
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
303
306
 
304
307
  return self.sonos_pages.build_sonos_success_response(str(result.get("message", "Settings saved.")))
305
308
 
306
309
  def _persisted_sonos_selection_matches(self, uids: list[str], coordinator_uid: str | None) -> bool:
307
310
  try:
308
311
  selected_group = SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group()
309
- except Exception:
312
+ except (OSError, ValueError, ValidationError) as err:
313
+ LOGGER.warning("Failed to read persisted Sonos selection: %s", err)
310
314
  return False
311
315
 
312
316
  if selected_group is None:
@@ -325,7 +329,8 @@ class UIController(APIController):
325
329
 
326
330
  try:
327
331
  speakers = self.sonos_service.list_network_speakers()
328
- except Exception:
332
+ except SonosDiscoveryError as err:
333
+ LOGGER.warning("Failed to list network speakers while building Sonos error message: %s", err)
329
334
  return message
330
335
 
331
336
  speaker = next((speaker for speaker in speakers if speaker.uid == coordinator_uid), None)
@@ -53,7 +53,7 @@ class SettingsUIPageBuilder:
53
53
  except HTTPException:
54
54
  raise
55
55
  except Exception as err:
56
- raise HTTPException(status_code=500, detail=f"Server error: {str(err)}")
56
+ raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
57
57
 
58
58
  return self.build_settings_success_response(str(result["message"]))
59
59
 
@@ -159,7 +159,7 @@ class SonosPlayerAdapter(PlayerPort):
159
159
  speaker.player_name,
160
160
  )
161
161
  speaker.join(coordinator)
162
- except Exception as err:
162
+ except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
163
163
  LOGGER.warning(
164
164
  "Failed to roll back Sonos group change `%s` for `%s`: %s",
165
165
  operation,
@@ -42,7 +42,7 @@ class Pn532ReaderAdapter(ReaderPort):
42
42
 
43
43
  self.pn532 = PN532_SPI(debug=False, reset=spi_reset, cs=spi_cs, irq=spi_irq)
44
44
  self.read_timeout_seconds = read_timeout_seconds
45
- ic, ver, rev, support = self.pn532.get_firmware_version()
45
+ _ic, ver, rev, _support = self.pn532.get_firmware_version()
46
46
  LOGGER.info("Found PN532 with firmware version: %s.%s", ver, rev)
47
47
  self._firmware_version: tuple[int, int] = (ver, rev)
48
48
  self.pn532.SAM_configuration()
@@ -1,4 +1,5 @@
1
1
  import ipaddress
2
+ import logging
2
3
  import re
3
4
  import select
4
5
  import socket
@@ -7,6 +8,10 @@ import time
7
8
  from dataclasses import dataclass
8
9
  from typing import Any, Protocol
9
10
 
11
+ from requests.exceptions import RequestException
12
+ from soco.exceptions import SoCoException, SoCoUPnPException
13
+ from urllib3.exceptions import HTTPError
14
+
10
15
  from jukebox.sonos.discovery import (
11
16
  DiscoveredSonosSpeaker,
12
17
  SonosDiscoveryError,
@@ -14,6 +19,9 @@ from jukebox.sonos.discovery import (
14
19
  sort_sonos_speakers,
15
20
  )
16
21
 
22
+ LOGGER = logging.getLogger("jukebox")
23
+ _SONOS_TRANSPORT_ERRORS = (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException)
24
+
17
25
 
18
26
  @dataclass(frozen=True)
19
27
  class _SonosDiscoverySnapshot:
@@ -217,7 +225,7 @@ class SoCoSonosDiscoveryAdapter(SonosDiscoveryPort):
217
225
  for speaker in list(discovered):
218
226
  try:
219
227
  available_speakers.update(speaker.all_zones)
220
- except Exception:
228
+ except _SONOS_TRANSPORT_ERRORS:
221
229
  available_speakers.add(speaker)
222
230
 
223
231
  if not available_speakers:
@@ -333,8 +341,8 @@ def _safe_speaker_identifier(speaker: "_SonosSpeakerLike") -> str:
333
341
  return ip_address
334
342
 
335
343
  try:
336
- uid = getattr(speaker, "uid")
337
- except Exception:
344
+ uid = speaker.uid
345
+ except _SONOS_TRANSPORT_ERRORS:
338
346
  return "unknown speaker"
339
347
 
340
348
  return str(uid)
@@ -343,7 +351,7 @@ def _safe_speaker_identifier(speaker: "_SonosSpeakerLike") -> str:
343
351
  def _safe_speaker_host(speaker: "_SonosSpeakerLike") -> str | None:
344
352
  try:
345
353
  ip_address = getattr(speaker, "ip_address", None)
346
- except Exception:
354
+ except _SONOS_TRANSPORT_ERRORS:
347
355
  return None
348
356
 
349
357
  if ip_address:
@@ -353,8 +361,8 @@ def _safe_speaker_host(speaker: "_SonosSpeakerLike") -> str | None:
353
361
 
354
362
  def _safe_speaker_uid(speaker: "_SonosSpeakerLike") -> str | None:
355
363
  try:
356
- return str(getattr(speaker, "uid"))
357
- except Exception:
364
+ return str(speaker.uid)
365
+ except _SONOS_TRANSPORT_ERRORS:
358
366
  return None
359
367
 
360
368
 
@@ -373,15 +381,15 @@ def _build_private_ipv4_networks_to_scan() -> list[str]:
373
381
  for adapter_ip in adapter.ips:
374
382
  try:
375
383
  ipv4_address = ipaddress.IPv4Address(adapter_ip.ip)
376
- except Exception:
384
+ except ValueError:
385
+ LOGGER.debug("Skipping non-IPv4 network adapter address: %r", adapter_ip.ip)
377
386
  continue
378
387
 
379
388
  if adapter_ip.network_prefix >= 32:
380
389
  continue
381
390
 
382
391
  network_prefix = adapter_ip.network_prefix
383
- if network_prefix < _MAX_SCAN_NETWORK_PREFIX:
384
- network_prefix = _MAX_SCAN_NETWORK_PREFIX
392
+ network_prefix = max(network_prefix, _MAX_SCAN_NETWORK_PREFIX)
385
393
 
386
394
  ipv4_network = ipaddress.ip_network(f"{ipv4_address}/{network_prefix}", strict=False)
387
395
  if not ipv4_network.is_private or ipv4_network.is_loopback or ipv4_network.is_link_local:
@@ -67,7 +67,7 @@ def _version_callback(value: bool) -> None:
67
67
  def _get_state(ctx: typer.Context) -> AdminCliState:
68
68
  state = ctx.obj
69
69
  if not isinstance(state, AdminCliState):
70
- raise RuntimeError("Admin CLI state was not initialized")
70
+ raise TypeError("Admin CLI state was not initialized")
71
71
  return state
72
72
 
73
73
 
@@ -50,7 +50,7 @@ def render_settings_output(
50
50
  def render_cli_error(err: BaseException, verbose: bool = False) -> str:
51
51
  message = _render_cli_error_message(err)
52
52
  if verbose and str(err) and str(err) != message:
53
- return f"{message}\n\nDetails: {str(err)}"
53
+ return f"{message}\n\nDetails: {err!s}"
54
54
  return message
55
55
 
56
56
 
@@ -376,7 +376,7 @@ def _render_cli_error_message(err: BaseException) -> str:
376
376
  return f"Malformed settings file at '{filepath}'. Fix the JSON syntax and try again."
377
377
  return "Malformed settings file. Fix the JSON syntax and try again."
378
378
  case UnsupportedSettingsVersionError():
379
- return f"Unsupported settings file version. {str(err)}"
379
+ return f"Unsupported settings file version. {err!s}"
380
380
  case InvalidSettingsError():
381
381
  return _render_invalid_settings_error(err)
382
382
  case SettingsError():
@@ -7,19 +7,15 @@ class PlayerPort(ABC):
7
7
  @abstractmethod
8
8
  def play(self, uri: str, shuffle: bool = False) -> None:
9
9
  """Start playing a URI with optional shuffle."""
10
- pass
11
10
 
12
11
  @abstractmethod
13
12
  def pause(self) -> None:
14
13
  """Pause playback."""
15
- pass
16
14
 
17
15
  @abstractmethod
18
16
  def resume(self) -> None:
19
17
  """Resume playback."""
20
- pass
21
18
 
22
19
  @abstractmethod
23
20
  def stop(self) -> None:
24
21
  """Stop playback."""
25
- pass
@@ -7,4 +7,3 @@ class ReaderPort(ABC):
7
7
  @abstractmethod
8
8
  def read(self) -> str | None:
9
9
  """Read a tag ID. Returns None if no tag detected."""
10
- pass
@@ -1,4 +1,4 @@
1
1
  from .current_tag_repository import CurrentTagRepository
2
2
  from .library_repository import LibraryRepository
3
3
 
4
- __all__ = ["LibraryRepository", "CurrentTagRepository"]
4
+ __all__ = ["CurrentTagRepository", "LibraryRepository"]
@@ -25,7 +25,7 @@ class SyncCurrentTag:
25
25
  elif command == "clear":
26
26
  self.repository.clear()
27
27
  next_state = success_state
28
- except Exception as err:
28
+ except Exception as err: # ruff: ignore[BLE001]
29
29
  LOGGER.warning(
30
30
  "Failed to sync current tag state; continuing, tag state may be stale: tag_id=%r, error=%s",
31
31
  tag_event.tag_id,
@@ -59,7 +59,7 @@ def resolve_connection_params(
59
59
  )
60
60
  defaults = profile.connections[protocol]
61
61
  if not isinstance(overrides, type(defaults)):
62
- raise ValueError(
62
+ raise TypeError(
63
63
  f"Expected overrides of type {type(defaults).__name__} for protocol '{protocol}', "
64
64
  f"got {type(overrides).__name__}"
65
65
  )
@@ -7,6 +7,6 @@ def table(headers, rows, indexed=False):
7
7
  widths = [max(len(str(x)) for x in col) for col in cols]
8
8
 
9
9
  def fmt(row):
10
- return " ".join(f"{str(val):<{widths[i]}}" for i, val in enumerate(row))
10
+ return " ".join(f"{val!s:<{widths[i]}}" for i, val in enumerate(row))
11
11
 
12
12
  return "\n".join([fmt(headers), *map(fmt, rows)])
@@ -0,0 +1,102 @@
1
+ [project]
2
+ name = "gukebox"
3
+ version = "1.4.2"
4
+ description = "A Jukebox to play music on speakers using 'CD' with NFC tag"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.14"
7
+ dependencies = [
8
+ "pydantic==2.13.4",
9
+ "questionary==2.1.1",
10
+ "soco==0.31.1",
11
+ "typer==0.27.0",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 4 - Beta",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Operating System :: OS Independent",
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ ]
22
+ keywords = [
23
+ "jukebox",
24
+ "music",
25
+ "nfc",
26
+ ]
27
+
28
+ [[project.authors]]
29
+ name = "Gudsfile"
30
+
31
+ [project.license]
32
+ file = "LICENSE"
33
+
34
+ [project.urls]
35
+ Repository = "https://github.com/Gudsfile/jukebox"
36
+
37
+ [project.optional-dependencies]
38
+ api = [
39
+ "fastapi==0.140.13",
40
+ "uvicorn==0.51.0",
41
+ ]
42
+ ui = [
43
+ "gukebox[api]",
44
+ "fastui==0.9.0",
45
+ "python-multipart==0.0.32",
46
+ ]
47
+ pn532 = [
48
+ "pyserial==3.5",
49
+ "spidev==3.8",
50
+ "lgpio==0.2.2.0; python_version < '3.13'",
51
+ ]
52
+
53
+ [project.scripts]
54
+ gukebox = "jukebox.app:main"
55
+ jukebox = "jukebox.app:main"
56
+ jukebox-admin = "jukebox.admin.app:main"
57
+
58
+ [dependency-groups]
59
+ dev = [
60
+ "pytest==9.1.1",
61
+ "pytest-mock==3.15.1",
62
+ "ruff==0.16.0",
63
+ "ty==0.0.64",
64
+ ]
65
+
66
+ [tool.ruff]
67
+ line-length = 120
68
+ indent-width = 4
69
+ target-version = "py311"
70
+ extend-exclude = ["pn532/"]
71
+
72
+ [tool.ruff.lint]
73
+ extend-select = [
74
+ "SIM",
75
+ "I",
76
+ "UP",
77
+ "G",
78
+ ]
79
+
80
+ [tool.ruff.lint.per-file-ignores]
81
+ "jukebox/adapters/inbound/admin/api/current_tag_router.py" = ["BLE001"]
82
+ "jukebox/adapters/inbound/admin/api/discs_router.py" = ["BLE001"]
83
+ "jukebox/adapters/inbound/admin/api/settings_router.py" = ["BLE001"]
84
+ "jukebox/adapters/inbound/admin/api_controller.py" = ["BLE001"]
85
+ "jukebox/adapters/inbound/admin/interactive_cli_controller.py" = ["BLE001"]
86
+ "jukebox/adapters/inbound/admin/ui_controller.py" = ["BLE001"]
87
+ "jukebox/adapters/inbound/admin/ui_pages/settings.py" = ["BLE001"]
88
+ "jukebox/admin/app.py" = ["BLE001"]
89
+
90
+ [tool.ty.src]
91
+ exclude = ["pn532/"]
92
+
93
+ [tool.uv.build-backend]
94
+ module-name = [
95
+ "jukebox",
96
+ "pn532",
97
+ ]
98
+ module-root = ""
99
+
100
+ [build-system]
101
+ requires = ["uv_build>=0.8.7,<0.13.0"]
102
+ build-backend = "uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "gukebox"
3
- version = "1.4.0.dev1"
3
+ version = "1.4.2"
4
4
  description = "A Jukebox to play music on speakers using 'CD' with NFC tag"
5
5
  authors = [{ name = "Gudsfile" }]
6
6
  readme = "README.md"
@@ -10,7 +10,7 @@ dependencies = [
10
10
  "pydantic==2.13.4",
11
11
  "questionary==2.1.1",
12
12
  "soco==0.31.1",
13
- "typer==0.26.7",
13
+ "typer==0.27.0",
14
14
  ]
15
15
  classifiers = [
16
16
  "Development Status :: 4 - Beta",
@@ -28,8 +28,8 @@ Repository = "https://github.com/Gudsfile/jukebox"
28
28
 
29
29
  [project.optional-dependencies]
30
30
  api = [
31
- "fastapi==0.136.3",
32
- "uvicorn==0.49.0",
31
+ "fastapi==0.140.13",
32
+ "uvicorn==0.51.0",
33
33
  ]
34
34
  ui = [
35
35
  "gukebox[api]",
@@ -45,10 +45,10 @@ pn532 = [
45
45
 
46
46
  [dependency-groups]
47
47
  dev = [
48
- "pytest==9.0.3",
48
+ "pytest==9.1.1",
49
49
  "pytest-mock==3.15.1",
50
- "ruff==0.15.16",
51
- "ty==0.0.44",
50
+ "ruff==0.16.0",
51
+ "ty==0.0.64",
52
52
  ]
53
53
 
54
54
  [tool.ruff]
@@ -65,11 +65,25 @@ extend-select = [
65
65
  "G",
66
66
  ]
67
67
 
68
+ [tool.ruff.lint.per-file-ignores]
69
+ # These inbound adapters (HTTP routers, UI/API controllers, CLI entry points) are the
70
+ # boundary layer whose job is to convert any unexpected failure into a safe response
71
+ # (an HTTP 500, a CLI error message) after more specific exceptions are already handled.
72
+ # A blind `except Exception` there is the intended last-resort safety net, not a bug.
73
+ "jukebox/adapters/inbound/admin/api/current_tag_router.py" = ["BLE001"]
74
+ "jukebox/adapters/inbound/admin/api/discs_router.py" = ["BLE001"]
75
+ "jukebox/adapters/inbound/admin/api/settings_router.py" = ["BLE001"]
76
+ "jukebox/adapters/inbound/admin/api_controller.py" = ["BLE001"]
77
+ "jukebox/adapters/inbound/admin/interactive_cli_controller.py" = ["BLE001"]
78
+ "jukebox/adapters/inbound/admin/ui_controller.py" = ["BLE001"]
79
+ "jukebox/adapters/inbound/admin/ui_pages/settings.py" = ["BLE001"]
80
+ "jukebox/admin/app.py" = ["BLE001"]
81
+
68
82
  [tool.ty.src]
69
83
  exclude = ["pn532/"]
70
84
 
71
85
  [build-system]
72
- requires = ["uv_build>=0.8.7,<0.12.0"]
86
+ requires = ["uv_build>=0.8.7,<0.13.0"]
73
87
  build-backend = "uv_build"
74
88
 
75
89
  [tool.uv.build-backend]
File without changes
File without changes
File without changes
@@ -25,25 +25,25 @@ from .tag_event import TagEvent
25
25
 
26
26
  __all__ = [
27
27
  "CURRENT_TAG_ABSENCE_GRACE_SECONDS",
28
+ "PLAYBACK_RETRY_DELAYS_SECONDS",
28
29
  "CurrentTagCommand",
29
30
  "CurrentTagContext",
30
31
  "CurrentTagState",
31
32
  "CurrentTagStatus",
33
+ "Disc",
34
+ "DiscMetadata",
35
+ "DiscOption",
32
36
  "Idle",
37
+ "Library",
33
38
  "NoTag",
34
- "TagPresent",
35
- "TagRemoved",
36
- "Playing",
37
- "Waiting",
38
39
  "Paused",
39
- "PLAYBACK_RETRY_DELAYS_SECONDS",
40
40
  "PlaybackCommand",
41
41
  "PlaybackState",
42
+ "Playing",
42
43
  "RetryState",
43
- "TransitionContext",
44
44
  "TagEvent",
45
- "Library",
46
- "Disc",
47
- "DiscMetadata",
48
- "DiscOption",
45
+ "TagPresent",
46
+ "TagRemoved",
47
+ "TransitionContext",
48
+ "Waiting",
49
49
  ]
@@ -10,11 +10,11 @@ from .library.search_discs import SearchDiscs
10
10
  from .sync_current_tag import SyncCurrentTag
11
11
 
12
12
  __all__ = [
13
- "HandleTagEvent",
14
13
  "AddDisc",
15
14
  "EditDisc",
16
15
  "GetCurrentTagStatus",
17
16
  "GetDisc",
17
+ "HandleTagEvent",
18
18
  "ListDiscs",
19
19
  "RemoveDisc",
20
20
  "ResolveTagId",
File without changes
File without changes
File without changes