gukebox 1.0.0.dev6__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 (115) hide show
  1. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/PKG-INFO +1 -1
  2. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/__init__.py +25 -0
  3. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/current_tag_router.py +161 -0
  4. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/discs_router.py +73 -0
  5. gukebox-1.0.0.dev7/discstore/adapters/inbound/api/models.py +48 -0
  6. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/api_controller.py +35 -11
  7. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/ui_controller.py +1 -0
  8. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/add_disc.py +2 -1
  9. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/edit_disc.py +2 -1
  10. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/sonos_discovery_adapter.py +1 -4
  11. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/app.py +41 -4
  12. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/cli_presentation.py +83 -61
  13. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/command_handlers.py +26 -14
  14. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/commands.py +8 -1
  15. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/di_container.py +1 -0
  16. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/entities.py +4 -1
  17. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/sonos/__init__.py +0 -4
  18. gukebox-1.0.0.dev7/jukebox/sonos/selection.py +168 -0
  19. gukebox-1.0.0.dev7/jukebox/sonos/service.py +125 -0
  20. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pyproject.toml +2 -1
  21. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/current_tag_router.py +0 -25
  22. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/discs_router.py +0 -48
  23. gukebox-1.0.0.dev6/discstore/adapters/inbound/api/models.py +0 -25
  24. gukebox-1.0.0.dev6/jukebox/shared/__init__.py +0 -0
  25. gukebox-1.0.0.dev6/jukebox/sonos/selection.py +0 -144
  26. gukebox-1.0.0.dev6/jukebox/sonos/service.py +0 -73
  27. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/LICENSE +0 -0
  28. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/README.md +0 -0
  29. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/__init__.py +0 -0
  30. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/__init__.py +0 -0
  31. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/__init__.py +0 -0
  32. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/api/settings_router.py +0 -0
  33. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_controller.py +0 -0
  34. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_display.py +0 -0
  35. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/config.py +0 -0
  36. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
  37. {gukebox-1.0.0.dev6/discstore/adapters/inbound/api → gukebox-1.0.0.dev7/discstore/adapters/outbound}/__init__.py +0 -0
  38. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/json_library_adapter.py +0 -0
  39. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
  40. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/app.py +0 -0
  41. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/command_handlers.py +0 -0
  42. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/commands.py +0 -0
  43. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/di_container.py +0 -0
  44. {gukebox-1.0.0.dev6/discstore/adapters/outbound → gukebox-1.0.0.dev7/discstore/domain}/__init__.py +0 -0
  45. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/entities/__init__.py +0 -0
  46. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/entities/current_tag_status.py +0 -0
  47. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/repositories/__init__.py +0 -0
  48. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/__init__.py +0 -0
  49. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
  50. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_disc.py +0 -0
  51. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/list_discs.py +0 -0
  52. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/remove_disc.py +0 -0
  53. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
  54. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/search_discs.py +0 -0
  55. {gukebox-1.0.0.dev6/discstore/domain → gukebox-1.0.0.dev7/jukebox}/__init__.py +0 -0
  56. {gukebox-1.0.0.dev6/jukebox → gukebox-1.0.0.dev7/jukebox/adapters}/__init__.py +0 -0
  57. {gukebox-1.0.0.dev6/jukebox/adapters → gukebox-1.0.0.dev7/jukebox/adapters/inbound}/__init__.py +0 -0
  58. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/cli_controller.py +0 -0
  59. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/config.py +0 -0
  60. {gukebox-1.0.0.dev6/jukebox/adapters/inbound → gukebox-1.0.0.dev7/jukebox/adapters/outbound}/__init__.py +0 -0
  61. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  62. {gukebox-1.0.0.dev6/jukebox/adapters/outbound → gukebox-1.0.0.dev7/jukebox/adapters/outbound/players}/__init__.py +0 -0
  63. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  64. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
  65. {gukebox-1.0.0.dev6/jukebox/adapters/outbound/players → gukebox-1.0.0.dev7/jukebox/adapters/outbound/readers}/__init__.py +0 -0
  66. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  67. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  68. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  69. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/__init__.py +0 -0
  70. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/services.py +0 -0
  71. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/app.py +0 -0
  72. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/di_container.py +0 -0
  73. {gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers → gukebox-1.0.0.dev7/jukebox/domain}/__init__.py +0 -0
  74. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/__init__.py +0 -0
  75. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/current_tag_action.py +0 -0
  76. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/disc.py +0 -0
  77. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/library.py +0 -0
  78. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_action.py +0 -0
  79. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_session.py +0 -0
  80. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/tag_event.py +0 -0
  81. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/__init__.py +0 -0
  82. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/player_port.py +0 -0
  83. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/reader_port.py +0 -0
  84. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/__init__.py +0 -0
  85. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  86. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/library_repository.py +0 -0
  87. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/__init__.py +0 -0
  88. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_action.py +0 -0
  89. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
  90. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
  91. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/__init__.py +0 -0
  92. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/definitions.py +0 -0
  93. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/dict_utils.py +0 -0
  94. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/errors.py +0 -0
  95. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/file_settings_repository.py +0 -0
  96. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/migration.py +0 -0
  97. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/repositories.py +0 -0
  98. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/resolve.py +0 -0
  99. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_resolver.py +0 -0
  100. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_validation.py +0 -0
  101. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  102. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/service_protocols.py +0 -0
  103. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/timing_validation.py +0 -0
  104. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/types.py +0 -0
  105. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/validation_rules.py +0 -0
  106. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/view_utils.py +0 -0
  107. {gukebox-1.0.0.dev6/jukebox/domain → gukebox-1.0.0.dev7/jukebox/shared}/__init__.py +0 -0
  108. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/config_utils.py +0 -0
  109. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/dependency_messages.py +0 -0
  110. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/logger.py +0 -0
  111. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/timing.py +0 -0
  112. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/sonos/discovery.py +0 -0
  113. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pn532/__init__.py +0 -0
  114. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pn532/pn532.py +0 -0
  115. {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/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.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
@@ -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
@@ -10,9 +10,11 @@ try:
10
10
  from discstore.adapters.inbound.api.current_tag_router import build_current_tag_router
11
11
  from discstore.adapters.inbound.api.discs_router import build_discs_router
12
12
  from discstore.adapters.inbound.api.models import (
13
+ CurrentTagDiscOutput,
13
14
  CurrentTagStatusOutput,
14
15
  DiscInput,
15
16
  DiscOutput,
17
+ DiscPatchInput,
16
18
  SettingsPatchInput,
17
19
  SettingsResetInput,
18
20
  )
@@ -26,20 +28,23 @@ except ModuleNotFoundError as e:
26
28
  from discstore.domain.use_cases.add_disc import AddDisc
27
29
  from discstore.domain.use_cases.edit_disc import EditDisc
28
30
  from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus
31
+ from discstore.domain.use_cases.get_disc import GetDisc
29
32
  from discstore.domain.use_cases.list_discs import ListDiscs
30
33
  from discstore.domain.use_cases.remove_disc import RemoveDisc
31
34
  from jukebox.settings.entities import SelectedSonosGroupSettings
32
35
  from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
33
36
  from jukebox.settings.service_protocols import SettingsService
34
37
  from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError
35
- from jukebox.sonos.selection import GetSonosSelectionStatus, PlanSonosSelection, SaveSonosSelection
38
+ from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection
36
39
  from jukebox.sonos.service import SonosService
37
40
 
38
41
  __all__ = [
39
42
  "APIController",
43
+ "CurrentTagDiscOutput",
40
44
  "CurrentTagStatusOutput",
41
45
  "DiscInput",
42
46
  "DiscOutput",
47
+ "DiscPatchInput",
43
48
  "SettingsPatchInput",
44
49
  "SettingsResetInput",
45
50
  "SonosSelectionInput",
@@ -54,11 +59,17 @@ class SelectedSonosGroupOutput(SelectedSonosGroupSettings):
54
59
  pass
55
60
 
56
61
 
57
- class SonosSelectionAvailabilityOutput(BaseModel):
62
+ class SonosSelectionMemberAvailabilityOutput(BaseModel):
63
+ uid: str
58
64
  status: str
59
65
  speaker: Optional[SonosSpeakerOutput] = None
60
66
 
61
67
 
68
+ class SonosSelectionAvailabilityOutput(BaseModel):
69
+ status: str
70
+ members: list[SonosSelectionMemberAvailabilityOutput]
71
+
72
+
62
73
  class SonosSelectionOutput(BaseModel):
63
74
  selected_group: Optional[SelectedSonosGroupOutput] = None
64
75
  availability: SonosSelectionAvailabilityOutput
@@ -66,6 +77,7 @@ class SonosSelectionOutput(BaseModel):
66
77
 
67
78
  class SonosSelectionInput(BaseModel):
68
79
  uids: list[str]
80
+ coordinator_uid: Optional[str] = None
69
81
 
70
82
 
71
83
  class SonosSelectionUpdateOutput(BaseModel):
@@ -82,6 +94,7 @@ class APIController:
82
94
  list_discs: ListDiscs,
83
95
  remove_disc: RemoveDisc,
84
96
  edit_disc: EditDisc,
97
+ get_disc: GetDisc,
85
98
  get_current_tag_status: GetCurrentTagStatus,
86
99
  settings_service: SettingsService,
87
100
  sonos_service: SonosService,
@@ -90,6 +103,7 @@ class APIController:
90
103
  self.list_discs = list_discs
91
104
  self.remove_disc = remove_disc
92
105
  self.edit_disc = edit_disc
106
+ self.get_disc = get_disc
93
107
  self.get_current_tag_status = get_current_tag_status
94
108
  self.settings_service = settings_service
95
109
  self.sonos_service = sonos_service
@@ -108,9 +122,18 @@ class APIController:
108
122
  list_discs=self.list_discs,
109
123
  remove_disc=self.remove_disc,
110
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,
111
135
  )
112
136
  )
