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.
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/PKG-INFO +1 -1
- gukebox-1.0.0.dev7/discstore/adapters/inbound/api/__init__.py +25 -0
- gukebox-1.0.0.dev7/discstore/adapters/inbound/api/current_tag_router.py +161 -0
- gukebox-1.0.0.dev7/discstore/adapters/inbound/api/discs_router.py +73 -0
- gukebox-1.0.0.dev7/discstore/adapters/inbound/api/models.py +48 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/api_controller.py +35 -11
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/ui_controller.py +1 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/add_disc.py +2 -1
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/edit_disc.py +2 -1
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/sonos_discovery_adapter.py +1 -4
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/app.py +41 -4
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/cli_presentation.py +83 -61
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/command_handlers.py +26 -14
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/commands.py +8 -1
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/di_container.py +1 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/entities.py +4 -1
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/sonos/__init__.py +0 -4
- gukebox-1.0.0.dev7/jukebox/sonos/selection.py +168 -0
- gukebox-1.0.0.dev7/jukebox/sonos/service.py +125 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pyproject.toml +2 -1
- gukebox-1.0.0.dev6/discstore/adapters/inbound/api/current_tag_router.py +0 -25
- gukebox-1.0.0.dev6/discstore/adapters/inbound/api/discs_router.py +0 -48
- gukebox-1.0.0.dev6/discstore/adapters/inbound/api/models.py +0 -25
- gukebox-1.0.0.dev6/jukebox/shared/__init__.py +0 -0
- gukebox-1.0.0.dev6/jukebox/sonos/selection.py +0 -144
- gukebox-1.0.0.dev6/jukebox/sonos/service.py +0 -73
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/LICENSE +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/README.md +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/api/settings_router.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/cli_display.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/config.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/inbound/interactive_cli_controller.py +0 -0
- {gukebox-1.0.0.dev6/discstore/adapters/inbound/api → gukebox-1.0.0.dev7/discstore/adapters/outbound}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/app.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/command_handlers.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/commands.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/di_container.py +0 -0
- {gukebox-1.0.0.dev6/discstore/adapters/outbound → gukebox-1.0.0.dev7/discstore/domain}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_current_tag_status.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/get_disc.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/list_discs.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/remove_disc.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/resolve_tag_id.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/discstore/domain/use_cases/search_discs.py +0 -0
- {gukebox-1.0.0.dev6/discstore/domain → gukebox-1.0.0.dev7/jukebox}/__init__.py +0 -0
- {gukebox-1.0.0.dev6/jukebox → gukebox-1.0.0.dev7/jukebox/adapters}/__init__.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/adapters → gukebox-1.0.0.dev7/jukebox/adapters/inbound}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/inbound/config.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/adapters/inbound → gukebox-1.0.0.dev7/jukebox/adapters/outbound}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/adapters/outbound → gukebox-1.0.0.dev7/jukebox/adapters/outbound/players}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/adapters/outbound/players → gukebox-1.0.0.dev7/jukebox/adapters/outbound/readers}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/admin/services.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/app.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/di_container.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/adapters/outbound/readers → gukebox-1.0.0.dev7/jukebox/domain}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/current_tag_action.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/disc.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_action.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/playback_session.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/repositories/library_repository.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_action.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/determine_current_tag_action.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/definitions.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/errors.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/file_settings_repository.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/migration.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/resolve.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_resolver.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/service_protocols.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/types.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.0.0.dev6/jukebox/domain → gukebox-1.0.0.dev7/jukebox/shared}/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/dependency_messages.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/logger.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/shared/timing.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/sonos/discovery.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pn532/__init__.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pn532/pn532.py +0 -0
- {gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/pn532/spi.py +0 -0
|
@@ -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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|
|
@@ -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
|
-
) ->
|
|
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
|
{gukebox-1.0.0.dev6 → gukebox-1.0.0.dev7}/jukebox/adapters/outbound/sonos_discovery_adapter.py
RENAMED
|
@@ -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
|
-
"{}: {}"
|
|
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
|
|
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(
|
|
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
|
-
|
|
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")
|