113
- self.app.include_router(build_current_tag_router(self.get_current_tag_status))
114
137
  self.app.include_router(build_settings_router(self.settings_service))
115
138
 
116
139
  @self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput])
@@ -137,21 +160,22 @@ class APIController:
137
160
  @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput)
138
161
  def put_sonos_selection(payload: SonosSelectionInput):
139
162
  try:
140
- plan = PlanSonosSelection(self.sonos_service).execute(requested_uids=payload.uids)
141
- if plan.status in {"invalid_request", "none_available"}:
142
- raise HTTPException(status_code=400, detail=str(plan.error_message))
143
- if plan.status == "needs_choice" or plan.selected_uid is None:
144
- raise HTTPException(status_code=400, detail="No Sonos speaker selection was made.")
145
-
146
163
  result = SaveSonosSelection(
147
164
  SettingsSelectedSonosGroupRepository(self.settings_service),
148
165
  self.sonos_service,
149
- ).execute(plan.selected_uid)
166
+ ).execute(payload.uids, coordinator_uid=payload.coordinator_uid)
150
167
  return SonosSelectionUpdateOutput(
151
168
  selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()),
152
169
  availability=SonosSelectionAvailabilityOutput(
153
170
  status="available",
154
- speaker=SonosSpeakerOutput(**result.speaker.model_dump()),
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
+ ],
155
179
  ),
156
180
  message=result.settings_message,
157
181
  restart_required=result.restart_required,
@@ -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
@@ -14,7 +14,7 @@ class EditDisc:
14
14
  uri: Optional[str] = None,
15
15
  metadata: Optional[DiscMetadata] = None,
16
16
  option: Optional[DiscOption] = None,
17
- ) -> None:
17
+ ) -> Disc:
18
18
  current_disc = self.repository.get_disc(tag_id)
19
19
  if current_disc is None:
20
20
  raise ValueError(f"Tag does not exist: tag_id='{tag_id}'")
@@ -37,3 +37,4 @@ class EditDisc:
37
37
 
38
38
  updated_disc = Disc(uri=new_uri, metadata=new_metadata, option=new_option)
39
39
  self.repository.update_disc(tag_id, updated_disc)
40
+ return updated_disc
@@ -153,10 +153,7 @@ class SoCoSonosDiscoveryAdapter(SonosDiscoveryPort):
153
153
  except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
154
154
  return (
155
155
  None,
156
- "{}: {}".format(
157
- _safe_speaker_identifier(speaker),
158
- err,
159
- ),
156
+ f"{_safe_speaker_identifier(speaker)}: {err}",
160
157
  )
161
158
 
162
159
 
@@ -82,6 +82,7 @@ def _run_command(ctx: typer.Context, command: object) -> None:
82
82
  sonos_service=services.sonos,
83
83
  settings_service=services.settings,
84
84
  speaker_prompt_fn=_prompt_for_sonos_speaker_selection,
85
+ coordinator_prompt_fn=_prompt_for_sonos_group_coordinator,
85
86
  )
86
87
  else:
87
88
  execute_server_command(
@@ -154,12 +155,30 @@ def _exit_on_command_validation_error(err: ValidationError) -> None:
154
155
  raise SystemExit(str(err)) from err
155
156
 
156
157
 
157
- def _prompt_for_sonos_speaker_selection(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
158
+ def _prompt_for_sonos_speaker_selection(speakers: list[DiscoveredSonosSpeaker]) -> Optional[list[str]]:
159
+ import questionary
160
+
161
+ try:
162
+ return questionary.checkbox(
163
+ "Select one or more Sonos speakers",
164
+ choices=[
165
+ questionary.Choice(
166
+ title=build_sonos_speaker_choice_label(speaker),
167
+ value=speaker.uid,
168
+ )
169
+ for speaker in speakers
170
+ ],
171
+ ).ask()
172
+ except KeyboardInterrupt:
173
+ return None
174
+
175
+
176
+ def _prompt_for_sonos_group_coordinator(speakers: list[DiscoveredSonosSpeaker]) -> Optional[str]:
158
177
  import questionary
159
178
 
160
179
  try:
161
180
  return questionary.select(
162
- "Select a Sonos speaker",
181
+ "Select the Sonos coordinator",
163
182
  choices=[
164
183
  questionary.Choice(
165
184
  title=build_sonos_speaker_choice_label(speaker),
@@ -294,10 +313,28 @@ def sonos_select(
294
313
  ctx: typer.Context,
295
314
  uids: Annotated[
296
315
  Optional[list[str]],
297
- typer.Option("--uids", help="discover and persist exactly one Sonos speaker UID"),
316
+ typer.Option(
317
+ "--uids",
318
+ help=(
319
+ "comma-separated Sonos speaker UIDs to persist as the selected group; may be repeated; "
320
+ "first UID is used as coordinator if --coordinator is omitted"
321
+ ),
322
+ ),
323
+ ] = None,
324
+ coordinator: Annotated[
325
+ Optional[str],
326
+ typer.Option("--coordinator", help="coordinator UID for the selected Sonos group"),
298
327
  ] = None,
299
328
  ) -> None:
300
- _run_command(ctx, SonosSelectCommand(type="sonos_select", uids=uids))
329
+ parsed_uids = (
330
+ None if uids is None else [uid.strip() for raw_uids in uids for uid in raw_uids.split(",") if uid.strip()]
331
+ )
332
+ try:
333
+ command = SonosSelectCommand(type="sonos_select", uids=parsed_uids, coordinator=coordinator)
334
+ except ValidationError as err:
335
+ _exit_on_command_validation_error(err)
336
+
337
+ _run_command(ctx, command)
301
338
 
302
339
 
303
340
  @sonos_app.command("show")