From 4bc5cc3099f79fa778bd98ece023cc6b0f079d43 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:45:31 +0200 Subject: [PATCH 1/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20CurrentTagStatu?= =?UTF-8?q?s=20entity=20to=20jukebox=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jukebox/domain/entities/__init__.py | 2 ++ jukebox/domain/entities/current_tag_status.py | 6 ++++++ 2 files changed, 8 insertions(+) create mode 100644 jukebox/domain/entities/current_tag_status.py diff --git a/jukebox/domain/entities/__init__.py b/jukebox/domain/entities/__init__.py index d0b4e1e2..ab41acaa 100644 --- a/jukebox/domain/entities/__init__.py +++ b/jukebox/domain/entities/__init__.py @@ -1,4 +1,5 @@ from .current_tag_action import CurrentTagAction +from .current_tag_status import CurrentTagStatus from .disc import Disc, DiscMetadata, DiscOption from .library import Library from .playback_action import PlaybackAction @@ -7,6 +8,7 @@ __all__ = [ "CurrentTagAction", + "CurrentTagStatus", "PlaybackAction", "PlaybackSession", "TagEvent", diff --git a/jukebox/domain/entities/current_tag_status.py b/jukebox/domain/entities/current_tag_status.py new file mode 100644 index 00000000..a985cc92 --- /dev/null +++ b/jukebox/domain/entities/current_tag_status.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class CurrentTagStatus(BaseModel): + tag_id: str + known_in_library: bool From 81d5cfabe94662b2d95140376ef557fbf68207c3 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:46:18 +0200 Subject: [PATCH 2/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20library=20use?= =?UTF-8?q?=20cases=20from=20discstore=20to=20jukebox=20domain?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jukebox/domain/use_cases/library/__init__.py | 0 jukebox/domain/use_cases/library/add_disc.py | 11 ++++++ jukebox/domain/use_cases/library/edit_disc.py | 38 +++++++++++++++++++ .../library/get_current_tag_status.py | 15 ++++++++ jukebox/domain/use_cases/library/get_disc.py | 14 +++++++ .../domain/use_cases/library/list_discs.py | 10 +++++ .../domain/use_cases/library/remove_disc.py | 9 +++++ .../use_cases/library/resolve_tag_id.py | 21 ++++++++++ .../domain/use_cases/library/search_discs.py | 27 +++++++++++++ 9 files changed, 145 insertions(+) create mode 100644 jukebox/domain/use_cases/library/__init__.py create mode 100644 jukebox/domain/use_cases/library/add_disc.py create mode 100644 jukebox/domain/use_cases/library/edit_disc.py create mode 100644 jukebox/domain/use_cases/library/get_current_tag_status.py create mode 100644 jukebox/domain/use_cases/library/get_disc.py create mode 100644 jukebox/domain/use_cases/library/list_discs.py create mode 100644 jukebox/domain/use_cases/library/remove_disc.py create mode 100644 jukebox/domain/use_cases/library/resolve_tag_id.py create mode 100644 jukebox/domain/use_cases/library/search_discs.py diff --git a/jukebox/domain/use_cases/library/__init__.py b/jukebox/domain/use_cases/library/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jukebox/domain/use_cases/library/add_disc.py b/jukebox/domain/use_cases/library/add_disc.py new file mode 100644 index 00000000..701ebdb1 --- /dev/null +++ b/jukebox/domain/use_cases/library/add_disc.py @@ -0,0 +1,11 @@ +from jukebox.domain.entities import Disc +from jukebox.domain.repositories import LibraryRepository + + +class AddDisc: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute(self, tag_id: str, disc: Disc) -> Disc: + self.repository.add_disc(tag_id, disc) + return disc diff --git a/jukebox/domain/use_cases/library/edit_disc.py b/jukebox/domain/use_cases/library/edit_disc.py new file mode 100644 index 00000000..97e77d35 --- /dev/null +++ b/jukebox/domain/use_cases/library/edit_disc.py @@ -0,0 +1,38 @@ +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.domain.repositories import LibraryRepository + + +class EditDisc: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute( + self, + tag_id: str, + uri: str | None = None, + metadata: DiscMetadata | None = None, + option: DiscOption | None = None, + ) -> Disc: + current_disc = self.repository.get_disc(tag_id) + if current_disc is None: + raise ValueError(f"Tag does not exist: tag_id='{tag_id}'") + + new_uri = uri if uri is not None else current_disc.uri + + new_metadata = current_disc.metadata + if metadata is not None: + current_data = current_disc.metadata.model_dump() + new_data = metadata.model_dump(exclude_unset=True) + current_data.update(new_data) + new_metadata = DiscMetadata(**current_data) + + new_option = current_disc.option + if option is not None: + current_opt_data = current_disc.option.model_dump() + new_opt_data = option.model_dump(exclude_unset=True) + current_opt_data.update(new_opt_data) + new_option = DiscOption(**current_opt_data) + + updated_disc = Disc(uri=new_uri, metadata=new_metadata, option=new_option) + self.repository.update_disc(tag_id, updated_disc) + return updated_disc diff --git a/jukebox/domain/use_cases/library/get_current_tag_status.py b/jukebox/domain/use_cases/library/get_current_tag_status.py new file mode 100644 index 00000000..b84deb1f --- /dev/null +++ b/jukebox/domain/use_cases/library/get_current_tag_status.py @@ -0,0 +1,15 @@ +from jukebox.domain.entities import CurrentTagStatus +from jukebox.domain.repositories import CurrentTagRepository, LibraryRepository + + +class GetCurrentTagStatus: + def __init__(self, current_tag_repository: CurrentTagRepository, library: LibraryRepository): + self.current_tag_repository = current_tag_repository + self.library = library + + def execute(self) -> CurrentTagStatus | None: + tag_id = self.current_tag_repository.get() + if tag_id is None: + return None + + return CurrentTagStatus(tag_id=tag_id, known_in_library=self.library.get_disc(tag_id) is not None) diff --git a/jukebox/domain/use_cases/library/get_disc.py b/jukebox/domain/use_cases/library/get_disc.py new file mode 100644 index 00000000..482e7a1f --- /dev/null +++ b/jukebox/domain/use_cases/library/get_disc.py @@ -0,0 +1,14 @@ +from jukebox.domain.entities import Disc +from jukebox.domain.repositories import LibraryRepository + + +class GetDisc: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute(self, tag_id: str) -> Disc: + disc = self.repository.get_disc(tag_id) + if disc is None: + raise ValueError(f"Tag not found: tag_id='{tag_id}'") + + return disc diff --git a/jukebox/domain/use_cases/library/list_discs.py b/jukebox/domain/use_cases/library/list_discs.py new file mode 100644 index 00000000..fad79aad --- /dev/null +++ b/jukebox/domain/use_cases/library/list_discs.py @@ -0,0 +1,10 @@ +from jukebox.domain.entities import Disc +from jukebox.domain.repositories import LibraryRepository + + +class ListDiscs: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute(self) -> dict[str, Disc]: + return self.repository.list_discs() diff --git a/jukebox/domain/use_cases/library/remove_disc.py b/jukebox/domain/use_cases/library/remove_disc.py new file mode 100644 index 00000000..85411733 --- /dev/null +++ b/jukebox/domain/use_cases/library/remove_disc.py @@ -0,0 +1,9 @@ +from jukebox.domain.repositories import LibraryRepository + + +class RemoveDisc: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute(self, tag_id: str) -> None: + self.repository.remove_disc(tag_id) diff --git a/jukebox/domain/use_cases/library/resolve_tag_id.py b/jukebox/domain/use_cases/library/resolve_tag_id.py new file mode 100644 index 00000000..706c5bf1 --- /dev/null +++ b/jukebox/domain/use_cases/library/resolve_tag_id.py @@ -0,0 +1,21 @@ +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus + + +class ResolveTagId: + def __init__(self, get_current_tag_status: GetCurrentTagStatus): + self.get_current_tag_status = get_current_tag_status + + def execute(self, tag_id: str | None, use_current_tag: bool) -> str: + has_explicit_tag_id = bool(tag_id) + if has_explicit_tag_id == use_current_tag: + raise ValueError("Exactly one tag source must be provided: explicit tag or --from-current.") + + if has_explicit_tag_id: + assert tag_id is not None + return tag_id + + current_tag_status = self.get_current_tag_status.execute() + if current_tag_status is None: + raise ValueError("No current tag is available.") + + return current_tag_status.tag_id diff --git a/jukebox/domain/use_cases/library/search_discs.py b/jukebox/domain/use_cases/library/search_discs.py new file mode 100644 index 00000000..b5a8e5f4 --- /dev/null +++ b/jukebox/domain/use_cases/library/search_discs.py @@ -0,0 +1,27 @@ +from jukebox.domain.entities import Disc +from jukebox.domain.repositories import LibraryRepository + + +class SearchDiscs: + def __init__(self, repository: LibraryRepository): + self.repository = repository + + def execute(self, query: str) -> dict[str, Disc]: + query_lower = query.lower() + results = {} + + for tag_id, disc in self.repository.list_discs().items(): + if query_lower in tag_id.lower(): + results[tag_id] = disc + continue + + metadata = disc.metadata + if ( + (metadata.artist and query_lower in metadata.artist.lower()) + or (metadata.album and query_lower in metadata.album.lower()) + or (metadata.track and query_lower in metadata.track.lower()) + or (metadata.playlist and query_lower in metadata.playlist.lower()) + ): + results[tag_id] = disc + + return results From 45489318d7d49e534d976be26a083ab71c1299f6 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:51:10 +0200 Subject: [PATCH 3/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20admin=20inbound?= =?UTF-8?q?=20adapters=20from=20discstore=20to=20jukebox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jukebox/adapters/inbound/admin/__init__.py | 0 .../adapters/inbound/admin/api/__init__.py | 0 .../inbound/admin/api/current_tag_router.py | 164 ++++++ .../inbound/admin/api/discs_router.py | 74 +++ jukebox/adapters/inbound/admin/api/models.py | 48 ++ .../inbound/admin/api/settings_router.py | 46 ++ .../adapters/inbound/admin/api_controller.py | 188 +++++++ .../adapters/inbound/admin/cli_controller.py | 126 +++++ jukebox/adapters/inbound/admin/cli_display.py | 58 ++ .../admin/interactive_cli_controller.py | 132 +++++ .../adapters/inbound/admin/ui_controller.py | 505 +++++++++++++++++ .../inbound/admin/ui_pages/__init__.py | 0 .../inbound/admin/ui_pages/library.py | 375 +++++++++++++ .../inbound/admin/ui_pages/settings.py | 525 ++++++++++++++++++ .../adapters/inbound/admin/ui_pages/sonos.py | 501 +++++++++++++++++ 15 files changed, 2742 insertions(+) create mode 100644 jukebox/adapters/inbound/admin/__init__.py create mode 100644 jukebox/adapters/inbound/admin/api/__init__.py create mode 100644 jukebox/adapters/inbound/admin/api/current_tag_router.py create mode 100644 jukebox/adapters/inbound/admin/api/discs_router.py create mode 100644 jukebox/adapters/inbound/admin/api/models.py create mode 100644 jukebox/adapters/inbound/admin/api/settings_router.py create mode 100644 jukebox/adapters/inbound/admin/api_controller.py create mode 100644 jukebox/adapters/inbound/admin/cli_controller.py create mode 100644 jukebox/adapters/inbound/admin/cli_display.py create mode 100644 jukebox/adapters/inbound/admin/interactive_cli_controller.py create mode 100644 jukebox/adapters/inbound/admin/ui_controller.py create mode 100644 jukebox/adapters/inbound/admin/ui_pages/__init__.py create mode 100644 jukebox/adapters/inbound/admin/ui_pages/library.py create mode 100644 jukebox/adapters/inbound/admin/ui_pages/settings.py create mode 100644 jukebox/adapters/inbound/admin/ui_pages/sonos.py diff --git a/jukebox/adapters/inbound/admin/__init__.py b/jukebox/adapters/inbound/admin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jukebox/adapters/inbound/admin/api/__init__.py b/jukebox/adapters/inbound/admin/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jukebox/adapters/inbound/admin/api/current_tag_router.py b/jukebox/adapters/inbound/admin/api/current_tag_router.py new file mode 100644 index 00000000..7788399b --- /dev/null +++ b/jukebox/adapters/inbound/admin/api/current_tag_router.py @@ -0,0 +1,164 @@ +from typing import Any + +from fastapi import APIRouter, HTTPException, Response, status +from pydantic import ValidationError + +from jukebox.adapters.inbound.admin.api.models import ( + CurrentTagDiscOutput, + CurrentTagStatusOutput, + DiscInput, + DiscOutput, + DiscPatchInput, +) +from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc + + +def build_current_tag_router( + get_current_tag_status: GetCurrentTagStatus, + add_disc: AddDisc, + edit_disc: EditDisc, + get_disc: GetDisc, + remove_disc: RemoveDisc, +) -> APIRouter: + router = APIRouter(prefix="/api/v1", tags=["current-tag"]) + + def read_current_tag_status() -> CurrentTagStatus | None: + return get_current_tag_status.execute() + + def ensure_expected_tag_id_matches( + expected_tag_id: str | None, current_tag_status: CurrentTagStatus | None + ) -> None: + if expected_tag_id is None: + return + + actual_tag_id = None if current_tag_status is None else current_tag_status.tag_id + if actual_tag_id != expected_tag_id: + raise HTTPException( + status_code=409, + detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={repr(actual_tag_id)}", + ) + + def build_current_tag_disc_output(tag_id: str, disc: Disc) -> CurrentTagDiscOutput: + return CurrentTagDiscOutput(tag_id=tag_id, disc=DiscOutput(**disc.model_dump())) + + @router.get( + "/current-tag", + response_model=CurrentTagStatusOutput, + responses={204: {"description": "No current tag"}}, + summary="Get the current NFC tag status", + ) + def get_current_tag() -> Any: + current_tag_status = read_current_tag_status() + if current_tag_status is None: + return Response(status_code=204) + + return CurrentTagStatusOutput(**current_tag_status.model_dump()) + + @router.get( + "/current-tag/disc", + response_model=CurrentTagDiscOutput, + responses={204: {"description": "No current tag"}, 404: {"description": "Current tag disc not found"}}, + summary="Get the current tag disc", + ) + def get_current_tag_disc() -> Any: + current_tag_status = read_current_tag_status() + if current_tag_status is None: + return Response(status_code=204) + + if not current_tag_status.known_in_library: + raise HTTPException(status_code=404, detail=f"Tag does not exist: tag_id='{current_tag_status.tag_id}'") + + return build_current_tag_disc_output(current_tag_status.tag_id, get_disc.execute(current_tag_status.tag_id)) + + @router.post( + "/current-tag/disc", + response_model=CurrentTagDiscOutput, + status_code=201, + responses={204: {"description": "No current tag"}, 409: {"description": "Current tag changed or disc exists"}}, + summary="Create a disc for the current tag", + ) + def create_current_tag_disc( + disc: DiscInput, + expected_tag_id: str | None = None, + ) -> Any: + current_tag_status = read_current_tag_status() + ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) + if current_tag_status is None: + return Response(status_code=204) + + try: + new_disc = Disc(**disc.model_dump()) + created_disc = add_disc.execute(current_tag_status.tag_id, new_disc) + return build_current_tag_disc_output(current_tag_status.tag_id, created_disc) + except ValueError as value_err: + raise HTTPException(status_code=409, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.patch( + "/current-tag/disc", + response_model=CurrentTagDiscOutput, + responses={ + 204: {"description": "No current tag"}, + 404: {"description": "Current tag disc not found"}, + 409: {"description": "Current tag changed"}, + }, + summary="Update the current tag disc", + ) + def update_current_tag_disc( + disc_patch: DiscPatchInput, + expected_tag_id: str | None = None, + ) -> Any: + current_tag_status = read_current_tag_status() + ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) + if current_tag_status is None: + return Response(status_code=204) + + try: + metadata = None + if disc_patch.metadata is not None: + metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True)) + + option = None + if disc_patch.option is not None: + option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True)) + + updated_disc = edit_disc.execute(current_tag_status.tag_id, disc_patch.uri, metadata, option) + return build_current_tag_disc_output(current_tag_status.tag_id, updated_disc) + except ValidationError as err: + raise HTTPException(status_code=422, detail=err.errors()) + except ValueError as value_err: + raise HTTPException(status_code=404, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.delete( + "/current-tag/disc", + status_code=204, + responses={ + 204: {"description": "No current tag or disc deleted"}, + 404: {"description": "Current tag disc not found"}, + 409: {"description": "Current tag changed"}, + }, + summary="Delete the current tag disc", + ) + def delete_current_tag_disc(expected_tag_id: str | None = None) -> Response: + current_tag_status = read_current_tag_status() + ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) + if current_tag_status is None: + return Response(status_code=status.HTTP_204_NO_CONTENT) + + try: + remove_disc.execute(current_tag_status.tag_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except ValueError as value_err: + raise HTTPException(status_code=404, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return router diff --git a/jukebox/adapters/inbound/admin/api/discs_router.py b/jukebox/adapters/inbound/admin/api/discs_router.py new file mode 100644 index 00000000..d373abf5 --- /dev/null +++ b/jukebox/adapters/inbound/admin/api/discs_router.py @@ -0,0 +1,74 @@ +from fastapi import APIRouter, HTTPException, Response, status +from pydantic import ValidationError + +from jukebox.adapters.inbound.admin.api.models import DiscInput, DiscOutput, DiscPatchInput +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc + + +def build_discs_router( + add_disc: AddDisc, + list_discs: ListDiscs, + remove_disc: RemoveDisc, + edit_disc: EditDisc, + get_disc: GetDisc, +) -> APIRouter: + router = APIRouter(prefix="/api/v1", tags=["discs"]) + + @router.get("/discs", response_model=dict[str, DiscOutput], summary="List discs") + def list_discs_route() -> dict[str, Disc]: + return list_discs.execute() + + @router.get("/discs/{tag_id}", response_model=DiscOutput, summary="Get a disc") + def get_disc_route(tag_id: str) -> Disc: + try: + return get_disc.execute(tag_id) + except ValueError as value_err: + raise HTTPException(status_code=404, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.post("/discs/{tag_id}", response_model=DiscOutput, status_code=201, summary="Create a disc") + def create_disc_route(tag_id: str, disc: DiscInput) -> Disc: + try: + new_disc = Disc(**disc.model_dump()) + return add_disc.execute(tag_id, new_disc) + except ValueError as value_err: + raise HTTPException(status_code=409, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.patch("/discs/{tag_id}", response_model=DiscOutput, summary="Update a disc") + def update_disc_route(tag_id: str, disc_patch: DiscPatchInput) -> Disc: + try: + metadata = None + if disc_patch.metadata is not None: + metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True)) + + option = None + if disc_patch.option is not None: + option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True)) + + return edit_disc.execute(tag_id, disc_patch.uri, metadata, option) + except ValidationError as err: + raise HTTPException(status_code=422, detail=err.errors()) + except ValueError as value_err: + raise HTTPException(status_code=404, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.delete("/discs/{tag_id}", status_code=204, summary="Delete a disc") + def remove_disc_route(tag_id: str) -> Response: + try: + remove_disc.execute(tag_id) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except ValueError as value_err: + raise HTTPException(status_code=404, detail=str(value_err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return router diff --git a/jukebox/adapters/inbound/admin/api/models.py b/jukebox/adapters/inbound/admin/api/models.py new file mode 100644 index 00000000..87fd64fc --- /dev/null +++ b/jukebox/adapters/inbound/admin/api/models.py @@ -0,0 +1,48 @@ +from typing import Any + +from pydantic import BaseModel, RootModel + +from jukebox.domain.entities import CurrentTagStatus, Disc + + +class DiscInput(Disc): + pass + + +class DiscOutput(Disc): + pass + + +class DiscPatchMetadataInput(BaseModel): + artist: str | None = None + album: str | None = None + track: str | None = None + playlist: str | None = None + + +class DiscPatchOptionInput(BaseModel): + shuffle: bool | None = None + is_test: bool | None = None + + +class DiscPatchInput(BaseModel): + uri: str | None = None + metadata: DiscPatchMetadataInput | None = None + option: DiscPatchOptionInput | None = None + + +class CurrentTagStatusOutput(CurrentTagStatus): + pass + + +class CurrentTagDiscOutput(BaseModel): + tag_id: str + disc: DiscOutput + + +class SettingsResetInput(BaseModel): + path: str + + +class SettingsPatchInput(RootModel[dict[str, Any]]): + pass diff --git a/jukebox/adapters/inbound/admin/api/settings_router.py b/jukebox/adapters/inbound/admin/api/settings_router.py new file mode 100644 index 00000000..75f958a8 --- /dev/null +++ b/jukebox/adapters/inbound/admin/api/settings_router.py @@ -0,0 +1,46 @@ +from typing import Any, cast + +from fastapi import APIRouter, HTTPException + +from jukebox.adapters.inbound.admin.api.models import SettingsPatchInput, SettingsResetInput +from jukebox.settings.errors import SettingsError +from jukebox.settings.service_protocols import SettingsService +from jukebox.settings.types import JsonObject + + +def build_settings_router(settings_service: SettingsService) -> APIRouter: + router = APIRouter(prefix="/api/v1", tags=["settings"]) + + @router.get("/settings", response_model=dict[str, Any], summary="Get persisted settings") + def get_settings() -> JsonObject: + try: + return settings_service.get_persisted_settings_view() + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.get("/settings/effective", response_model=dict[str, Any], summary="Get effective settings") + def get_effective_settings() -> JsonObject: + try: + return settings_service.get_effective_settings_view() + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.patch("/settings", response_model=dict[str, Any], summary="Patch persisted settings") + def patch_settings(patch: SettingsPatchInput) -> JsonObject: + try: + return settings_service.patch_persisted_settings(cast(JsonObject, patch.root)) + except SettingsError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @router.post("/settings/reset", response_model=dict[str, Any], summary="Reset a persisted setting") + def reset_settings(payload: SettingsResetInput) -> JsonObject: + try: + return settings_service.reset_persisted_value(payload.path) + except SettingsError as err: + raise HTTPException(status_code=400, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return router diff --git a/jukebox/adapters/inbound/admin/api_controller.py b/jukebox/adapters/inbound/admin/api_controller.py new file mode 100644 index 00000000..bdd6d52a --- /dev/null +++ b/jukebox/adapters/inbound/admin/api_controller.py @@ -0,0 +1,188 @@ +from pydantic import BaseModel + +from jukebox.shared.dependency_messages import optional_extra_dependency_message + +try: + from fastapi import FastAPI, HTTPException + + from jukebox.adapters.inbound.admin.api.current_tag_router import build_current_tag_router + from jukebox.adapters.inbound.admin.api.discs_router import build_discs_router + from jukebox.adapters.inbound.admin.api.models import ( + CurrentTagDiscOutput, + CurrentTagStatusOutput, + DiscInput, + DiscOutput, + DiscPatchInput, + SettingsPatchInput, + SettingsResetInput, + ) + from jukebox.adapters.inbound.admin.api.settings_router import build_settings_router +except ModuleNotFoundError as e: + if e.name != "fastapi": + raise + raise ModuleNotFoundError( + optional_extra_dependency_message("The `api_controller` module", "api", "jukebox-admin api") + ) from e +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc +from jukebox.settings.entities import SelectedSonosGroupSettings +from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository +from jukebox.settings.service_protocols import SettingsService +from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError +from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection +from jukebox.sonos.service import SonosService + +__all__ = [ + "APIController", + "CurrentTagDiscOutput", + "CurrentTagStatusOutput", + "DiscInput", + "DiscOutput", + "DiscPatchInput", + "SettingsPatchInput", + "SettingsResetInput", + "SonosSelectionInput", +] + + +class SonosSpeakerOutput(DiscoveredSonosSpeaker): + pass + + +class SelectedSonosGroupOutput(SelectedSonosGroupSettings): + pass + + +class SonosSelectionMemberAvailabilityOutput(BaseModel): + uid: str + status: str + speaker: SonosSpeakerOutput | None = None + + +class SonosSelectionAvailabilityOutput(BaseModel): + status: str + members: list[SonosSelectionMemberAvailabilityOutput] + + +class SonosSelectionOutput(BaseModel): + selected_group: SelectedSonosGroupOutput | None = None + availability: SonosSelectionAvailabilityOutput + + +class SonosSelectionInput(BaseModel): + uids: list[str] + coordinator_uid: str | None = None + + +class SonosSelectionUpdateOutput(BaseModel): + selected_group: SelectedSonosGroupOutput + availability: SonosSelectionAvailabilityOutput + message: str + restart_required: bool + + +class APIController: + def __init__( + self, + add_disc: AddDisc, + list_discs: ListDiscs, + remove_disc: RemoveDisc, + edit_disc: EditDisc, + get_disc: GetDisc, + get_current_tag_status: GetCurrentTagStatus, + settings_service: SettingsService, + sonos_service: SonosService, + ): + self.add_disc = add_disc + self.list_discs = list_discs + self.remove_disc = remove_disc + self.edit_disc = edit_disc + self.get_disc = get_disc + self.get_current_tag_status = get_current_tag_status + self.settings_service = settings_service + self.sonos_service = sonos_service + self.app = FastAPI( + title="Jukebox Admin API", + description="API for managing Jukebox disc library and settings", + docs_url="/docs", + redoc_url="/redoc", + ) + self.register_routes() + + def register_routes(self): + self.app.include_router( + build_discs_router( + add_disc=self.add_disc, + list_discs=self.list_discs, + remove_disc=self.remove_disc, + edit_disc=self.edit_disc, + get_disc=self.get_disc, + ) + ) + self.app.include_router( + build_current_tag_router( + get_current_tag_status=self.get_current_tag_status, + add_disc=self.add_disc, + edit_disc=self.edit_disc, + get_disc=self.get_disc, + remove_disc=self.remove_disc, + ) + ) + self.app.include_router(build_settings_router(self.settings_service)) + + @self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput]) + def get_sonos_speakers(): + try: + return self.sonos_service.list_network_speakers() + except SonosDiscoveryError as err: + raise HTTPException(status_code=502, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @self.app.get("/api/v1/sonos/selection", response_model=SonosSelectionOutput) + def get_sonos_selection(): + try: + return GetSonosSelectionStatus( + SettingsSelectedSonosGroupRepository(self.settings_service), + self.sonos_service, + ).execute() + except SonosDiscoveryError as err: + raise HTTPException(status_code=502, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput) + def put_sonos_selection(payload: SonosSelectionInput): + try: + result = SaveSonosSelection( + SettingsSelectedSonosGroupRepository(self.settings_service), + self.sonos_service, + ).execute(payload.uids, coordinator_uid=payload.coordinator_uid) + return SonosSelectionUpdateOutput( + selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()), + availability=SonosSelectionAvailabilityOutput( + status="available", + members=[ + SonosSelectionMemberAvailabilityOutput( + uid=member.uid, + status="available", + speaker=SonosSpeakerOutput(**member.model_dump()), + ) + for member in result.members + ], + ), + message=result.settings_message, + restart_required=result.restart_required, + ) + except SonosDiscoveryError as err: + raise HTTPException(status_code=502, detail=str(err)) + except ValueError as err: + raise HTTPException(status_code=400, detail=str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") diff --git a/jukebox/adapters/inbound/admin/cli_controller.py b/jukebox/adapters/inbound/admin/cli_controller.py new file mode 100644 index 00000000..b0406279 --- /dev/null +++ b/jukebox/adapters/inbound/admin/cli_controller.py @@ -0,0 +1,126 @@ +import logging + +from jukebox.adapters.inbound.admin.cli_display import display_library_line, display_library_table +from jukebox.admin.library_commands import ( + CliAddCommand, + CliEditCommand, + CliGetCommand, + CliListCommand, + CliRemoveCommand, + CliSearchCommand, +) +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc +from jukebox.domain.use_cases.library.resolve_tag_id import ResolveTagId +from jukebox.domain.use_cases.library.search_discs import SearchDiscs + +LOGGER = logging.getLogger("discstore") + + +class CLIController: + def __init__( + self, + add_disc: AddDisc, + list_discs: ListDiscs, + remove_disc: RemoveDisc, + edit_disc: EditDisc, + get_disc: GetDisc, + search_discs: SearchDiscs, + resolve_tag_id: ResolveTagId, + ): + self.add_disc = add_disc + self.list_discs = list_discs + self.remove_disc = remove_disc + self.edit_disc = edit_disc + self.get_disc = get_disc + self.search_discs = search_discs + self.resolve_tag_id = resolve_tag_id + + def run( + self, + command: CliAddCommand | CliListCommand | CliRemoveCommand | CliEditCommand | CliGetCommand | CliSearchCommand, + ) -> None: + match command: + case CliAddCommand(): + self.add_disc_flow(command) + case CliListCommand(): + self.list_discs_flow(command) + case CliRemoveCommand(): + self.remove_disc_flow(command) + case CliEditCommand(): + self.edit_disc_flow(command) + case CliGetCommand(): + self.get_disc_flow(command) + case CliSearchCommand(): + self.search_discs_flow(command) + case _: + LOGGER.error("Command not implemented yet: command='%s'", command) + + def add_disc_flow(self, command: CliAddCommand) -> None: + tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) + option = DiscOption() + metadata = DiscMetadata(track=command.track, artist=command.artist, album=command.album) + + disc = Disc(uri=command.uri, metadata=metadata, option=option) + self.add_disc.execute(tag, disc) + LOGGER.info("āœ… Disc successfully added") + + def list_discs_flow(self, command: CliListCommand) -> None: + discs = self.list_discs.execute() + if command.mode == "table": + display_library_table(discs) + return + if command.mode == "line": + display_library_line(discs) + return + LOGGER.error("Displaying mode not implemented yet: mode='%s'", command.mode) + + def remove_disc_flow(self, command: CliRemoveCommand) -> None: + tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) + self.remove_disc.execute(tag) + LOGGER.info("šŸ—‘ļø Disc successfully removed") + + def edit_disc_flow(self, command: CliEditCommand) -> None: + metadata_fields = {} + if command.track is not None: + metadata_fields["track"] = command.track + if command.artist is not None: + metadata_fields["artist"] = command.artist + if command.album is not None: + metadata_fields["album"] = command.album + + metadata = DiscMetadata(**metadata_fields) if metadata_fields else None + + self.edit_disc.execute( + tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag), + uri=command.uri, + metadata=metadata, + option=None, + ) + LOGGER.info("āœ… Disc successfully edited") + + def get_disc_flow(self, command: CliGetCommand) -> None: + try: + tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) + disc = self.get_disc.execute(tag) + print(f"\nšŸ“€ Disc: {tag}") + print(f" URI : {disc.uri}") + print(f" Artist : {disc.metadata.artist or '/'}") + print(f" Album : {disc.metadata.album or '/'}") + print(f" Track : {disc.metadata.track or '/'}") + print(f" Playlist : {disc.metadata.playlist or '/'}") + print(f" Shuffle : {disc.option.shuffle}") + except ValueError as e: + LOGGER.error(str(e)) + + def search_discs_flow(self, command: CliSearchCommand) -> None: + results = self.search_discs.execute(command.query) + if not results: + LOGGER.info("No discs found matching '%s'", command.query) + return + LOGGER.info("Found %d disc(s) matching '%s':", len(results), command.query) + display_library_table(results) diff --git a/jukebox/adapters/inbound/admin/cli_display.py b/jukebox/adapters/inbound/admin/cli_display.py new file mode 100644 index 00000000..c7447089 --- /dev/null +++ b/jukebox/adapters/inbound/admin/cli_display.py @@ -0,0 +1,58 @@ +from jukebox.domain.entities import Disc + +MAX_COL_WIDTH = 20 + + +def display_library_line(discs: dict[str, Disc]) -> None: + if not discs: + print("The library is empty") + return + + print("=== Discs Library ===\n") + for disc_id, disc in discs.items(): + print(f"ID : {disc_id}") + print(f" URI : {disc.uri}") + print(f" Artist : {disc.metadata.artist or '/'}") + print(f" Album : {disc.metadata.album or '/'}") + print(f" Track : {disc.metadata.track or '/'}") + print(f" Playlist : {disc.metadata.playlist or '/'}") + print(f" Shuffle : {disc.option.shuffle}") + print("-" * 30) + + +def truncate(text: str, max_length: int) -> str: + if len(text) <= max_length: + return text + return text[: max_length - 3] + "..." + + +def display_library_table(discs: dict[str, Disc]) -> None: + if not discs: + print("The library is empty") + return + + headers = ["ID", "URI", "Artist", "Album", "Track", "Playlist", "Shuffle"] + rows = [] + for disc_id, disc in discs.items(): + rows.append( + [ + truncate(str(disc_id), MAX_COL_WIDTH), + truncate(disc.uri, MAX_COL_WIDTH), + truncate(disc.metadata.artist or "/", MAX_COL_WIDTH), + truncate(disc.metadata.album or "/", MAX_COL_WIDTH), + truncate(disc.metadata.track or "/", MAX_COL_WIDTH), + truncate(disc.metadata.playlist or "/", MAX_COL_WIDTH), + str(disc.option.shuffle), + ] + ) + + cols = list(zip(*([headers] + rows))) + col_widths = [max(len(str(item)) for item in col) for col in cols] + + def format_line(line): + return " | ".join(f"{str(item):<{col_widths[i]}}" for i, item in enumerate(line)) + + print(format_line(headers)) + print("-+-".join("-" * w for w in col_widths)) + for row in rows: + print(format_line(row)) diff --git a/jukebox/adapters/inbound/admin/interactive_cli_controller.py b/jukebox/adapters/inbound/admin/interactive_cli_controller.py new file mode 100644 index 00000000..1af699c1 --- /dev/null +++ b/jukebox/adapters/inbound/admin/interactive_cli_controller.py @@ -0,0 +1,132 @@ +import logging + +from jukebox.adapters.inbound.admin.cli_display import display_library_line, display_library_table +from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc + +LOGGER = logging.getLogger("discstore") + + +class InteractiveCLIController: + available_commands = "\n* " + "\n* ".join(["add", "remove", "list", "edit", "current", "exit", "help"]) + help_message = f"\nAvailable commands: {available_commands}" + + def __init__( + self, + add_disc: AddDisc, + list_discs: ListDiscs, + remove_disc: RemoveDisc, + edit_disc: EditDisc, + get_current_tag_status: GetCurrentTagStatus, + ): + self.add_disc = add_disc + self.list_discs = list_discs + self.remove_disc = remove_disc + self.edit_disc = edit_disc + self.get_current_tag_status = get_current_tag_status + + def run(self) -> None: + print(self.help_message) + while True: + command = input("discstore> ") + self.handle_command(command) + + def handle_command(self, command: str) -> None: + try: + match command: + case "add": + self.add_disc_flow() + case "remove": + self.remove_disc_flow() + case "list": + self.list_discs_flow() + case "edit": + self.edit_disc_flow() + case "current": + self.current_tag_flow() + case "exit": + print("See you soon!") + exit(0) + case "help": + print(self.help_message) + case _: + print(f"Invalid command `{command}`") + print(self.help_message) + except Exception as err: + print(f"Error: {err}") + LOGGER.error("Error during handling command: %s", err) + + def add_disc_flow(self) -> None: + print("\n-- Add a disc --") + current_tag_status = self.get_current_tag_status.execute() + tag = self._prompt_for_tag(current_tag_status, action="add") + uri = input("discstore> add uri> ").strip() + option = DiscOption() + metadata = DiscMetadata() + + disc = Disc(uri=uri, metadata=metadata, option=option) + self.add_disc.execute(tag, disc) + print("āœ… Disc successfully added") + + def list_discs_flow(self) -> None: + print("\n-- List all discs --") + mode = input("discstore> list mode(table/line)> ").strip() + + discs = self.list_discs.execute() + if mode == "table" or mode == "": + display_library_table(discs) + return + if mode == "line": + display_library_line(discs) + return + print(f"Displaying mode not implemented yet: `{mode}`") + + def remove_disc_flow(self) -> None: + print("\n-- Remove a disc --") + tag = input("discstore> remove tag> ").strip() + self.remove_disc.execute(tag) + print("šŸ—‘ļø Disc successfully removed") + + def edit_disc_flow(self) -> None: + print("\n-- Edit a disc --") + current_tag_status = self.get_current_tag_status.execute() + tag = self._prompt_for_tag(current_tag_status, action="edit") + uri = input("discstore> edit uri> ").strip() + option = DiscOption() + metadata = DiscMetadata() + + self.edit_disc.execute(tag, uri, metadata, option) + print("āœ… Disc successfully edited") + + def current_tag_flow(self) -> None: + print("\n-- Current tag --") + current_tag_status = self.get_current_tag_status.execute() + if current_tag_status is None: + print("No current tag is available") + return + + print(f"Tag ID : {current_tag_status.tag_id}") + print(f"Known in library : {'yes' if current_tag_status.known_in_library else 'no'}") + + def _prompt_for_tag(self, current_tag_status: CurrentTagStatus | None, action: str) -> str: + default_tag = "" + if current_tag_status is not None and ( + (action == "add" and not current_tag_status.known_in_library) + or (action == "edit" and current_tag_status.known_in_library) + ): + default_tag = current_tag_status.tag_id + prompt = f"discstore> {action} tag" + if default_tag: + prompt += f" [{default_tag}]" + prompt += "> " + + entered_tag = input(prompt).strip() + tag = entered_tag or default_tag + if not tag: + raise ValueError("A tag ID is required.") + + return tag diff --git a/jukebox/adapters/inbound/admin/ui_controller.py b/jukebox/adapters/inbound/admin/ui_controller.py new file mode 100644 index 00000000..9c79020b --- /dev/null +++ b/jukebox/adapters/inbound/admin/ui_controller.py @@ -0,0 +1,505 @@ +from collections.abc import AsyncIterator +from typing import Annotated + +from jukebox.shared.dependency_messages import optional_extra_dependency_message + +try: + from fastapi import HTTPException, Request + from fastapi.responses import HTMLResponse, StreamingResponse + from fastui import AnyComponent, FastUI, prebuilt_html + from fastui import components as c + from fastui.events import GoToEvent + from fastui.forms import fastui_form +except ModuleNotFoundError as e: + raise ModuleNotFoundError( + optional_extra_dependency_message("The `ui_controller` module", "ui", "jukebox-admin ui") + ) from e + +from pydantic import BaseModel, Field + +from jukebox.adapters.inbound.admin.api_controller import APIController +from jukebox.adapters.inbound.admin.ui_pages.library import DiscForm, DiscTable, LibraryUIPageBuilder +from jukebox.adapters.inbound.admin.ui_pages.settings import SettingsUIPageBuilder +from jukebox.adapters.inbound.admin.ui_pages.sonos import SonosSelectionForm, SonosUIPageBuilder +from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc +from jukebox.settings.definitions import ( + EditableSettingDisplay, + get_setting_definition, +) +from jukebox.settings.errors import SettingsError +from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository +from jukebox.settings.service_protocols import SettingsService +from jukebox.settings.types import JsonObject, JsonValue +from jukebox.sonos.discovery import SonosDiscoveryError +from jukebox.sonos.selection import SaveSonosSelection +from jukebox.sonos.service import SonosService + + +class SettingValueForm(BaseModel): + value: str = Field(title="Value") + + +class UIController(APIController): + def __init__( + self, + add_disc: AddDisc, + list_discs: ListDiscs, + remove_disc: RemoveDisc, + edit_disc: EditDisc, + get_disc: GetDisc, + get_current_tag_status: GetCurrentTagStatus, + settings_service: SettingsService, + sonos_service: SonosService, + ): + self.get_disc = get_disc + self.library_pages = LibraryUIPageBuilder( + list_discs=list_discs, + get_disc=get_disc, + get_current_tag_status=get_current_tag_status, + ) + self.sonos_pages = SonosUIPageBuilder(settings_service=settings_service, sonos_service=sonos_service) + self.settings_pages = SettingsUIPageBuilder(settings_service=settings_service) + super().__init__( + add_disc, + list_discs, + remove_disc, + edit_disc, + get_disc, + get_current_tag_status, + settings_service, + sonos_service, + ) + + def register_routes(self): + super().register_routes() + + @self.app.get("/api/ui/", response_model=FastUI, response_model_exclude_none=True) + def list_discs(toast: str | None = None) -> list[AnyComponent]: + return self._build_index_page_components(toast=toast) + + @self.app.get("/api/ui/current-tag-banner/events") + async def get_current_tag_banner_events(request: Request) -> StreamingResponse: + return StreamingResponse( + self._current_tag_banner_event_stream(request), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + @self.app.get("/api/ui/discs/new", response_model=FastUI, response_model_exclude_none=True) + def new_disc_form(prefill: str | None = None) -> list[AnyComponent]: + return self._build_form_page_components( + title="Add disc", + form_components=self._build_new_disc_form_components(prefill_current=(prefill == "current")), + ) + + @self.app.post("/api/ui/discs", response_model=FastUI, response_model_exclude_none=True) + async def create_disc(disc: Annotated[DiscForm, fastui_form(DiscForm)]) -> list[AnyComponent]: + metadata = DiscMetadata( + artist=disc.artist, + album=disc.album, + track=disc.track, + ) + option = DiscOption(shuffle=disc.shuffle) + + try: + self.add_disc.execute(disc.tag, Disc(uri=disc.uri, metadata=metadata, option=option)) + except ValueError as err: + raise self._field_validation_error("tag", str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self._build_success_response("toast-add-disc-success") + + @self.app.get("/api/ui/discs/{tag_id}/edit", response_model=FastUI, response_model_exclude_none=True) + def edit_disc_form(tag_id: str) -> list[AnyComponent]: + return self._build_form_page_components( + title=f"Edit disc {tag_id}", + form_components=self._build_edit_disc_form_components(tag_id), + ) + + @self.app.post("/api/ui/discs/{tag_id}", response_model=FastUI, response_model_exclude_none=True) + async def update_disc( + tag_id: str, + disc: Annotated[DiscForm, fastui_form(DiscForm)], + ) -> list[AnyComponent]: + metadata = DiscMetadata( + artist=disc.artist, + album=disc.album, + track=disc.track, + ) + option = DiscOption(shuffle=disc.shuffle) + + try: + if disc.tag != tag_id: + raise HTTPException( + status_code=422, + detail={ + "form": [ + { + "loc": ["tag"], + "msg": "Editing tag IDs is not supported.", + } + ] + }, + ) + self.edit_disc.execute(tag_id=tag_id, uri=disc.uri, metadata=metadata, option=option) + except ValueError as err: + raise self._field_validation_error("tag", str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self._build_success_response("toast-edit-disc-success") + + @self.app.get("/api/ui/discs/{tag_id}/delete", response_model=FastUI, response_model_exclude_none=True) + def delete_disc_confirmation(tag_id: str) -> list[AnyComponent]: + return self._build_form_page_components( + title=f"Delete disc {tag_id}", + form_components=self._build_delete_disc_form_components(tag_id), + ) + + # Fast-UI buttons and forms do not support the DELETE method directly. So we cannot call DELETE on + # /api/ui/discs/{tag_id}. Instead, we just use POST on /api/ui/discs/{tag_id}/delete. + @self.app.post("/api/ui/discs/{tag_id}/delete", response_model=FastUI, response_model_exclude_none=True) + async def delete_disc(tag_id: str) -> list[AnyComponent]: + try: + self.remove_disc.execute(tag_id) + except ValueError as err: + raise HTTPException(status_code=404, detail=str(err)) + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self._build_success_response("toast-remove-disc-success") + + @self.app.get("/api/ui/settings", response_model=FastUI, response_model_exclude_none=True) + def settings_page(toast: str | None = None, toast_message: str | None = None) -> list[AnyComponent]: + return self._build_settings_page_components(toast=toast, toast_message=toast_message) + + @self.app.get("/api/ui/settings/{setting_path}/edit", response_model=FastUI, response_model_exclude_none=True) + def edit_setting_form(setting_path: str) -> list[AnyComponent]: + return self._build_settings_edit_page_components(setting_path) + + @self.app.post("/api/ui/settings/{setting_path}", response_model=FastUI, response_model_exclude_none=True) + async def update_setting( + setting_path: str, + form: Annotated[SettingValueForm, fastui_form(SettingValueForm)], + ) -> list[AnyComponent]: + definition = get_setting_definition(setting_path) + if definition is None: + raise HTTPException(status_code=404, detail=f"Unknown setting path: {setting_path}") + + try: + patch = self._build_settings_patch(setting_path, form.value) + result = self.settings_service.patch_persisted_settings(patch) + except ValueError as err: + raise self._field_validation_error("value", str(err)) + except SettingsError as err: + if self._persisted_value_matches(setting_path, self._lookup_optional_dotted_path(patch, setting_path)): + return self._build_settings_success_response( + "Settings saved, but effective settings are still unavailable." + ) + raise self._field_validation_error("value", str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self._build_settings_success_response(str(result["message"])) + + @self.app.post("/api/ui/settings/{setting_path}/reset", response_model=FastUI, response_model_exclude_none=True) + async def reset_setting(setting_path: str) -> list[AnyComponent]: + return self._reset_setting(setting_path) + + @self.app.get("/api/ui/sonos", response_model=FastUI, response_model_exclude_none=True) + def sonos_page(toast: str | None = None, toast_message: str | None = None) -> list[AnyComponent]: + return self._build_sonos_page_components(toast=toast, toast_message=toast_message) + + @self.app.get("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True) + def edit_sonos_form( + error_message: str | None = None, + uids: list[str] | None = None, + coordinator_uid: str | None = None, + ) -> list[AnyComponent]: + field_errors = None + if error_message: + field_errors = {self._sonos_field_name_for_error(error_message): error_message} + return self._build_sonos_edit_page_components( + error_message=error_message, + field_errors=field_errors, + submitted_uids=uids, + submitted_coordinator_uid=coordinator_uid, + ) + + @self.app.post("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True) + async def update_sonos_selection( + form: Annotated[SonosSelectionForm, fastui_form(SonosSelectionForm)], + ) -> list[AnyComponent]: + try: + result = SaveSonosSelection( + selected_group_repository=SettingsSelectedSonosGroupRepository(self.settings_service), + sonos_service=self.sonos_service, + ).execute(form.uids, coordinator_uid=form.coordinator_uid) + except SonosDiscoveryError as err: + raise HTTPException(status_code=502, detail=str(err)) + except SettingsError as err: + display_message = self._build_sonos_error_message(str(err), form.coordinator_uid) + if self._persisted_sonos_selection_matches(form.uids, form.coordinator_uid): + return self.sonos_pages.build_sonos_success_response( + "Sonos selection saved, but effective settings are still unavailable." + ) + return self.sonos_pages.build_sonos_edit_error_response( + display_message, + form.uids, + form.coordinator_uid, + ) + except ValueError as err: + return self.sonos_pages.build_sonos_edit_error_response( + self._build_sonos_error_message(str(err), form.coordinator_uid), + form.uids, + form.coordinator_uid, + ) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self.sonos_pages.build_sonos_success_response(str(result.settings_message)) + + @self.app.post("/api/ui/sonos/reset", response_model=FastUI, response_model_exclude_none=True) + async def reset_sonos_selection() -> list[AnyComponent]: + return self._reset_sonos_selection() + + @self.app.get("/{path:path}") + def html_landing(path: str) -> HTMLResponse: + del path + return HTMLResponse(prebuilt_html(title="Jukebox Admin", api_root_url="/api/ui")) + + def _build_success_response(self, toast_event_name: str) -> list[AnyComponent]: + return [ + c.FireEvent(event=GoToEvent(url=f"/?toast={toast_event_name}")), + ] + + def _build_settings_success_response(self, message: str) -> list[AnyComponent]: + return self.settings_pages.build_settings_success_response(message) + + def _reset_setting(self, setting_path: str) -> list[AnyComponent]: + return self.settings_pages.reset_setting(setting_path) + + def _build_sonos_page_components( + self, + toast: str | None = None, + toast_message: str | None = None, + error_message: str | None = None, + ) -> list[AnyComponent]: + return self.sonos_pages.build_sonos_page_components( + toast=toast, + toast_message=toast_message, + error_message=error_message, + ) + + def _build_sonos_edit_page_components( + self, + error_message: str | None = None, + field_errors: dict[str, str] | None = None, + submitted_uids: list[str] | None = None, + submitted_coordinator_uid: str | None = None, + ) -> list[AnyComponent]: + return self.sonos_pages.build_sonos_edit_page_components( + error_message=error_message, + field_errors=field_errors, + submitted_uids=submitted_uids, + submitted_coordinator_uid=submitted_coordinator_uid, + ) + + def _reset_sonos_selection(self) -> list[AnyComponent]: + selected_group_repository = SettingsSelectedSonosGroupRepository(self.settings_service) + try: + result = self.settings_service.reset_persisted_value("jukebox.player.sonos.selected_group") + except SettingsError as err: + if selected_group_repository.get_selected_group() is None: + return self.sonos_pages.build_sonos_success_response( + "Sonos selection cleared, but effective settings are still unavailable." + ) + return self._build_sonos_page_components(error_message=str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self.sonos_pages.build_sonos_success_response(str(result.get("message", "Settings saved."))) + + def _persisted_sonos_selection_matches(self, uids: list[str], coordinator_uid: str | None) -> bool: + try: + selected_group = SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group() + except Exception: + return False + + if selected_group is None: + return False + + expected_coordinator_uid = coordinator_uid or (uids[0] if uids else None) + return ( + selected_group.coordinator_uid == expected_coordinator_uid + and [member.uid for member in selected_group.members] == uids + ) + + def _build_sonos_error_message(self, message: str, coordinator_uid: str | None) -> str: + prefix = "Selected Sonos coordinator must be one of the selected speakers: " + if coordinator_uid is None or not message.startswith(prefix): + return message + + try: + speakers = self.sonos_service.list_network_speakers() + except Exception: + return message + + speaker = next((speaker for speaker in speakers if speaker.uid == coordinator_uid), None) + if speaker is None: + return message + + return f"{prefix}{speaker.name} [{speaker.uid}]" + + def _build_index_page_components(self, toast: str | None = None) -> list[AnyComponent]: + return self.library_pages.build_index_page_components(toast=toast) + + def _build_settings_page_components( + self, + toast: str | None = None, + toast_message: str | None = None, + ) -> list[AnyComponent]: + return self.settings_pages.build_settings_page_components(toast=toast, toast_message=toast_message) + + def _build_settings_section_components( + self, + section: str, + settings: list[EditableSettingDisplay], + ) -> list[AnyComponent]: + return self.settings_pages.build_settings_section_components(section, settings) + + def _build_settings_row(self, setting: EditableSettingDisplay, index: int) -> AnyComponent: + return self.settings_pages.build_settings_row(setting, index) + + def _build_settings_edit_page_components( + self, + setting_path: str, + reset_error: str | None = None, + ) -> list[AnyComponent]: + return self.settings_pages.build_settings_edit_page_components(setting_path, reset_error=reset_error) + + def _build_settings_edit_form(self, setting: EditableSettingDisplay) -> AnyComponent: + return self.settings_pages.build_settings_edit_form(setting) + + def _build_settings_reset_form(self, setting_path: str) -> AnyComponent: + return self.settings_pages.build_settings_reset_form(setting_path) + + def _get_settings_displays(self) -> tuple[list[EditableSettingDisplay], str | None]: + return self.settings_pages.get_settings_displays() + + def _build_settings_badges(self, setting: EditableSettingDisplay) -> list[AnyComponent]: + return self.settings_pages.build_settings_badges(setting) + + def _build_settings_value_summary(self, setting: EditableSettingDisplay) -> AnyComponent: + return self.settings_pages.build_settings_value_summary(setting) + + def _build_settings_value_cell(self, label: str, value: str) -> AnyComponent: + return self.settings_pages.build_settings_value_cell(label, value) + + def _build_settings_edit_guidance(self, setting: EditableSettingDisplay) -> str: + return self.settings_pages.build_settings_edit_guidance(setting) + + def _build_settings_patch(self, setting_path: str, raw_value: str) -> JsonObject: + return self.settings_pages.build_settings_patch(setting_path, raw_value) + + def _build_dotted_patch(self, dotted_path: str, value: JsonValue) -> JsonObject: + return self.settings_pages.build_dotted_patch(dotted_path, value) + + def _persisted_value_matches(self, dotted_path: str, expected_value: object) -> bool: + return self.settings_pages.persisted_value_matches(dotted_path, expected_value) + + def _has_persisted_value(self, dotted_path: str) -> bool: + return self.settings_pages.has_persisted_value(dotted_path) + + def _lookup_optional_dotted_path(self, root: JsonObject, dotted_path: str) -> object: + return self.settings_pages.lookup_optional_dotted_path(root, dotted_path) + + def _format_settings_display_value(self, setting_path: str, value: object) -> str: + return self.settings_pages.format_settings_display_value(setting_path, value) + + def _format_settings_provenance(self, provenance: str) -> str: + return self.settings_pages.format_settings_provenance(provenance) + + def _build_form_page_components(self, title: str, form_components: list[AnyComponent]) -> list[AnyComponent]: + return self.library_pages.build_form_page_components(title=title, form_components=form_components) + + def _build_current_tag_banner_components(self, current_tag_status: CurrentTagStatus | None) -> list[AnyComponent]: + return self.library_pages.build_current_tag_banner_components(current_tag_status) + + def _build_disc_library_components(self, discs: list[DiscTable]) -> list[AnyComponent]: + return self.library_pages.build_disc_library_components(discs) + + def _build_disc_library_header(self) -> AnyComponent: + return self.library_pages._build_disc_library_header() + + def _build_disc_library_row(self, disc: DiscTable) -> AnyComponent: + return self.library_pages._build_disc_library_row(disc) + + def _build_disc_header_cell(self, label: str, class_name: str) -> AnyComponent: + return self.library_pages._build_disc_header_cell(label, class_name) + + def _build_disc_value_cell(self, label: str, value: str | None, class_name: str) -> AnyComponent: + return self.library_pages._build_disc_value_cell(label, value, class_name) + + def _build_new_disc_form_components(self, prefill_current: bool) -> list[AnyComponent]: + return self.library_pages.build_new_disc_form_components(prefill_current) + + def _build_edit_disc_form_components(self, tag_id: str) -> list[AnyComponent]: + return self.library_pages.build_edit_disc_form_components(tag_id) + + def _build_delete_disc_form_components(self, tag_id: str) -> list[AnyComponent]: + return self.library_pages.build_delete_disc_form_components(tag_id) + + async def _current_tag_banner_event_stream( + self, + request: Request, + poll_interval_seconds: float = 0.5, + ) -> AsyncIterator[bytes]: + async for payload in self.library_pages.current_tag_banner_event_stream(request, poll_interval_seconds): + yield payload + + def _serialize_current_tag_components(self, components: list[AnyComponent]) -> str: + return self.library_pages.serialize_current_tag_components(components) + + @staticmethod + def _sonos_field_name_for_error(message: str) -> str: + if "coordinator" in message: + return "coordinator_uid" + return "uids" + + def _field_validation_error(self, field_name: str, message: str) -> HTTPException: + return HTTPException( + status_code=422, + detail={ + "form": [ + { + "loc": [field_name], + "msg": message, + } + ] + }, + ) + + +c.Page.model_rebuild() diff --git a/jukebox/adapters/inbound/admin/ui_pages/__init__.py b/jukebox/adapters/inbound/admin/ui_pages/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/jukebox/adapters/inbound/admin/ui_pages/library.py b/jukebox/adapters/inbound/admin/ui_pages/library.py new file mode 100644 index 00000000..c562e1d8 --- /dev/null +++ b/jukebox/adapters/inbound/admin/ui_pages/library.py @@ -0,0 +1,375 @@ +import asyncio +import json +from collections.abc import AsyncIterator + +from fastapi import Request +from fastui import AnyComponent +from fastui import components as c +from fastui.events import BackEvent, GoToEvent, PageEvent +from pydantic import BaseModel, Field + +from jukebox.domain.entities import CurrentTagStatus, DiscMetadata, DiscOption +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs + + +class DiscTable(DiscMetadata, DiscOption): + tag: str = Field(title="Tag ID") + uri: str = Field(title="URI / Path") + + +class DiscForm(BaseModel): + tag: str = Field(title="Tag ID") + uri: str = Field(title="URI / Path") + artist: str | None = Field(None, title="Artist") + album: str | None = Field(None, title="Album") + track: str | None = Field(None, title="Track") + shuffle: bool = Field(False, title="Shuffle") + + +class LibraryUIPageBuilder: + def __init__( + self, + list_discs: ListDiscs, + get_disc: GetDisc, + get_current_tag_status: GetCurrentTagStatus, + ): + self.list_discs = list_discs + self.get_disc = get_disc + self.get_current_tag_status = get_current_tag_status + + def build_index_page_components(self, toast: str | None = None) -> list[AnyComponent]: + discs = self.list_discs.execute() + discs_list = [ + DiscTable(tag=tag, uri=disc.uri, **disc.metadata.model_dump(), **disc.option.model_dump()) + for tag, disc in discs.items() + ] + + components: list[AnyComponent] = [ + c.Heading(text="Jukebox Admin", level=1), + c.Paragraph(text=f"šŸ“€ {len(discs)} disc(s) in library"), + c.ServerLoad( + path="/current-tag-banner/events", + sse=True, + sse_retry=2000, + ), + c.Div( + class_name="d-flex flex-wrap gap-2", + components=[ + c.Button(text="āž• Add a new disc", on_click=GoToEvent(url="/discs/new")), + c.Button( + text="šŸ”Š Sonos Speakers", + on_click=GoToEvent(url="/sonos"), + class_name="btn btn-secondary", + ), + c.Button(text="āš™ļø Settings", on_click=GoToEvent(url="/settings"), class_name="btn btn-secondary"), + ], + ), + c.Toast( + title="Toast", + body=[c.Paragraph(text="šŸŽ‰ Disc added")], + open_trigger=PageEvent(name="toast-add-disc-success"), + position="bottom-end", + ), + c.Toast( + title="Toast", + body=[c.Paragraph(text="šŸŽ‰ Disc edited")], + open_trigger=PageEvent(name="toast-edit-disc-success"), + position="bottom-end", + ), + c.Toast( + title="Toast", + body=[c.Paragraph(text="šŸ—‘ļø Disc removed")], + open_trigger=PageEvent(name="toast-remove-disc-success"), + position="bottom-end", + ), + *self.build_disc_library_components(discs_list), + ] + + page_components: list[AnyComponent] = [c.Page(components=components)] + + if toast in {"toast-add-disc-success", "toast-edit-disc-success", "toast-remove-disc-success"}: + page_components.append(c.FireEvent(event=PageEvent(name=toast))) + + return page_components + + def build_form_page_components(self, title: str, form_components: list[AnyComponent]) -> list[AnyComponent]: + return [ + c.Page( + components=[ + c.Heading(text=title, level=1), + *form_components, + c.Div( + class_name="mt-3", + components=[ + c.Link( + components=[c.Text(text="Back to Library")], + on_click=GoToEvent(url="/"), + ) + ], + ), + ] + ) + ] + + def build_current_tag_banner_components( + self, + current_tag_status: CurrentTagStatus | None, + ) -> list[AnyComponent]: + if current_tag_status is None: + return [] + + if current_tag_status.known_in_library: + return [ + c.Div( + class_name="alert alert-info mb-3 d-flex flex-column flex-md-row gap-3 justify-content-between align-items-md-center", + components=[ + c.Div( + class_name="mb-0", + components=[ + c.Heading(text="Known disc on reader", level=4), + c.Paragraph(text=f'Tag "{current_tag_status.tag_id}" is already in the library.'), + ], + ), + c.Button( + text="Edit this disc", + on_click=GoToEvent(url=f"/discs/{current_tag_status.tag_id}/edit"), + ), + ], + ) + ] + + return [ + c.Div( + class_name="alert alert-warning mb-3 d-flex flex-column flex-md-row gap-3 justify-content-between align-items-md-center", + components=[ + c.Div( + class_name="mb-0", + components=[ + c.Heading(text="Unknown disc on reader", level=4), + c.Paragraph(text=f'Tag "{current_tag_status.tag_id}" is ready to be added to the library.'), + ], + ), + c.Button(text="Add this disc", on_click=GoToEvent(url="/discs/new?prefill=current")), + ], + ) + ] + + def build_disc_library_components(self, discs: list[DiscTable]) -> list[AnyComponent]: + if not discs: + return [c.Paragraph(text="No disc found")] + + return [ + c.Div( + class_name="border rounded mt-3 mb-5 overflow-hidden", + components=[ + self._build_disc_library_header(), + *[self._build_disc_library_row(disc) for disc in discs], + ], + ) + ] + + def build_new_disc_form_components(self, prefill_current: bool) -> list[AnyComponent]: + initial = None + + if prefill_current: + current_tag_status = self.get_current_tag_status.execute() + if current_tag_status is None: + return [ + c.Error( + title="No current tag available", + description="There is no tag on the reader right now, so the form cannot be prefilled.", + ) + ] + if current_tag_status.known_in_library: + return [ + c.Error( + title="Current tag already known", + description=f'Tag "{current_tag_status.tag_id}" is already in the library.', + ) + ] + initial = {"tag": current_tag_status.tag_id, "shuffle": False} + + return [ + c.ModelForm( + model=DiscForm, + submit_url="/api/ui/discs", + method="POST", + initial=initial, + ) + ] + + def build_edit_disc_form_components(self, tag_id: str) -> list[AnyComponent]: + if not tag_id: + return [ + c.Error( + title="No disc selected", + description="Edit mode requires an existing disc tag ID.", + ) + ] + try: + disc = self.get_disc.execute(tag_id) + except ValueError as err: + return [ + c.Error( + title="Disc not found", + description=str(err), + ) + ] + + return [ + c.ModelForm( + model=DiscForm, + submit_url=f"/api/ui/discs/{tag_id}", + method="POST", + initial={ + "tag": tag_id, + "uri": disc.uri, + "artist": disc.metadata.artist, + "album": disc.metadata.album, + "track": disc.metadata.track, + "shuffle": disc.option.shuffle, + }, + ), + c.Button( + text="šŸ—‘ļø Delete this disc", + on_click=GoToEvent(url=f"/discs/{tag_id}/delete"), + class_name="btn btn-danger mt-3", + ), + ] + + def build_delete_disc_form_components(self, tag_id: str) -> list[AnyComponent]: + if not tag_id: + return [c.Error(title="No disc selected", description="Delete mode requires an existing disc tag ID.")] + try: + _ = self.get_disc.execute(tag_id) + except ValueError as err: + return [c.Error(title="Disc not found", description=str(err))] + + return [ + c.Paragraph(text=f'Are you sure you want to delete the disc with tag "{tag_id}"?'), + c.Div( + class_name="alert alert-danger", + components=[c.Paragraph(text="This action cannot be undone.")], + ), + c.Div( + class_name="d-flex gap-2 mt-3", + components=[ + c.Form( + form_fields=[], + submit_url=f"/api/ui/discs/{tag_id}/delete", + method="POST", + ), + c.Button( + text="Cancel", + on_click=BackEvent(), + class_name="btn btn-secondary", + ), + ], + ), + ] + + async def current_tag_banner_event_stream( + self, + request: Request, + poll_interval_seconds: float = 0.5, + ) -> AsyncIterator[bytes]: + previous_payload: str | None = None + + while True: + payload = self.serialize_current_tag_components( + self.build_current_tag_banner_components(self.get_current_tag_status.execute()) + ) + if payload != previous_payload: + previous_payload = payload + yield f"data: {payload}\n\n".encode() + + if await request.is_disconnected(): + break + + await asyncio.sleep(poll_interval_seconds) + + @staticmethod + def serialize_current_tag_components(components: list[AnyComponent]) -> str: + return json.dumps([component.model_dump(by_alias=True, exclude_none=True) for component in components]) + + def _build_disc_library_header(self) -> AnyComponent: + return c.Div( + class_name="d-none d-lg-block px-3 py-2 bg-light-subtle", + components=[ + c.Div( + class_name="row g-2 align-items-center", + components=[ + self._build_disc_header_cell("Tag ID", "col-lg"), + self._build_disc_header_cell("URI / Path", "col-lg-3"), + self._build_disc_header_cell("Artist", "col-lg-2 text-lg-center"), + self._build_disc_header_cell("Album", "col-lg-2 text-lg-center"), + self._build_disc_header_cell("Track", "col-lg-2 text-lg-center"), + self._build_disc_header_cell("Shuffle", "col-lg-1 text-lg-center"), + c.Div( + class_name="col-lg-auto d-flex justify-content-lg-end", + components=[ + c.Button( + text="Edit āœļø", + class_name="btn btn-secondary invisible", + ) + ], + ), + ], + ) + ], + ) + + def _build_disc_library_row(self, disc: DiscTable) -> AnyComponent: + return c.Div( + class_name="px-3 py-2 border-top", + components=[ + c.Div( + class_name="row g-2 align-items-center", + components=[ + self._build_disc_value_cell("Tag ID", disc.tag, "col-12 col-lg"), + self._build_disc_value_cell("URI / Path", disc.uri, "col-12 col-lg-3"), + self._build_disc_value_cell("Artist", disc.artist, "col-6 col-md-3 col-lg-2 text-lg-center"), + self._build_disc_value_cell("Album", disc.album, "col-6 col-md-3 col-lg-2 text-lg-center"), + self._build_disc_value_cell("Track", disc.track, "col-6 col-md-3 col-lg-2 text-lg-center"), + self._build_disc_value_cell( + "Shuffle", "āœ“" if disc.shuffle else "Ɨ", "col-6 col-md-3 col-lg-1 text-lg-center" + ), + c.Div( + class_name="col-12 col-lg-auto d-flex justify-content-lg-end", + components=[ + c.Button( + text="Edit āœļø", + on_click=GoToEvent(url=f"/discs/{disc.tag}/edit"), + class_name="btn btn-secondary", + ), + ], + ), + ], + ) + ], + ) + + def _build_disc_header_cell(self, label: str, class_name: str) -> AnyComponent: + justify_class = "justify-content-lg-start" + if "text-lg-center" in class_name: + justify_class = "justify-content-lg-center" + elif "text-lg-end" in class_name: + justify_class = "justify-content-lg-end" + + return c.Div( + class_name=f"{class_name} d-flex align-items-center {justify_class}", + components=[ + c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-0"), + ], + ) + + def _build_disc_value_cell(self, label: str, value: str | None, class_name: str) -> AnyComponent: + return c.Div( + class_name=class_name, + components=[ + c.Paragraph(text=label, class_name="d-lg-none text-uppercase text-muted small fw-semibold mb-1"), + c.Paragraph(text=value or "—", class_name="mb-0 text-break"), + ], + ) diff --git a/jukebox/adapters/inbound/admin/ui_pages/settings.py b/jukebox/adapters/inbound/admin/ui_pages/settings.py new file mode 100644 index 00000000..763a16c2 --- /dev/null +++ b/jukebox/adapters/inbound/admin/ui_pages/settings.py @@ -0,0 +1,525 @@ +import json +from itertools import groupby +from typing import cast +from urllib.parse import urlencode + +from fastapi import HTTPException +from fastui import AnyComponent +from fastui import components as c +from fastui.components.forms import FormFieldInput, FormFieldSelect, FormFieldTextarea +from fastui.events import GoToEvent, PageEvent +from fastui.forms import SelectOption + +from jukebox.settings.definitions import ( + EditableSettingDisplay, + build_editable_setting_displays, + get_setting_definition, +) +from jukebox.settings.errors import SettingsError +from jukebox.settings.service_protocols import SettingsService +from jukebox.settings.types import JsonObject, JsonValue + +_MISSING = object() + + +class SettingsUIPageBuilder: + def __init__(self, settings_service: SettingsService): + self.settings_service = settings_service + + def build_settings_success_response(self, message: str) -> list[AnyComponent]: + query = urlencode( + { + "toast": "toast-settings-success", + "toast_message": message, + } + ) + return [ + c.FireEvent(event=GoToEvent(url=f"/settings?{query}")), + ] + + def reset_setting(self, setting_path: str) -> list[AnyComponent]: + definition = get_setting_definition(setting_path) + if definition is None: + raise HTTPException(status_code=404, detail=f"Unknown setting path: {setting_path}") + + try: + result = self.settings_service.reset_persisted_value(setting_path) + except SettingsError as err: + if not self.has_persisted_value(setting_path): + return self.build_settings_success_response( + "Settings reset, but effective settings are still unavailable." + ) + return self.build_settings_edit_page_components(setting_path, reset_error=str(err)) + except HTTPException: + raise + except Exception as err: + raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") + + return self.build_settings_success_response(str(result["message"])) + + def build_settings_page_components( + self, + toast: str | None = None, + toast_message: str | None = None, + ) -> list[AnyComponent]: + settings, effective_settings_error = self.get_settings_displays() + components: list[AnyComponent] = [ + c.Heading(text="Settings", level=1), + c.Div( + class_name="d-flex flex-wrap gap-2 mb-4", + components=[ + c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), + ], + ), + ] + if effective_settings_error: + components.append( + c.Error( + title="Effective settings unavailable", + description=( + f"{effective_settings_error} Persisted overrides are still shown below so you can inspect" + " and repair saved values." + ), + ) + ) + + for section, entries_iter in groupby(settings, key=lambda entry: entry.section): + entries = list(entries_iter) + components.extend(self.build_settings_section_components(section, entries)) + + components.append( + c.Toast( + title="Toast", + body=[c.Paragraph(text=toast_message or "Settings saved.")], + open_trigger=PageEvent(name="toast-settings-success"), + position="bottom-end", + ) + ) + + page_components: list[AnyComponent] = [c.Page(components=components)] + if toast == "toast-settings-success": + page_components.append(c.FireEvent(event=PageEvent(name=toast))) + + return page_components + + def build_settings_section_components( + self, + section: str, + settings: list[EditableSettingDisplay], + ) -> list[AnyComponent]: + first_setting = settings[0] + section_components: list[AnyComponent] = [ + c.Heading(text=first_setting.section_label, level=2), + ] + if first_setting.section_description: + section_components.append(c.Paragraph(text=first_setting.section_description, class_name="mb-2")) + + section_components.append( + c.Div( + class_name="border rounded overflow-hidden mb-4", + components=[self.build_settings_row(setting, index) for index, setting in enumerate(settings)], + ) + ) + + return [*section_components] + + def build_settings_row(self, setting: EditableSettingDisplay, index: int) -> AnyComponent: + info_components: list[AnyComponent] = [ + c.Heading(text=setting.label, level=4), + c.Paragraph(text=setting.path, class_name="text-muted small mb-1"), + c.Paragraph(text=setting.description, class_name="mb-2"), + ] + + badge_components = self.build_settings_badges(setting) + if badge_components: + info_components.append( + c.Div( + class_name="d-flex flex-wrap gap-2 mb-3", + components=badge_components, + ) + ) + info_components.append(self.build_settings_value_summary(setting)) + + action_components: list[AnyComponent] = [ + c.Button( + text="Manage Speakers šŸ”Š" if setting.path == "jukebox.player.sonos.selected_group" else "Edit āœļø", + on_click=( + GoToEvent(url="/sonos") + if setting.path == "jukebox.player.sonos.selected_group" + else GoToEvent(url=f"/settings/{setting.path}/edit") + ), + class_name="btn btn-secondary text-nowrap", + ) + ] + row_class_name = "px-3 py-3" + if index > 0: + row_class_name += " border-top" + + return c.Div( + class_name=row_class_name, + components=[ + c.Div( + class_name="d-flex flex-column flex-xl-row gap-3 justify-content-between align-items-xl-start", + components=[ + c.Div(class_name="flex-grow-1", components=info_components), + c.Div(class_name="d-grid gap-2 align-self-start", components=action_components), + ], + ) + ], + ) + + def build_settings_edit_page_components( + self, + setting_path: str, + reset_error: str | None = None, + ) -> list[AnyComponent]: + settings, effective_settings_error = self.get_settings_displays() + setting = next((candidate for candidate in settings if candidate.path == setting_path), None) + if setting is None: + return [ + c.Page( + components=[ + c.Heading(text="Edit setting", level=1), + c.Error(title="Setting not found", description=f"Unknown setting path: {setting_path}"), + c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), + c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), + ] + ) + ] + + components: list[AnyComponent] = [ + c.Heading(text=f"Edit {setting.label}", level=1), + c.Paragraph( + text=f"{setting.section_label} setting", class_name="text-uppercase text-muted small fw-semibold mb-1" + ), + c.Paragraph(text=setting.path, class_name="text-muted small mb-1"), + c.Paragraph(text=setting.description, class_name="mb-3"), + ] + + badge_components = self.build_settings_badges(setting) + if badge_components: + components.append( + c.Div( + class_name="d-flex flex-wrap gap-2 mb-3", + components=badge_components, + ) + ) + + if reset_error: + components.append( + c.Error( + title="Reset failed", + description=reset_error, + ) + ) + + if effective_settings_error: + components.append( + c.Error( + title="Effective settings unavailable", + description=( + f"{effective_settings_error} Showing persisted and default values where possible so this" + " setting can still be reviewed or repaired." + ), + ) + ) + + components.append( + c.Div( + class_name="border rounded p-3 mb-4 bg-light-subtle", + components=[ + c.Heading(text="Current values", level=3), + self.build_settings_value_summary(setting), + ], + ) + ) + + components.append( + c.Div( + class_name="border rounded p-3 mb-4", + components=[ + c.Heading(text="Update override", level=3), + c.Paragraph(text=self.build_settings_edit_guidance(setting), class_name="mb-3"), + self.build_settings_edit_form(setting), + ], + ) + ) + + if setting.is_persisted: + components.extend( + [ + c.Div( + class_name="border rounded p-3 mb-4", + components=[ + c.Heading(text="Reset override", level=3), + c.Paragraph( + text=( + "Reset removes the persisted override entirely. Use it to fall back to defaults," + " environment overrides, or CLI overrides." + ) + ), + self.build_settings_reset_form(setting.path), + ], + ) + ] + ) + + components.append( + c.Div( + class_name="mt-3 d-flex flex-wrap gap-3", + components=[ + c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), + c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), + ], + ) + ) + + return [c.Page(components=components)] + + def build_settings_edit_form(self, setting: EditableSettingDisplay) -> AnyComponent: + initial_value = setting.persisted_value if setting.is_persisted else setting.effective_value + field_description = setting.description + if setting.field_type == "object": + field_description = ( + f"{field_description} Enter a JSON object matching the persisted setting shape. " + "Leave blank to persist null. Use Reset to remove the persisted override." + ) + if setting.requires_restart: + field_description = f"{field_description} Takes effect after restart." + + if setting.choices: + options: list[SelectOption] = [ + { + "value": choice.value, + "label": choice.label, + } + for choice in setting.choices + ] + form_field = FormFieldSelect( + name="value", + title=setting.label, + options=options, + initial=None if initial_value is None else str(initial_value), + description=field_description, + required=True, + vanilla=True, + ) + elif setting.field_type == "object": + form_field = FormFieldTextarea( + name="value", + title=setting.label, + initial=json.dumps(initial_value, indent=2) if initial_value is not None else "", + description=field_description, + required=False, + rows=12, + placeholder="Enter a JSON object. Leave blank to persist null.", + ) + else: + form_field = FormFieldInput( + name="value", + title=setting.label, + initial=None if initial_value is None else str(initial_value), + description=field_description, + required=True, + html_type="number" if setting.field_type == "integer" else "text", + ) + + return c.Form( + form_fields=[form_field], + submit_url=f"/api/ui/settings/{setting.path}", + method="POST", + footer=[c.Button(text="Save", html_type="submit", class_name="btn btn-primary")], + ) + + def build_settings_reset_form(self, setting_path: str) -> AnyComponent: + return c.Form( + form_fields=[], + submit_url=f"/api/ui/settings/{setting_path}/reset", + method="POST", + footer=[c.Button(text="Reset", html_type="submit", class_name="btn btn-outline-danger text-nowrap px-3")], + ) + + def get_settings_displays(self) -> tuple[list[EditableSettingDisplay], str | None]: + persisted_settings = self.settings_service.get_persisted_settings_view() + effective_settings_error: str | None = None + try: + effective_settings_view = self.settings_service.get_effective_settings_view() + except SettingsError as err: + effective_settings_view = {} + effective_settings_error = str(err) + + return build_editable_setting_displays(persisted_settings, effective_settings_view), effective_settings_error + + def build_settings_badges(self, setting: EditableSettingDisplay) -> list[AnyComponent]: + badge_components: list[AnyComponent] = [] + if setting.is_persisted and not setting.is_pinned_default: + badge_components.append(c.Paragraph(text="Configured", class_name="badge text-bg-success text-uppercase")) + if setting.is_pinned_default: + badge_components.append(c.Paragraph(text="Pinned default", class_name="badge text-bg-info text-uppercase")) + if setting.requires_restart: + badge_components.append( + c.Paragraph(text="Restart required", class_name="badge text-bg-warning text-uppercase") + ) + if setting.advanced: + badge_components.append(c.Paragraph(text="Advanced", class_name="badge text-bg-dark text-uppercase")) + return badge_components + + def build_settings_value_summary(self, setting: EditableSettingDisplay) -> AnyComponent: + return c.Div( + class_name="row g-3", + components=[ + self.build_settings_value_cell( + "Default", + self.format_settings_display_value(setting.path, setting.default_value), + ), + self.build_settings_value_cell( + "Persisted override", + self.format_settings_display_value(setting.path, setting.persisted_value) + if setting.is_persisted + else "None", + ), + self.build_settings_value_cell( + "Effective value", + self.format_settings_display_value(setting.path, setting.effective_value), + ), + self.build_settings_value_cell( + "Source", + self.format_settings_provenance(setting.provenance), + ), + ], + ) + + def build_settings_value_cell(self, label: str, value: str) -> AnyComponent: + return c.Div( + class_name="col-12 col-md-6 col-xl-3", + components=[ + c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-1"), + c.Paragraph(text=value, class_name="mb-0 text-break"), + ], + ) + + def build_settings_edit_guidance(self, setting: EditableSettingDisplay) -> str: + guidance = "Save a persisted override for this setting." + if setting.choices: + guidance = f"{guidance} Choose one of the supported options below." + elif setting.field_type == "object": + guidance = ( + f"{guidance} Provide a JSON object matching the stored setting shape," + " or leave the field blank to persist null." + ) + + return f"{guidance} The effective value may still be superseded by environment or CLI overrides." + + def build_settings_patch(self, setting_path: str, raw_value: str) -> JsonObject: + definition = get_setting_definition(setting_path) + if definition is None: + raise ValueError(f"Unknown setting path: {setting_path}") + + if definition.choices and raw_value not in {choice.value for choice in definition.choices}: + raise ValueError("Choose a valid option.") + + if definition.field_type == "integer": + try: + value: JsonValue = int(raw_value) + except ValueError as err: + raise ValueError("Enter a valid integer.") from err + elif definition.field_type == "number": + try: + value = float(raw_value) + except ValueError as err: + raise ValueError("Enter a valid number.") from err + elif definition.field_type == "object": + if raw_value.strip() == "": + value = None + return self.build_dotted_patch(setting_path, value) + try: + value = cast(JsonValue, json.loads(raw_value)) + except json.JSONDecodeError as err: + raise ValueError("Enter valid JSON.") from err + if not isinstance(value, dict): + raise ValueError("Enter a JSON object.") + else: + value = raw_value + + return self.build_dotted_patch(setting_path, value) + + def build_dotted_patch(self, dotted_path: str, value: JsonValue) -> JsonObject: + patch: JsonObject = {} + cursor = patch + parts = dotted_path.split(".") + for part in parts[:-1]: + child: JsonObject = {} + cursor[part] = child + cursor = child + cursor[parts[-1]] = value + return patch + + def persisted_value_matches(self, dotted_path: str, expected_value: object) -> bool: + return ( + self.lookup_optional_dotted_path(self.settings_service.get_persisted_settings_view(), dotted_path) + == expected_value + ) + + def has_persisted_value(self, dotted_path: str) -> bool: + return ( + self.lookup_optional_dotted_path(self.settings_service.get_persisted_settings_view(), dotted_path) + is not _MISSING + ) + + def lookup_optional_dotted_path(self, root: JsonObject, dotted_path: str) -> object: + current: JsonObject = root + parts = dotted_path.split(".") + for part in parts[:-1]: + child = current.get(part, _MISSING) + if not isinstance(child, dict): + return _MISSING + current = cast(JsonObject, child) + return current.get(parts[-1], _MISSING) + + def format_settings_display_value(self, setting_path: str, value: object) -> str: + if value is None: + return "null" + + definition = get_setting_definition(setting_path) + if definition is not None and definition.choices and isinstance(value, str): + choice_labels = {choice.value: choice.label for choice in definition.choices} + if value in choice_labels: + return choice_labels[value] + + if setting_path == "jukebox.player.sonos.selected_group" and isinstance(value, dict): + selected_group = cast(dict[str, object], value) + members = selected_group.get("members") + coordinator_uid = selected_group.get("coordinator_uid") + if isinstance(members, list) and isinstance(coordinator_uid, str): + member_uids = [] + for member in members: + if not isinstance(member, dict): + continue + selected_member = cast(dict[str, object], member) + uid = selected_member.get("uid") + if not isinstance(uid, str): + continue + member_uids.append(uid) + if member_uids: + return "{} (coordinator); members: {}".format(coordinator_uid, ", ".join(member_uids)) + + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float)): + return str(value) + if isinstance(value, str): + return value + try: + return json.dumps(value, sort_keys=True, separators=(", ", ": ")) + except TypeError: + return str(value) + + def format_settings_provenance(self, provenance: str) -> str: + labels = { + "default": "Default", + "file": "Settings file", + "env": "Environment override", + "cli": "CLI override", + "mixed": "Mixed source", + } + return labels.get(provenance, provenance) diff --git a/jukebox/adapters/inbound/admin/ui_pages/sonos.py b/jukebox/adapters/inbound/admin/ui_pages/sonos.py new file mode 100644 index 00000000..b11b97f2 --- /dev/null +++ b/jukebox/adapters/inbound/admin/ui_pages/sonos.py @@ -0,0 +1,501 @@ +from urllib.parse import urlencode + +from fastui import AnyComponent +from fastui import components as c +from fastui.components.forms import FormFieldSelect +from fastui.events import GoToEvent, PageEvent +from fastui.forms import SelectOption +from pydantic import BaseModel, Field, field_validator + +from jukebox.settings.entities import SelectedSonosGroupSettings +from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository +from jukebox.settings.service_protocols import SettingsService +from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError +from jukebox.sonos.selection import ( + GetSonosSelectionStatus, + SonosSelectionStatus, +) +from jukebox.sonos.service import SonosService + + +class SonosSelectionForm(BaseModel): + uids: list[str] = Field(default_factory=list, title="Speakers") + coordinator_uid: str | None = Field(None, title="Coordinator") + + @field_validator("uids", mode="before") + @classmethod + def coerce_single_uid_to_list(cls, value): + if isinstance(value, str): + return [value] + return value + + +class SonosUIPageBuilder: + def __init__(self, settings_service: SettingsService, sonos_service: SonosService): + self.settings_service = settings_service + self.sonos_service = sonos_service + + def build_sonos_success_response(self, message: str) -> list[AnyComponent]: + query = urlencode( + { + "toast": "toast-sonos-success", + "toast_message": message, + } + ) + return [ + c.FireEvent(event=GoToEvent(url=f"/sonos?{query}")), + ] + + def build_sonos_edit_error_response( + self, + message: str, + uids: list[str], + coordinator_uid: str | None, + ) -> list[AnyComponent]: + query = urlencode( + [ + ("error_message", message), + *[("uids", uid) for uid in uids], + *([("coordinator_uid", coordinator_uid)] if coordinator_uid is not None else []), + ] + ) + return [ + c.FireEvent(event=GoToEvent(url=f"/sonos/edit?{query}")), + ] + + def build_sonos_page_components( + self, + toast: str | None = None, + toast_message: str | None = None, + error_message: str | None = None, + ) -> list[AnyComponent]: + selected_group = self._get_selected_group() + status: SonosSelectionStatus | None = None + speakers: list[DiscoveredSonosSpeaker] = [] + discovery_error = error_message + + try: + status = GetSonosSelectionStatus( + selected_group_repository=SettingsSelectedSonosGroupRepository(self.settings_service), + sonos_service=self.sonos_service, + ).execute() + speakers = self.sonos_service.list_network_speakers() + except SonosDiscoveryError as err: + discovery_error = str(err) + + components: list[AnyComponent] = [ + c.Heading(text="Sonos Speakers", level=1), + c.Div( + class_name="d-flex flex-wrap gap-3 mb-4", + components=[ + c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), + c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), + ], + ), + ] + + if discovery_error: + components.append( + c.Error( + title="Sonos discovery unavailable", + description=discovery_error, + ) + ) + + components.extend(self._build_saved_selection_components(status=status, selected_group=selected_group)) + + action_components: list[AnyComponent] = [ + c.Button(text="Edit selection", on_click=GoToEvent(url="/sonos/edit")), + ] + if selected_group is not None: + action_components.append(self._build_reset_form(button_text="Clear saved selection")) + components.append( + c.Div( + class_name="d-flex flex-wrap gap-2 mb-4", + components=action_components, + ) + ) + + if discovery_error is None: + components.extend( + self._build_discovered_speakers_components(speakers=speakers, selected_group=selected_group) + ) + + components.append( + c.Toast( + title="Toast", + body=[c.Paragraph(text=toast_message or "Sonos settings saved.")], + open_trigger=PageEvent(name="toast-sonos-success"), + position="bottom-end", + ) + ) + + page_components: list[AnyComponent] = [c.Page(components=components)] + if toast == "toast-sonos-success": + page_components.append(c.FireEvent(event=PageEvent(name=toast))) + + return page_components + + def build_sonos_edit_page_components( + self, + error_message: str | None = None, + field_errors: dict[str, str] | None = None, + submitted_uids: list[str] | None = None, + submitted_coordinator_uid: str | None = None, + ) -> list[AnyComponent]: + selected_group = self._get_selected_group() + components: list[AnyComponent] = [ + c.Heading(text="Edit Sonos Selection", level=1), + c.Paragraph( + text="Choose one or more visible speakers and select the coordinator used for playback.", + class_name="mb-3", + ), + ] + + try: + speakers = self.sonos_service.list_network_speakers() + except SonosDiscoveryError as err: + components.append( + c.Error( + title="Sonos discovery unavailable", + description=error_message or str(err), + ) + ) + components.extend(self._build_navigation_links()) + return [c.Page(components=components)] + + if not speakers: + components.append( + c.Error( + title="No Sonos speakers found", + description="No visible Sonos speakers are currently discoverable on the network.", + ) + ) + components.extend(self._build_navigation_links()) + return [c.Page(components=components)] + + components.append( + c.Div( + class_name="border rounded p-3 mb-4", + components=[ + c.Heading(text="Selection", level=3), + *self._build_edit_error_components(error_message), + *self._build_edit_saved_selection_components(selected_group, speakers), + c.Paragraph(text="Changes take effect after restart.", class_name="mb-3"), + self._build_selection_form( + speakers=speakers, + selected_group=selected_group, + field_errors=field_errors, + submitted_uids=submitted_uids, + submitted_coordinator_uid=submitted_coordinator_uid, + ), + ], + ) + ) + + components.extend(self._build_navigation_links()) + return [c.Page(components=components)] + + def _build_edit_error_components(self, error_message: str | None) -> list[AnyComponent]: + if not error_message: + return [] + + return [ + c.Div( + class_name="alert alert-danger mb-3", + components=[ + c.Paragraph(text="Selection not saved", class_name="fw-semibold mb-1"), + c.Paragraph(text=error_message, class_name="mb-0"), + ], + ) + ] + + def _build_edit_saved_selection_components( + self, + selected_group: SelectedSonosGroupSettings | None, + speakers: list[DiscoveredSonosSpeaker], + ) -> list[AnyComponent]: + if selected_group is None: + return [] + + speakers_by_uid = {speaker.uid: speaker for speaker in speakers} + coordinator = speakers_by_uid.get(selected_group.coordinator_uid) + coordinator_label = ( + f"{coordinator.name} [{coordinator.uid}]" if coordinator is not None else selected_group.coordinator_uid + ) + member_labels = [ + f"{speakers_by_uid[member.uid].name} [{member.uid}]" if member.uid in speakers_by_uid else member.uid + for member in selected_group.members + ] + + return [ + c.Div( + class_name="bg-light-subtle border rounded p-3 mb-3", + components=[ + c.Paragraph(text="Current saved selection", class_name="text-uppercase text-muted small mb-1"), + c.Paragraph(text=f"Coordinator: {coordinator_label}", class_name="mb-1"), + c.Paragraph(text="Members: {}".format(", ".join(member_labels)), class_name="mb-0"), + ], + ) + ] + + def _build_selection_form( + self, + speakers: list[DiscoveredSonosSpeaker], + selected_group: SelectedSonosGroupSettings | None, + field_errors: dict[str, str] | None = None, + submitted_uids: list[str] | None = None, + submitted_coordinator_uid: str | None = None, + ) -> AnyComponent: + selected_uids = ( + list(submitted_uids) + if submitted_uids is not None + else [member.uid for member in selected_group.members] + if selected_group is not None + else [] + ) + available_uids = {speaker.uid for speaker in speakers} + initial_uids = [uid for uid in selected_uids if uid in available_uids] + if submitted_uids is None and not initial_uids and speakers: + initial_uids = [speakers[0].uid] + + if submitted_coordinator_uid is not None and submitted_coordinator_uid in available_uids: + initial_coordinator_uid = submitted_coordinator_uid + elif selected_group is not None and selected_group.coordinator_uid in available_uids: + initial_coordinator_uid = selected_group.coordinator_uid + else: + initial_coordinator_uid = initial_uids[0] if initial_uids else speakers[0].uid + + speaker_options: list[SelectOption] = [ + { + "value": speaker.uid, + "label": self._build_speaker_option_label(speaker), + } + for speaker in speakers + ] + + return c.Form( + form_fields=[ + FormFieldSelect( + name="uids", + title="Speakers", + options=speaker_options, + initial=initial_uids, + description="Select the Sonos speakers that should participate in playback.", + required=True, + multiple=True, + error=field_errors.get("uids") if field_errors is not None else None, + vanilla=True, + ), + FormFieldSelect( + name="coordinator_uid", + title="Coordinator", + options=speaker_options, + initial=initial_coordinator_uid, + description="Choose the speaker that should coordinate the selected group.", + required=True, + error=field_errors.get("coordinator_uid") if field_errors is not None else None, + vanilla=True, + ), + ], + submit_url="/api/ui/sonos/edit", + method="POST", + footer=[c.Button(text="Save", html_type="submit", class_name="btn btn-primary")], + ) + + def _build_saved_selection_components( + self, + status: SonosSelectionStatus | None, + selected_group: SelectedSonosGroupSettings | None, + ) -> list[AnyComponent]: + if selected_group is None: + return [ + c.Div( + class_name="border rounded p-3 mb-4 bg-light-subtle", + components=[ + c.Heading(text="Saved selection", level=3), + c.Paragraph(text="No Sonos speaker selection is currently saved."), + ], + ) + ] + + components: list[AnyComponent] = [ + c.Heading(text="Saved selection", level=3), + ] + + if status is None: + components.extend( + [ + c.Paragraph(text=f"Coordinator: {selected_group.coordinator_uid}"), + c.Paragraph(text="Members: {}".format(", ".join(member.uid for member in selected_group.members))), + ] + ) + else: + status_label = { + "available": "Available", + "partial": "Partially available", + "unavailable": "Unavailable", + "not_selected": "Not selected", + }.get(status.availability.status, status.availability.status) + coordinator_label = self._format_saved_coordinator(status) + components.append(c.Paragraph(text=f"Status: {status_label}")) + components.append(c.Paragraph(text=f"Coordinator: {coordinator_label}")) + components.append( + c.Paragraph( + text="Members: {}".format( + ", ".join(self._format_status_member(member) for member in status.availability.members) + ) + ) + ) + + return [ + c.Div( + class_name="border rounded p-3 mb-4 bg-light-subtle", + components=components, + ) + ] + + def _build_discovered_speakers_components( + self, + speakers: list[DiscoveredSonosSpeaker], + selected_group: SelectedSonosGroupSettings | None, + ) -> list[AnyComponent]: + if not speakers: + return [ + c.Div( + class_name="border rounded p-3 mb-4", + components=[ + c.Heading(text="Discovered speakers", level=3), + c.Paragraph(text="No visible Sonos speakers found."), + ], + ) + ] + + selected_uids = {member.uid for member in selected_group.members} if selected_group is not None else set() + coordinator_uid = selected_group.coordinator_uid if selected_group is not None else None + + return [ + c.Heading(text="Discovered speakers", level=2), + c.Div( + class_name="border rounded overflow-hidden mb-4", + components=[ + self._build_speaker_header(), + *[ + self._build_speaker_row( + speaker=speaker, + is_selected=speaker.uid in selected_uids, + is_coordinator=speaker.uid == coordinator_uid, + ) + for speaker in speakers + ], + ], + ), + ] + + def _build_speaker_header(self) -> AnyComponent: + return c.Div( + class_name="d-none d-lg-block px-3 py-2 bg-light-subtle", + components=[ + c.Div( + class_name="row g-2 align-items-center", + components=[ + self._build_speaker_header_cell("Name", "col-lg-3"), + self._build_speaker_header_cell("Host", "col-lg-3"), + self._build_speaker_header_cell("Household", "col-lg-4"), + self._build_speaker_header_cell("Selection", "col-lg-2 text-lg-center"), + ], + ) + ], + ) + + def _build_speaker_row( + self, + speaker: DiscoveredSonosSpeaker, + is_selected: bool, + is_coordinator: bool, + ) -> AnyComponent: + selection_label = "Coordinator" if is_coordinator else "Selected" if is_selected else "Available" + return c.Div( + class_name="px-3 py-2 border-top", + components=[ + c.Div( + class_name="row g-2 align-items-center", + components=[ + self._build_speaker_value_cell("Name", speaker.name, "col-12 col-lg-3"), + self._build_speaker_value_cell("Host", speaker.host, "col-12 col-lg-3"), + self._build_speaker_value_cell("Household", speaker.household_id, "col-12 col-lg-4"), + self._build_speaker_value_cell( + "Selection", + selection_label, + "col-12 col-lg-2 text-lg-center", + ), + ], + ) + ], + ) + + def _build_speaker_header_cell(self, label: str, class_name: str) -> AnyComponent: + return c.Div( + class_name=f"{class_name} d-flex align-items-center", + components=[ + c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-0"), + ], + ) + + def _build_speaker_value_cell(self, label: str, value: str, class_name: str) -> AnyComponent: + return c.Div( + class_name=class_name, + components=[ + c.Paragraph(text=label, class_name="d-lg-none text-uppercase text-muted small fw-semibold mb-1"), + c.Paragraph(text=value, class_name="mb-0 text-break"), + ], + ) + + def _build_navigation_links(self) -> list[AnyComponent]: + return [ + c.Div( + class_name="mt-3 d-flex flex-wrap gap-3", + components=[ + c.Link(components=[c.Text(text="Back to Sonos")], on_click=GoToEvent(url="/sonos")), + c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), + c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), + ], + ) + ] + + def _build_reset_form(self, button_text: str) -> AnyComponent: + return c.Form( + form_fields=[], + submit_url="/api/ui/sonos/reset", + method="POST", + footer=[ + c.Button( + text=button_text, + html_type="submit", + class_name="btn btn-outline-danger text-nowrap px-3", + ) + ], + ) + + def _get_selected_group(self) -> SelectedSonosGroupSettings | None: + return SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group() + + @staticmethod + def _build_speaker_option_label(speaker: DiscoveredSonosSpeaker) -> str: + return f"{speaker.name} ({speaker.host})" + + @staticmethod + def _format_status_member(member) -> str: + if member.speaker is not None: + return f"{member.speaker.name} [{member.uid}]" + return f"{member.uid} [unavailable]" + + @staticmethod + def _format_saved_coordinator(status: SonosSelectionStatus) -> str: + if status.selected_group is None: + return "unknown" + + for member in status.availability.members: + if member.uid == status.selected_group.coordinator_uid and member.speaker is not None: + return f"{member.speaker.name} [{member.uid}]" + return status.selected_group.coordinator_uid From 98905182ff4c7749b11b658f451dffd9afb35cbb Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:52:24 +0200 Subject: [PATCH 4/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20move=20library=20comma?= =?UTF-8?q?nds=20and=20handlers=20to=20jukebox=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jukebox/admin/library_command_handlers.py | 32 ++++++++++++ jukebox/admin/library_commands.py | 59 +++++++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 jukebox/admin/library_command_handlers.py create mode 100644 jukebox/admin/library_commands.py diff --git a/jukebox/admin/library_command_handlers.py b/jukebox/admin/library_command_handlers.py new file mode 100644 index 00000000..de7e20a7 --- /dev/null +++ b/jukebox/admin/library_command_handlers.py @@ -0,0 +1,32 @@ +from collections.abc import Callable +from typing import Protocol + +from jukebox.settings.service_protocols import SettingsService + +from .library_commands import InteractiveCliCommand + + +class LibraryController(Protocol): + def run(self, command: object) -> None: ... + + +class InteractiveLibraryController(Protocol): + def run(self) -> None: ... + + +def execute_library_command( + verbose: bool, + command: object, + settings_service: SettingsService, + build_cli_controller: Callable[[str], LibraryController], + build_interactive_cli_controller: Callable[[str], InteractiveLibraryController], +) -> None: + runtime_config = settings_service.resolve_admin_runtime(verbose=verbose) + + if isinstance(command, InteractiveCliCommand): + interactive_cli = build_interactive_cli_controller(runtime_config.library_path) + interactive_cli.run() + return + + cli = build_cli_controller(runtime_config.library_path) + cli.run(command) diff --git a/jukebox/admin/library_commands.py b/jukebox/admin/library_commands.py new file mode 100644 index 00000000..50bb5978 --- /dev/null +++ b/jukebox/admin/library_commands.py @@ -0,0 +1,59 @@ +from enum import StrEnum +from typing import Literal, Self + +from pydantic import BaseModel, model_validator + + +class CliTagSourceCommand(BaseModel): + tag: str | None = None + use_current_tag: bool = False + + @model_validator(mode="after") + def validate_tag_source(self) -> Self: + has_explicit_tag = bool(self.tag) + if has_explicit_tag == self.use_current_tag: + raise ValueError("Exactly one tag source must be provided: explicit tag or --from-current.") + return self + + +class CliAddCommand(CliTagSourceCommand): + type: Literal["add"] + uri: str + track: str | None = None + artist: str | None = None + album: str | None = None + + +class CliListCommandModes(StrEnum): + table = "table" + line = "line" + + +class CliListCommand(BaseModel): + type: Literal["list"] + mode: CliListCommandModes = CliListCommandModes.table + + +class CliRemoveCommand(CliTagSourceCommand): + type: Literal["remove"] + + +class CliEditCommand(CliTagSourceCommand): + type: Literal["edit"] + uri: str | None = None + track: str | None = None + artist: str | None = None + album: str | None = None + + +class CliGetCommand(CliTagSourceCommand): + type: Literal["get"] + + +class CliSearchCommand(BaseModel): + type: Literal["search"] + query: str + + +class InteractiveCliCommand(BaseModel): + type: Literal["interactive"] From dd1f6063c13100757a9f9a41aa6be5c5291996a7 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:53:08 +0200 Subject: [PATCH 5/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20merge=20discstore=20DI?= =?UTF-8?q?=20container=20into=20jukebox=20admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jukebox/admin/app.py | 14 ++++++--- jukebox/admin/di_container.py | 55 ++++++++++++++++++++++++++++------- 2 files changed, 55 insertions(+), 14 deletions(-) diff --git a/jukebox/admin/app.py b/jukebox/admin/app.py index e812013a..540f0b7d 100644 --- a/jukebox/admin/app.py +++ b/jukebox/admin/app.py @@ -5,8 +5,8 @@ import typer from pydantic import ValidationError -from discstore.command_handlers import execute_library_command -from discstore.commands import ( +from jukebox.admin.library_command_handlers import execute_library_command +from jukebox.admin.library_commands import ( CliAddCommand, CliEditCommand, CliGetCommand, @@ -16,7 +16,6 @@ CliSearchCommand, InteractiveCliCommand, ) -from discstore.di_container import build_cli_controller, build_interactive_cli_controller from jukebox.settings.errors import SettingsError from jukebox.shared.config_utils import get_package_version from jukebox.shared.logger import set_logger @@ -41,7 +40,14 @@ is_settings_command, is_sonos_command, ) -from .di_container import build_admin_api_app, build_admin_services, build_admin_ui_app, build_settings_service +from .di_container import ( + build_admin_api_app, + build_admin_services, + build_admin_ui_app, + build_cli_controller, + build_interactive_cli_controller, + build_settings_service, +) from .pn532_command_handlers import execute_pn532_command from .pn532_commands import Pn532ProbeCommand, Pn532ProfilesCommand, Pn532SelectCommand, is_pn532_command from .sonos_households import GroupedSonosHousehold diff --git a/jukebox/admin/di_container.py b/jukebox/admin/di_container.py index 21ed3c9d..e4ac8b6c 100644 --- a/jukebox/admin/di_container.py +++ b/jukebox/admin/di_container.py @@ -1,12 +1,14 @@ -from discstore.adapters.outbound.json_library_adapter import JsonLibraryAdapter -from discstore.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc +from jukebox.adapters.outbound.json_library_adapter import JsonLibraryAdapter from jukebox.adapters.outbound.sonos_discovery_adapter import SoCoSonosDiscoveryAdapter +from jukebox.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter +from jukebox.domain.use_cases.library.add_disc import AddDisc +from jukebox.domain.use_cases.library.edit_disc import EditDisc +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.use_cases.library.get_disc import GetDisc +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc +from jukebox.domain.use_cases.library.resolve_tag_id import ResolveTagId +from jukebox.domain.use_cases.library.search_discs import SearchDiscs from jukebox.settings.file_settings_repository import FileSettingsRepository from jukebox.settings.resolve import SettingsService as SettingsServiceImpl from jukebox.settings.resolve import build_environment_settings_overrides @@ -56,7 +58,7 @@ def build_admin_api_app(library_path: str, services: AdminServices): repository = JsonLibraryAdapter(library_path) current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) - from discstore.adapters.inbound.api_controller import APIController + from jukebox.adapters.inbound.admin.api_controller import APIController return APIController( AddDisc(repository), @@ -74,7 +76,7 @@ def build_admin_ui_app(library_path: str, services: AdminServices): repository = JsonLibraryAdapter(library_path) current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) - from discstore.adapters.inbound.ui_controller import UIController + from jukebox.adapters.inbound.admin.ui_controller import UIController return UIController( AddDisc(repository), @@ -90,3 +92,36 @@ def build_admin_ui_app(library_path: str, services: AdminServices): def build_sonos_service() -> SonosService: return DefaultSonosService(SoCoSonosDiscoveryAdapter()) + + +def build_cli_controller(library_path: str): + repository = JsonLibraryAdapter(library_path) + current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) + get_current_tag_status = GetCurrentTagStatus(current_tag_repository, repository) + + from jukebox.adapters.inbound.admin.cli_controller import CLIController + + return CLIController( + AddDisc(repository), + ListDiscs(repository), + RemoveDisc(repository), + EditDisc(repository), + GetDisc(repository), + SearchDiscs(repository), + ResolveTagId(get_current_tag_status), + ) + + +def build_interactive_cli_controller(library_path: str): + repository = JsonLibraryAdapter(library_path) + current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) + + from jukebox.adapters.inbound.admin.interactive_cli_controller import InteractiveCLIController + + return InteractiveCLIController( + AddDisc(repository), + ListDiscs(repository), + RemoveDisc(repository), + EditDisc(repository), + GetCurrentTagStatus(current_tag_repository, repository), + ) From 5571e3f06a94fb1637c777fb6854ef741967ff5b Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:54:05 +0200 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=94=A5=20remove=20discstore=20re-expo?= =?UTF-8?q?rt=20stubs=20and=20empty=20package?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- discstore/__init__.py | 0 discstore/adapters/__init__.py | 0 discstore/adapters/inbound/__init__.py | 0 discstore/adapters/inbound/api/__init__.py | 25 - .../inbound/api/current_tag_router.py | 164 ------ .../adapters/inbound/api/discs_router.py | 74 --- discstore/adapters/inbound/api/models.py | 48 -- .../adapters/inbound/api/settings_router.py | 46 -- discstore/adapters/inbound/api_controller.py | 188 ------- discstore/adapters/inbound/cli_controller.py | 126 ----- discstore/adapters/inbound/cli_display.py | 58 -- .../inbound/interactive_cli_controller.py | 132 ----- discstore/adapters/inbound/ui_controller.py | 505 ----------------- .../adapters/inbound/ui_pages/__init__.py | 0 .../adapters/inbound/ui_pages/library.py | 375 ------------- .../adapters/inbound/ui_pages/settings.py | 525 ------------------ discstore/adapters/inbound/ui_pages/sonos.py | 501 ----------------- discstore/adapters/outbound/__init__.py | 0 .../adapters/outbound/json_library_adapter.py | 3 - .../outbound/text_current_tag_adapter.py | 3 - discstore/command_handlers.py | 32 -- discstore/commands.py | 59 -- discstore/di_container.py | 42 -- discstore/domain/__init__.py | 0 discstore/domain/entities/__init__.py | 6 - .../domain/entities/current_tag_status.py | 6 - discstore/domain/repositories/__init__.py | 4 - discstore/domain/use_cases/__init__.py | 19 - discstore/domain/use_cases/add_disc.py | 11 - discstore/domain/use_cases/edit_disc.py | 38 -- .../use_cases/get_current_tag_status.py | 15 - discstore/domain/use_cases/get_disc.py | 14 - discstore/domain/use_cases/list_discs.py | 10 - discstore/domain/use_cases/remove_disc.py | 9 - discstore/domain/use_cases/resolve_tag_id.py | 21 - discstore/domain/use_cases/search_discs.py | 27 - tests/jukebox/admin/test_app.py | 20 +- .../domain/entities/test_disc_metadata.py | 2 +- .../domain/entities/test_disc_option.py | 2 +- tests/jukebox/domain/entities/test_discs.py | 2 +- tests/jukebox/domain/entities/test_library.py | 2 +- 41 files changed, 14 insertions(+), 3100 deletions(-) delete mode 100644 discstore/__init__.py delete mode 100644 discstore/adapters/__init__.py delete mode 100644 discstore/adapters/inbound/__init__.py delete mode 100644 discstore/adapters/inbound/api/__init__.py delete mode 100644 discstore/adapters/inbound/api/current_tag_router.py delete mode 100644 discstore/adapters/inbound/api/discs_router.py delete mode 100644 discstore/adapters/inbound/api/models.py delete mode 100644 discstore/adapters/inbound/api/settings_router.py delete mode 100644 discstore/adapters/inbound/api_controller.py delete mode 100644 discstore/adapters/inbound/cli_controller.py delete mode 100644 discstore/adapters/inbound/cli_display.py delete mode 100644 discstore/adapters/inbound/interactive_cli_controller.py delete mode 100644 discstore/adapters/inbound/ui_controller.py delete mode 100644 discstore/adapters/inbound/ui_pages/__init__.py delete mode 100644 discstore/adapters/inbound/ui_pages/library.py delete mode 100644 discstore/adapters/inbound/ui_pages/settings.py delete mode 100644 discstore/adapters/inbound/ui_pages/sonos.py delete mode 100644 discstore/adapters/outbound/__init__.py delete mode 100644 discstore/adapters/outbound/json_library_adapter.py delete mode 100644 discstore/adapters/outbound/text_current_tag_adapter.py delete mode 100644 discstore/command_handlers.py delete mode 100644 discstore/commands.py delete mode 100644 discstore/di_container.py delete mode 100644 discstore/domain/__init__.py delete mode 100644 discstore/domain/entities/__init__.py delete mode 100644 discstore/domain/entities/current_tag_status.py delete mode 100644 discstore/domain/repositories/__init__.py delete mode 100644 discstore/domain/use_cases/__init__.py delete mode 100644 discstore/domain/use_cases/add_disc.py delete mode 100644 discstore/domain/use_cases/edit_disc.py delete mode 100644 discstore/domain/use_cases/get_current_tag_status.py delete mode 100644 discstore/domain/use_cases/get_disc.py delete mode 100644 discstore/domain/use_cases/list_discs.py delete mode 100644 discstore/domain/use_cases/remove_disc.py delete mode 100644 discstore/domain/use_cases/resolve_tag_id.py delete mode 100644 discstore/domain/use_cases/search_discs.py diff --git a/discstore/__init__.py b/discstore/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/adapters/__init__.py b/discstore/adapters/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/adapters/inbound/__init__.py b/discstore/adapters/inbound/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/adapters/inbound/api/__init__.py b/discstore/adapters/inbound/api/__init__.py deleted file mode 100644 index 3aaf672b..00000000 --- a/discstore/adapters/inbound/api/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -from discstore.adapters.inbound.api.current_tag_router import build_current_tag_router -from discstore.adapters.inbound.api.discs_router import build_discs_router -from discstore.adapters.inbound.api.models import ( - CurrentTagDiscOutput, - CurrentTagStatusOutput, - DiscInput, - DiscOutput, - DiscPatchInput, - SettingsPatchInput, - SettingsResetInput, -) -from discstore.adapters.inbound.api.settings_router import build_settings_router - -__all__ = [ - "CurrentTagDiscOutput", - "CurrentTagStatusOutput", - "DiscInput", - "DiscOutput", - "DiscPatchInput", - "SettingsPatchInput", - "SettingsResetInput", - "build_current_tag_router", - "build_discs_router", - "build_settings_router", -] diff --git a/discstore/adapters/inbound/api/current_tag_router.py b/discstore/adapters/inbound/api/current_tag_router.py deleted file mode 100644 index 4ae447bb..00000000 --- a/discstore/adapters/inbound/api/current_tag_router.py +++ /dev/null @@ -1,164 +0,0 @@ -from typing import Any - -from fastapi import APIRouter, HTTPException, Response, status -from pydantic import ValidationError - -from discstore.adapters.inbound.api.models import ( - CurrentTagDiscOutput, - CurrentTagStatusOutput, - DiscInput, - DiscOutput, - DiscPatchInput, -) -from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.remove_disc import RemoveDisc - - -def build_current_tag_router( - get_current_tag_status: GetCurrentTagStatus, - add_disc: AddDisc, - edit_disc: EditDisc, - get_disc: GetDisc, - remove_disc: RemoveDisc, -) -> APIRouter: - router = APIRouter(prefix="/api/v1", tags=["current-tag"]) - - def read_current_tag_status() -> CurrentTagStatus | None: - return get_current_tag_status.execute() - - def ensure_expected_tag_id_matches( - expected_tag_id: str | None, current_tag_status: CurrentTagStatus | None - ) -> None: - if expected_tag_id is None: - return - - actual_tag_id = None if current_tag_status is None else current_tag_status.tag_id - if actual_tag_id != expected_tag_id: - raise HTTPException( - status_code=409, - detail=f"Current tag changed: expected_tag_id='{expected_tag_id}', actual_tag_id={repr(actual_tag_id)}", - ) - - def build_current_tag_disc_output(tag_id: str, disc: Disc) -> CurrentTagDiscOutput: - return CurrentTagDiscOutput(tag_id=tag_id, disc=DiscOutput(**disc.model_dump())) - - @router.get( - "/current-tag", - response_model=CurrentTagStatusOutput, - responses={204: {"description": "No current tag"}}, - summary="Get the current NFC tag status", - ) - def get_current_tag() -> Any: - current_tag_status = read_current_tag_status() - if current_tag_status is None: - return Response(status_code=204) - - return CurrentTagStatusOutput(**current_tag_status.model_dump()) - - @router.get( - "/current-tag/disc", - response_model=CurrentTagDiscOutput, - responses={204: {"description": "No current tag"}, 404: {"description": "Current tag disc not found"}}, - summary="Get the current tag disc", - ) - def get_current_tag_disc() -> Any: - current_tag_status = read_current_tag_status() - if current_tag_status is None: - return Response(status_code=204) - - if not current_tag_status.known_in_library: - raise HTTPException(status_code=404, detail=f"Tag does not exist: tag_id='{current_tag_status.tag_id}'") - - return build_current_tag_disc_output(current_tag_status.tag_id, get_disc.execute(current_tag_status.tag_id)) - - @router.post( - "/current-tag/disc", - response_model=CurrentTagDiscOutput, - status_code=201, - responses={204: {"description": "No current tag"}, 409: {"description": "Current tag changed or disc exists"}}, - summary="Create a disc for the current tag", - ) - def create_current_tag_disc( - disc: DiscInput, - expected_tag_id: str | None = None, - ) -> Any: - current_tag_status = read_current_tag_status() - ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) - if current_tag_status is None: - return Response(status_code=204) - - try: - new_disc = Disc(**disc.model_dump()) - created_disc = add_disc.execute(current_tag_status.tag_id, new_disc) - return build_current_tag_disc_output(current_tag_status.tag_id, created_disc) - except ValueError as value_err: - raise HTTPException(status_code=409, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.patch( - "/current-tag/disc", - response_model=CurrentTagDiscOutput, - responses={ - 204: {"description": "No current tag"}, - 404: {"description": "Current tag disc not found"}, - 409: {"description": "Current tag changed"}, - }, - summary="Update the current tag disc", - ) - def update_current_tag_disc( - disc_patch: DiscPatchInput, - expected_tag_id: str | None = None, - ) -> Any: - current_tag_status = read_current_tag_status() - ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) - if current_tag_status is None: - return Response(status_code=204) - - try: - metadata = None - if disc_patch.metadata is not None: - metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True)) - - option = None - if disc_patch.option is not None: - option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True)) - - updated_disc = edit_disc.execute(current_tag_status.tag_id, disc_patch.uri, metadata, option) - return build_current_tag_disc_output(current_tag_status.tag_id, updated_disc) - except ValidationError as err: - raise HTTPException(status_code=422, detail=err.errors()) - except ValueError as value_err: - raise HTTPException(status_code=404, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.delete( - "/current-tag/disc", - status_code=204, - responses={ - 204: {"description": "No current tag or disc deleted"}, - 404: {"description": "Current tag disc not found"}, - 409: {"description": "Current tag changed"}, - }, - summary="Delete the current tag disc", - ) - def delete_current_tag_disc(expected_tag_id: str | None = None) -> Response: - current_tag_status = read_current_tag_status() - ensure_expected_tag_id_matches(expected_tag_id, current_tag_status) - if current_tag_status is None: - return Response(status_code=status.HTTP_204_NO_CONTENT) - - try: - remove_disc.execute(current_tag_status.tag_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - except ValueError as value_err: - raise HTTPException(status_code=404, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return router diff --git a/discstore/adapters/inbound/api/discs_router.py b/discstore/adapters/inbound/api/discs_router.py deleted file mode 100644 index a9ab4f81..00000000 --- a/discstore/adapters/inbound/api/discs_router.py +++ /dev/null @@ -1,74 +0,0 @@ -from fastapi import APIRouter, HTTPException, Response, status -from pydantic import ValidationError - -from discstore.adapters.inbound.api.models import DiscInput, DiscOutput, DiscPatchInput -from discstore.domain.entities import Disc, DiscMetadata, DiscOption -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc - - -def build_discs_router( - add_disc: AddDisc, - list_discs: ListDiscs, - remove_disc: RemoveDisc, - edit_disc: EditDisc, - get_disc: GetDisc, -) -> APIRouter: - router = APIRouter(prefix="/api/v1", tags=["discs"]) - - @router.get("/discs", response_model=dict[str, DiscOutput], summary="List discs") - def list_discs_route() -> dict[str, Disc]: - return list_discs.execute() - - @router.get("/discs/{tag_id}", response_model=DiscOutput, summary="Get a disc") - def get_disc_route(tag_id: str) -> Disc: - try: - return get_disc.execute(tag_id) - except ValueError as value_err: - raise HTTPException(status_code=404, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.post("/discs/{tag_id}", response_model=DiscOutput, status_code=201, summary="Create a disc") - def create_disc_route(tag_id: str, disc: DiscInput) -> Disc: - try: - new_disc = Disc(**disc.model_dump()) - return add_disc.execute(tag_id, new_disc) - except ValueError as value_err: - raise HTTPException(status_code=409, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.patch("/discs/{tag_id}", response_model=DiscOutput, summary="Update a disc") - def update_disc_route(tag_id: str, disc_patch: DiscPatchInput) -> Disc: - try: - metadata = None - if disc_patch.metadata is not None: - metadata = DiscMetadata(**disc_patch.metadata.model_dump(exclude_unset=True)) - - option = None - if disc_patch.option is not None: - option = DiscOption(**disc_patch.option.model_dump(exclude_unset=True)) - - return edit_disc.execute(tag_id, disc_patch.uri, metadata, option) - except ValidationError as err: - raise HTTPException(status_code=422, detail=err.errors()) - except ValueError as value_err: - raise HTTPException(status_code=404, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.delete("/discs/{tag_id}", status_code=204, summary="Delete a disc") - def remove_disc_route(tag_id: str) -> Response: - try: - remove_disc.execute(tag_id) - return Response(status_code=status.HTTP_204_NO_CONTENT) - except ValueError as value_err: - raise HTTPException(status_code=404, detail=str(value_err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return router diff --git a/discstore/adapters/inbound/api/models.py b/discstore/adapters/inbound/api/models.py deleted file mode 100644 index d2bd1ab2..00000000 --- a/discstore/adapters/inbound/api/models.py +++ /dev/null @@ -1,48 +0,0 @@ -from typing import Any - -from pydantic import BaseModel, RootModel - -from discstore.domain.entities import CurrentTagStatus, Disc - - -class DiscInput(Disc): - pass - - -class DiscOutput(Disc): - pass - - -class DiscPatchMetadataInput(BaseModel): - artist: str | None = None - album: str | None = None - track: str | None = None - playlist: str | None = None - - -class DiscPatchOptionInput(BaseModel): - shuffle: bool | None = None - is_test: bool | None = None - - -class DiscPatchInput(BaseModel): - uri: str | None = None - metadata: DiscPatchMetadataInput | None = None - option: DiscPatchOptionInput | None = None - - -class CurrentTagStatusOutput(CurrentTagStatus): - pass - - -class CurrentTagDiscOutput(BaseModel): - tag_id: str - disc: DiscOutput - - -class SettingsResetInput(BaseModel): - path: str - - -class SettingsPatchInput(RootModel[dict[str, Any]]): - pass diff --git a/discstore/adapters/inbound/api/settings_router.py b/discstore/adapters/inbound/api/settings_router.py deleted file mode 100644 index a266caff..00000000 --- a/discstore/adapters/inbound/api/settings_router.py +++ /dev/null @@ -1,46 +0,0 @@ -from typing import Any, cast - -from fastapi import APIRouter, HTTPException - -from discstore.adapters.inbound.api.models import SettingsPatchInput, SettingsResetInput -from jukebox.settings.errors import SettingsError -from jukebox.settings.service_protocols import SettingsService -from jukebox.settings.types import JsonObject - - -def build_settings_router(settings_service: SettingsService) -> APIRouter: - router = APIRouter(prefix="/api/v1", tags=["settings"]) - - @router.get("/settings", response_model=dict[str, Any], summary="Get persisted settings") - def get_settings() -> JsonObject: - try: - return settings_service.get_persisted_settings_view() - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.get("/settings/effective", response_model=dict[str, Any], summary="Get effective settings") - def get_effective_settings() -> JsonObject: - try: - return settings_service.get_effective_settings_view() - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.patch("/settings", response_model=dict[str, Any], summary="Patch persisted settings") - def patch_settings(patch: SettingsPatchInput) -> JsonObject: - try: - return settings_service.patch_persisted_settings(cast(JsonObject, patch.root)) - except SettingsError as err: - raise HTTPException(status_code=400, detail=str(err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @router.post("/settings/reset", response_model=dict[str, Any], summary="Reset a persisted setting") - def reset_settings(payload: SettingsResetInput) -> JsonObject: - try: - return settings_service.reset_persisted_value(payload.path) - except SettingsError as err: - raise HTTPException(status_code=400, detail=str(err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return router diff --git a/discstore/adapters/inbound/api_controller.py b/discstore/adapters/inbound/api_controller.py deleted file mode 100644 index 2f4442b4..00000000 --- a/discstore/adapters/inbound/api_controller.py +++ /dev/null @@ -1,188 +0,0 @@ -from pydantic import BaseModel - -from jukebox.shared.dependency_messages import optional_extra_dependency_message - -try: - from fastapi import FastAPI, HTTPException - - from discstore.adapters.inbound.api.current_tag_router import build_current_tag_router - from discstore.adapters.inbound.api.discs_router import build_discs_router - from discstore.adapters.inbound.api.models import ( - CurrentTagDiscOutput, - CurrentTagStatusOutput, - DiscInput, - DiscOutput, - DiscPatchInput, - SettingsPatchInput, - SettingsResetInput, - ) - from discstore.adapters.inbound.api.settings_router import build_settings_router -except ModuleNotFoundError as e: - if e.name != "fastapi": - raise - raise ModuleNotFoundError( - optional_extra_dependency_message("The `api_controller` module", "api", "jukebox-admin api") - ) from e -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc -from jukebox.settings.entities import SelectedSonosGroupSettings -from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository -from jukebox.settings.service_protocols import SettingsService -from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError -from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection -from jukebox.sonos.service import SonosService - -__all__ = [ - "APIController", - "CurrentTagDiscOutput", - "CurrentTagStatusOutput", - "DiscInput", - "DiscOutput", - "DiscPatchInput", - "SettingsPatchInput", - "SettingsResetInput", - "SonosSelectionInput", -] - - -class SonosSpeakerOutput(DiscoveredSonosSpeaker): - pass - - -class SelectedSonosGroupOutput(SelectedSonosGroupSettings): - pass - - -class SonosSelectionMemberAvailabilityOutput(BaseModel): - uid: str - status: str - speaker: SonosSpeakerOutput | None = None - - -class SonosSelectionAvailabilityOutput(BaseModel): - status: str - members: list[SonosSelectionMemberAvailabilityOutput] - - -class SonosSelectionOutput(BaseModel): - selected_group: SelectedSonosGroupOutput | None = None - availability: SonosSelectionAvailabilityOutput - - -class SonosSelectionInput(BaseModel): - uids: list[str] - coordinator_uid: str | None = None - - -class SonosSelectionUpdateOutput(BaseModel): - selected_group: SelectedSonosGroupOutput - availability: SonosSelectionAvailabilityOutput - message: str - restart_required: bool - - -class APIController: - def __init__( - self, - add_disc: AddDisc, - list_discs: ListDiscs, - remove_disc: RemoveDisc, - edit_disc: EditDisc, - get_disc: GetDisc, - get_current_tag_status: GetCurrentTagStatus, - settings_service: SettingsService, - sonos_service: SonosService, - ): - self.add_disc = add_disc - self.list_discs = list_discs - self.remove_disc = remove_disc - self.edit_disc = edit_disc - self.get_disc = get_disc - self.get_current_tag_status = get_current_tag_status - self.settings_service = settings_service - self.sonos_service = sonos_service - self.app = FastAPI( - title="Jukebox Admin API", - description="API for managing Jukebox disc library and settings", - docs_url="/docs", - redoc_url="/redoc", - ) - self.register_routes() - - def register_routes(self): - self.app.include_router( - build_discs_router( - add_disc=self.add_disc, - list_discs=self.list_discs, - remove_disc=self.remove_disc, - edit_disc=self.edit_disc, - get_disc=self.get_disc, - ) - ) - self.app.include_router( - build_current_tag_router( - get_current_tag_status=self.get_current_tag_status, - add_disc=self.add_disc, - edit_disc=self.edit_disc, - get_disc=self.get_disc, - remove_disc=self.remove_disc, - ) - ) - self.app.include_router(build_settings_router(self.settings_service)) - - @self.app.get("/api/v1/sonos/speakers", response_model=list[SonosSpeakerOutput]) - def get_sonos_speakers(): - try: - return self.sonos_service.list_network_speakers() - except SonosDiscoveryError as err: - raise HTTPException(status_code=502, detail=str(err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @self.app.get("/api/v1/sonos/selection", response_model=SonosSelectionOutput) - def get_sonos_selection(): - try: - return GetSonosSelectionStatus( - SettingsSelectedSonosGroupRepository(self.settings_service), - self.sonos_service, - ).execute() - except SonosDiscoveryError as err: - raise HTTPException(status_code=502, detail=str(err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - @self.app.put("/api/v1/sonos/selection", response_model=SonosSelectionUpdateOutput) - def put_sonos_selection(payload: SonosSelectionInput): - try: - result = SaveSonosSelection( - SettingsSelectedSonosGroupRepository(self.settings_service), - self.sonos_service, - ).execute(payload.uids, coordinator_uid=payload.coordinator_uid) - return SonosSelectionUpdateOutput( - selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()), - availability=SonosSelectionAvailabilityOutput( - status="available", - members=[ - SonosSelectionMemberAvailabilityOutput( - uid=member.uid, - status="available", - speaker=SonosSpeakerOutput(**member.model_dump()), - ) - for member in result.members - ], - ), - message=result.settings_message, - restart_required=result.restart_required, - ) - except SonosDiscoveryError as err: - raise HTTPException(status_code=502, detail=str(err)) - except ValueError as err: - raise HTTPException(status_code=400, detail=str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") diff --git a/discstore/adapters/inbound/cli_controller.py b/discstore/adapters/inbound/cli_controller.py deleted file mode 100644 index bfd56385..00000000 --- a/discstore/adapters/inbound/cli_controller.py +++ /dev/null @@ -1,126 +0,0 @@ -import logging - -from discstore.adapters.inbound.cli_display import display_library_line, display_library_table -from discstore.commands import ( - CliAddCommand, - CliEditCommand, - CliGetCommand, - CliListCommand, - CliRemoveCommand, - CliSearchCommand, -) -from discstore.domain.entities import Disc, DiscMetadata, DiscOption -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc -from discstore.domain.use_cases.resolve_tag_id import ResolveTagId -from discstore.domain.use_cases.search_discs import SearchDiscs - -LOGGER = logging.getLogger("discstore") - - -class CLIController: - def __init__( - self, - add_disc: AddDisc, - list_discs: ListDiscs, - remove_disc: RemoveDisc, - edit_disc: EditDisc, - get_disc: GetDisc, - search_discs: SearchDiscs, - resolve_tag_id: ResolveTagId, - ): - self.add_disc = add_disc - self.list_discs = list_discs - self.remove_disc = remove_disc - self.edit_disc = edit_disc - self.get_disc = get_disc - self.search_discs = search_discs - self.resolve_tag_id = resolve_tag_id - - def run( - self, - command: CliAddCommand | CliListCommand | CliRemoveCommand | CliEditCommand | CliGetCommand | CliSearchCommand, - ) -> None: - match command: - case CliAddCommand(): - self.add_disc_flow(command) - case CliListCommand(): - self.list_discs_flow(command) - case CliRemoveCommand(): - self.remove_disc_flow(command) - case CliEditCommand(): - self.edit_disc_flow(command) - case CliGetCommand(): - self.get_disc_flow(command) - case CliSearchCommand(): - self.search_discs_flow(command) - case _: - LOGGER.error("Command not implemented yet: command='%s'", command) - - def add_disc_flow(self, command: CliAddCommand) -> None: - tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) - option = DiscOption() - metadata = DiscMetadata(track=command.track, artist=command.artist, album=command.album) - - disc = Disc(uri=command.uri, metadata=metadata, option=option) - self.add_disc.execute(tag, disc) - LOGGER.info("āœ… Disc successfully added") - - def list_discs_flow(self, command: CliListCommand) -> None: - discs = self.list_discs.execute() - if command.mode == "table": - display_library_table(discs) - return - if command.mode == "line": - display_library_line(discs) - return - LOGGER.error("Displaying mode not implemented yet: mode='%s'", command.mode) - - def remove_disc_flow(self, command: CliRemoveCommand) -> None: - tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) - self.remove_disc.execute(tag) - LOGGER.info("šŸ—‘ļø Disc successfully removed") - - def edit_disc_flow(self, command: CliEditCommand) -> None: - metadata_fields = {} - if command.track is not None: - metadata_fields["track"] = command.track - if command.artist is not None: - metadata_fields["artist"] = command.artist - if command.album is not None: - metadata_fields["album"] = command.album - - metadata = DiscMetadata(**metadata_fields) if metadata_fields else None - - self.edit_disc.execute( - tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag), - uri=command.uri, - metadata=metadata, - option=None, - ) - LOGGER.info("āœ… Disc successfully edited") - - def get_disc_flow(self, command: CliGetCommand) -> None: - try: - tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag) - disc = self.get_disc.execute(tag) - print(f"\nšŸ“€ Disc: {tag}") - print(f" URI : {disc.uri}") - print(f" Artist : {disc.metadata.artist or '/'}") - print(f" Album : {disc.metadata.album or '/'}") - print(f" Track : {disc.metadata.track or '/'}") - print(f" Playlist : {disc.metadata.playlist or '/'}") - print(f" Shuffle : {disc.option.shuffle}") - except ValueError as e: - LOGGER.error(str(e)) - - def search_discs_flow(self, command: CliSearchCommand) -> None: - results = self.search_discs.execute(command.query) - if not results: - LOGGER.info("No discs found matching '%s'", command.query) - return - LOGGER.info("Found %d disc(s) matching '%s':", len(results), command.query) - display_library_table(results) diff --git a/discstore/adapters/inbound/cli_display.py b/discstore/adapters/inbound/cli_display.py deleted file mode 100644 index 76272269..00000000 --- a/discstore/adapters/inbound/cli_display.py +++ /dev/null @@ -1,58 +0,0 @@ -from discstore.domain.entities import Disc - -MAX_COL_WIDTH = 20 - - -def display_library_line(discs: dict[str, Disc]) -> None: - if not discs: - print("The library is empty") - return - - print("=== Discs Library ===\n") - for disc_id, disc in discs.items(): - print(f"ID : {disc_id}") - print(f" URI : {disc.uri}") - print(f" Artist : {disc.metadata.artist or '/'}") - print(f" Album : {disc.metadata.album or '/'}") - print(f" Track : {disc.metadata.track or '/'}") - print(f" Playlist : {disc.metadata.playlist or '/'}") - print(f" Shuffle : {disc.option.shuffle}") - print("-" * 30) - - -def truncate(text: str, max_length: int) -> str: - if len(text) <= max_length: - return text - return text[: max_length - 3] + "..." - - -def display_library_table(discs: dict[str, Disc]) -> None: - if not discs: - print("The library is empty") - return - - headers = ["ID", "URI", "Artist", "Album", "Track", "Playlist", "Shuffle"] - rows = [] - for disc_id, disc in discs.items(): - rows.append( - [ - truncate(str(disc_id), MAX_COL_WIDTH), - truncate(disc.uri, MAX_COL_WIDTH), - truncate(disc.metadata.artist or "/", MAX_COL_WIDTH), - truncate(disc.metadata.album or "/", MAX_COL_WIDTH), - truncate(disc.metadata.track or "/", MAX_COL_WIDTH), - truncate(disc.metadata.playlist or "/", MAX_COL_WIDTH), - str(disc.option.shuffle), - ] - ) - - cols = list(zip(*([headers] + rows))) - col_widths = [max(len(str(item)) for item in col) for col in cols] - - def format_line(line): - return " | ".join(f"{str(item):<{col_widths[i]}}" for i, item in enumerate(line)) - - print(format_line(headers)) - print("-+-".join("-" * w for w in col_widths)) - for row in rows: - print(format_line(row)) diff --git a/discstore/adapters/inbound/interactive_cli_controller.py b/discstore/adapters/inbound/interactive_cli_controller.py deleted file mode 100644 index 9aaa23fc..00000000 --- a/discstore/adapters/inbound/interactive_cli_controller.py +++ /dev/null @@ -1,132 +0,0 @@ -import logging - -from discstore.adapters.inbound.cli_display import display_library_line, display_library_table -from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc - -LOGGER = logging.getLogger("discstore") - - -class InteractiveCLIController: - available_commands = "\n* " + "\n* ".join(["add", "remove", "list", "edit", "current", "exit", "help"]) - help_message = f"\nAvailable commands: {available_commands}" - - def __init__( - self, - add_disc: AddDisc, - list_discs: ListDiscs, - remove_disc: RemoveDisc, - edit_disc: EditDisc, - get_current_tag_status: GetCurrentTagStatus, - ): - self.add_disc = add_disc - self.list_discs = list_discs - self.remove_disc = remove_disc - self.edit_disc = edit_disc - self.get_current_tag_status = get_current_tag_status - - def run(self) -> None: - print(self.help_message) - while True: - command = input("discstore> ") - self.handle_command(command) - - def handle_command(self, command: str) -> None: - try: - match command: - case "add": - self.add_disc_flow() - case "remove": - self.remove_disc_flow() - case "list": - self.list_discs_flow() - case "edit": - self.edit_disc_flow() - case "current": - self.current_tag_flow() - case "exit": - print("See you soon!") - exit(0) - case "help": - print(self.help_message) - case _: - print(f"Invalid command `{command}`") - print(self.help_message) - except Exception as err: - print(f"Error: {err}") - LOGGER.error("Error during handling command: %s", err) - - def add_disc_flow(self) -> None: - print("\n-- Add a disc --") - current_tag_status = self.get_current_tag_status.execute() - tag = self._prompt_for_tag(current_tag_status, action="add") - uri = input("discstore> add uri> ").strip() - option = DiscOption() - metadata = DiscMetadata() - - disc = Disc(uri=uri, metadata=metadata, option=option) - self.add_disc.execute(tag, disc) - print("āœ… Disc successfully added") - - def list_discs_flow(self) -> None: - print("\n-- List all discs --") - mode = input("discstore> list mode(table/line)> ").strip() - - discs = self.list_discs.execute() - if mode == "table" or mode == "": - display_library_table(discs) - return - if mode == "line": - display_library_line(discs) - return - print(f"Displaying mode not implemented yet: `{mode}`") - - def remove_disc_flow(self) -> None: - print("\n-- Remove a disc --") - tag = input("discstore> remove tag> ").strip() - self.remove_disc.execute(tag) - print("šŸ—‘ļø Disc successfully removed") - - def edit_disc_flow(self) -> None: - print("\n-- Edit a disc --") - current_tag_status = self.get_current_tag_status.execute() - tag = self._prompt_for_tag(current_tag_status, action="edit") - uri = input("discstore> edit uri> ").strip() - option = DiscOption() - metadata = DiscMetadata() - - self.edit_disc.execute(tag, uri, metadata, option) - print("āœ… Disc successfully edited") - - def current_tag_flow(self) -> None: - print("\n-- Current tag --") - current_tag_status = self.get_current_tag_status.execute() - if current_tag_status is None: - print("No current tag is available") - return - - print(f"Tag ID : {current_tag_status.tag_id}") - print(f"Known in library : {'yes' if current_tag_status.known_in_library else 'no'}") - - def _prompt_for_tag(self, current_tag_status: CurrentTagStatus | None, action: str) -> str: - default_tag = "" - if current_tag_status is not None and ( - (action == "add" and not current_tag_status.known_in_library) - or (action == "edit" and current_tag_status.known_in_library) - ): - default_tag = current_tag_status.tag_id - prompt = f"discstore> {action} tag" - if default_tag: - prompt += f" [{default_tag}]" - prompt += "> " - - entered_tag = input(prompt).strip() - tag = entered_tag or default_tag - if not tag: - raise ValueError("A tag ID is required.") - - return tag diff --git a/discstore/adapters/inbound/ui_controller.py b/discstore/adapters/inbound/ui_controller.py deleted file mode 100644 index db291c5d..00000000 --- a/discstore/adapters/inbound/ui_controller.py +++ /dev/null @@ -1,505 +0,0 @@ -from collections.abc import AsyncIterator -from typing import Annotated - -from jukebox.shared.dependency_messages import optional_extra_dependency_message - -try: - from fastapi import HTTPException, Request - from fastapi.responses import HTMLResponse, StreamingResponse - from fastui import AnyComponent, FastUI, prebuilt_html - from fastui import components as c - from fastui.events import GoToEvent - from fastui.forms import fastui_form -except ModuleNotFoundError as e: - raise ModuleNotFoundError( - optional_extra_dependency_message("The `ui_controller` module", "ui", "jukebox-admin ui") - ) from e - -from pydantic import BaseModel, Field - -from discstore.adapters.inbound.api_controller import APIController -from discstore.adapters.inbound.ui_pages.library import DiscForm, DiscTable, LibraryUIPageBuilder -from discstore.adapters.inbound.ui_pages.settings import SettingsUIPageBuilder -from discstore.adapters.inbound.ui_pages.sonos import SonosSelectionForm, SonosUIPageBuilder -from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc -from jukebox.settings.definitions import ( - EditableSettingDisplay, - get_setting_definition, -) -from jukebox.settings.errors import SettingsError -from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository -from jukebox.settings.service_protocols import SettingsService -from jukebox.settings.types import JsonObject, JsonValue -from jukebox.sonos.discovery import SonosDiscoveryError -from jukebox.sonos.selection import SaveSonosSelection -from jukebox.sonos.service import SonosService - - -class SettingValueForm(BaseModel): - value: str = Field(title="Value") - - -class UIController(APIController): - def __init__( - self, - add_disc: AddDisc, - list_discs: ListDiscs, - remove_disc: RemoveDisc, - edit_disc: EditDisc, - get_disc: GetDisc, - get_current_tag_status: GetCurrentTagStatus, - settings_service: SettingsService, - sonos_service: SonosService, - ): - self.get_disc = get_disc - self.library_pages = LibraryUIPageBuilder( - list_discs=list_discs, - get_disc=get_disc, - get_current_tag_status=get_current_tag_status, - ) - self.sonos_pages = SonosUIPageBuilder(settings_service=settings_service, sonos_service=sonos_service) - self.settings_pages = SettingsUIPageBuilder(settings_service=settings_service) - super().__init__( - add_disc, - list_discs, - remove_disc, - edit_disc, - get_disc, - get_current_tag_status, - settings_service, - sonos_service, - ) - - def register_routes(self): - super().register_routes() - - @self.app.get("/api/ui/", response_model=FastUI, response_model_exclude_none=True) - def list_discs(toast: str | None = None) -> list[AnyComponent]: - return self._build_index_page_components(toast=toast) - - @self.app.get("/api/ui/current-tag-banner/events") - async def get_current_tag_banner_events(request: Request) -> StreamingResponse: - return StreamingResponse( - self._current_tag_banner_event_stream(request), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no", - }, - ) - - @self.app.get("/api/ui/discs/new", response_model=FastUI, response_model_exclude_none=True) - def new_disc_form(prefill: str | None = None) -> list[AnyComponent]: - return self._build_form_page_components( - title="Add disc", - form_components=self._build_new_disc_form_components(prefill_current=(prefill == "current")), - ) - - @self.app.post("/api/ui/discs", response_model=FastUI, response_model_exclude_none=True) - async def create_disc(disc: Annotated[DiscForm, fastui_form(DiscForm)]) -> list[AnyComponent]: - metadata = DiscMetadata( - artist=disc.artist, - album=disc.album, - track=disc.track, - ) - option = DiscOption(shuffle=disc.shuffle) - - try: - self.add_disc.execute(disc.tag, Disc(uri=disc.uri, metadata=metadata, option=option)) - except ValueError as err: - raise self._field_validation_error("tag", str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self._build_success_response("toast-add-disc-success") - - @self.app.get("/api/ui/discs/{tag_id}/edit", response_model=FastUI, response_model_exclude_none=True) - def edit_disc_form(tag_id: str) -> list[AnyComponent]: - return self._build_form_page_components( - title=f"Edit disc {tag_id}", - form_components=self._build_edit_disc_form_components(tag_id), - ) - - @self.app.post("/api/ui/discs/{tag_id}", response_model=FastUI, response_model_exclude_none=True) - async def update_disc( - tag_id: str, - disc: Annotated[DiscForm, fastui_form(DiscForm)], - ) -> list[AnyComponent]: - metadata = DiscMetadata( - artist=disc.artist, - album=disc.album, - track=disc.track, - ) - option = DiscOption(shuffle=disc.shuffle) - - try: - if disc.tag != tag_id: - raise HTTPException( - status_code=422, - detail={ - "form": [ - { - "loc": ["tag"], - "msg": "Editing tag IDs is not supported.", - } - ] - }, - ) - self.edit_disc.execute(tag_id=tag_id, uri=disc.uri, metadata=metadata, option=option) - except ValueError as err: - raise self._field_validation_error("tag", str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self._build_success_response("toast-edit-disc-success") - - @self.app.get("/api/ui/discs/{tag_id}/delete", response_model=FastUI, response_model_exclude_none=True) - def delete_disc_confirmation(tag_id: str) -> list[AnyComponent]: - return self._build_form_page_components( - title=f"Delete disc {tag_id}", - form_components=self._build_delete_disc_form_components(tag_id), - ) - - # Fast-UI buttons and forms do not support the DELETE method directly. So we cannot call DELETE on - # /api/ui/discs/{tag_id}. Instead, we just use POST on /api/ui/discs/{tag_id}/delete. - @self.app.post("/api/ui/discs/{tag_id}/delete", response_model=FastUI, response_model_exclude_none=True) - async def delete_disc(tag_id: str) -> list[AnyComponent]: - try: - self.remove_disc.execute(tag_id) - except ValueError as err: - raise HTTPException(status_code=404, detail=str(err)) - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self._build_success_response("toast-remove-disc-success") - - @self.app.get("/api/ui/settings", response_model=FastUI, response_model_exclude_none=True) - def settings_page(toast: str | None = None, toast_message: str | None = None) -> list[AnyComponent]: - return self._build_settings_page_components(toast=toast, toast_message=toast_message) - - @self.app.get("/api/ui/settings/{setting_path}/edit", response_model=FastUI, response_model_exclude_none=True) - def edit_setting_form(setting_path: str) -> list[AnyComponent]: - return self._build_settings_edit_page_components(setting_path) - - @self.app.post("/api/ui/settings/{setting_path}", response_model=FastUI, response_model_exclude_none=True) - async def update_setting( - setting_path: str, - form: Annotated[SettingValueForm, fastui_form(SettingValueForm)], - ) -> list[AnyComponent]: - definition = get_setting_definition(setting_path) - if definition is None: - raise HTTPException(status_code=404, detail=f"Unknown setting path: {setting_path}") - - try: - patch = self._build_settings_patch(setting_path, form.value) - result = self.settings_service.patch_persisted_settings(patch) - except ValueError as err: - raise self._field_validation_error("value", str(err)) - except SettingsError as err: - if self._persisted_value_matches(setting_path, self._lookup_optional_dotted_path(patch, setting_path)): - return self._build_settings_success_response( - "Settings saved, but effective settings are still unavailable." - ) - raise self._field_validation_error("value", str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self._build_settings_success_response(str(result["message"])) - - @self.app.post("/api/ui/settings/{setting_path}/reset", response_model=FastUI, response_model_exclude_none=True) - async def reset_setting(setting_path: str) -> list[AnyComponent]: - return self._reset_setting(setting_path) - - @self.app.get("/api/ui/sonos", response_model=FastUI, response_model_exclude_none=True) - def sonos_page(toast: str | None = None, toast_message: str | None = None) -> list[AnyComponent]: - return self._build_sonos_page_components(toast=toast, toast_message=toast_message) - - @self.app.get("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True) - def edit_sonos_form( - error_message: str | None = None, - uids: list[str] | None = None, - coordinator_uid: str | None = None, - ) -> list[AnyComponent]: - field_errors = None - if error_message: - field_errors = {self._sonos_field_name_for_error(error_message): error_message} - return self._build_sonos_edit_page_components( - error_message=error_message, - field_errors=field_errors, - submitted_uids=uids, - submitted_coordinator_uid=coordinator_uid, - ) - - @self.app.post("/api/ui/sonos/edit", response_model=FastUI, response_model_exclude_none=True) - async def update_sonos_selection( - form: Annotated[SonosSelectionForm, fastui_form(SonosSelectionForm)], - ) -> list[AnyComponent]: - try: - result = SaveSonosSelection( - selected_group_repository=SettingsSelectedSonosGroupRepository(self.settings_service), - sonos_service=self.sonos_service, - ).execute(form.uids, coordinator_uid=form.coordinator_uid) - except SonosDiscoveryError as err: - raise HTTPException(status_code=502, detail=str(err)) - except SettingsError as err: - display_message = self._build_sonos_error_message(str(err), form.coordinator_uid) - if self._persisted_sonos_selection_matches(form.uids, form.coordinator_uid): - return self.sonos_pages.build_sonos_success_response( - "Sonos selection saved, but effective settings are still unavailable." - ) - return self.sonos_pages.build_sonos_edit_error_response( - display_message, - form.uids, - form.coordinator_uid, - ) - except ValueError as err: - return self.sonos_pages.build_sonos_edit_error_response( - self._build_sonos_error_message(str(err), form.coordinator_uid), - form.uids, - form.coordinator_uid, - ) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self.sonos_pages.build_sonos_success_response(str(result.settings_message)) - - @self.app.post("/api/ui/sonos/reset", response_model=FastUI, response_model_exclude_none=True) - async def reset_sonos_selection() -> list[AnyComponent]: - return self._reset_sonos_selection() - - @self.app.get("/{path:path}") - def html_landing(path: str) -> HTMLResponse: - del path - return HTMLResponse(prebuilt_html(title="Jukebox Admin", api_root_url="/api/ui")) - - def _build_success_response(self, toast_event_name: str) -> list[AnyComponent]: - return [ - c.FireEvent(event=GoToEvent(url=f"/?toast={toast_event_name}")), - ] - - def _build_settings_success_response(self, message: str) -> list[AnyComponent]: - return self.settings_pages.build_settings_success_response(message) - - def _reset_setting(self, setting_path: str) -> list[AnyComponent]: - return self.settings_pages.reset_setting(setting_path) - - def _build_sonos_page_components( - self, - toast: str | None = None, - toast_message: str | None = None, - error_message: str | None = None, - ) -> list[AnyComponent]: - return self.sonos_pages.build_sonos_page_components( - toast=toast, - toast_message=toast_message, - error_message=error_message, - ) - - def _build_sonos_edit_page_components( - self, - error_message: str | None = None, - field_errors: dict[str, str] | None = None, - submitted_uids: list[str] | None = None, - submitted_coordinator_uid: str | None = None, - ) -> list[AnyComponent]: - return self.sonos_pages.build_sonos_edit_page_components( - error_message=error_message, - field_errors=field_errors, - submitted_uids=submitted_uids, - submitted_coordinator_uid=submitted_coordinator_uid, - ) - - def _reset_sonos_selection(self) -> list[AnyComponent]: - selected_group_repository = SettingsSelectedSonosGroupRepository(self.settings_service) - try: - result = self.settings_service.reset_persisted_value("jukebox.player.sonos.selected_group") - except SettingsError as err: - if selected_group_repository.get_selected_group() is None: - return self.sonos_pages.build_sonos_success_response( - "Sonos selection cleared, but effective settings are still unavailable." - ) - return self._build_sonos_page_components(error_message=str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self.sonos_pages.build_sonos_success_response(str(result.get("message", "Settings saved."))) - - def _persisted_sonos_selection_matches(self, uids: list[str], coordinator_uid: str | None) -> bool: - try: - selected_group = SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group() - except Exception: - return False - - if selected_group is None: - return False - - expected_coordinator_uid = coordinator_uid or (uids[0] if uids else None) - return ( - selected_group.coordinator_uid == expected_coordinator_uid - and [member.uid for member in selected_group.members] == uids - ) - - def _build_sonos_error_message(self, message: str, coordinator_uid: str | None) -> str: - prefix = "Selected Sonos coordinator must be one of the selected speakers: " - if coordinator_uid is None or not message.startswith(prefix): - return message - - try: - speakers = self.sonos_service.list_network_speakers() - except Exception: - return message - - speaker = next((speaker for speaker in speakers if speaker.uid == coordinator_uid), None) - if speaker is None: - return message - - return f"{prefix}{speaker.name} [{speaker.uid}]" - - def _build_index_page_components(self, toast: str | None = None) -> list[AnyComponent]: - return self.library_pages.build_index_page_components(toast=toast) - - def _build_settings_page_components( - self, - toast: str | None = None, - toast_message: str | None = None, - ) -> list[AnyComponent]: - return self.settings_pages.build_settings_page_components(toast=toast, toast_message=toast_message) - - def _build_settings_section_components( - self, - section: str, - settings: list[EditableSettingDisplay], - ) -> list[AnyComponent]: - return self.settings_pages.build_settings_section_components(section, settings) - - def _build_settings_row(self, setting: EditableSettingDisplay, index: int) -> AnyComponent: - return self.settings_pages.build_settings_row(setting, index) - - def _build_settings_edit_page_components( - self, - setting_path: str, - reset_error: str | None = None, - ) -> list[AnyComponent]: - return self.settings_pages.build_settings_edit_page_components(setting_path, reset_error=reset_error) - - def _build_settings_edit_form(self, setting: EditableSettingDisplay) -> AnyComponent: - return self.settings_pages.build_settings_edit_form(setting) - - def _build_settings_reset_form(self, setting_path: str) -> AnyComponent: - return self.settings_pages.build_settings_reset_form(setting_path) - - def _get_settings_displays(self) -> tuple[list[EditableSettingDisplay], str | None]: - return self.settings_pages.get_settings_displays() - - def _build_settings_badges(self, setting: EditableSettingDisplay) -> list[AnyComponent]: - return self.settings_pages.build_settings_badges(setting) - - def _build_settings_value_summary(self, setting: EditableSettingDisplay) -> AnyComponent: - return self.settings_pages.build_settings_value_summary(setting) - - def _build_settings_value_cell(self, label: str, value: str) -> AnyComponent: - return self.settings_pages.build_settings_value_cell(label, value) - - def _build_settings_edit_guidance(self, setting: EditableSettingDisplay) -> str: - return self.settings_pages.build_settings_edit_guidance(setting) - - def _build_settings_patch(self, setting_path: str, raw_value: str) -> JsonObject: - return self.settings_pages.build_settings_patch(setting_path, raw_value) - - def _build_dotted_patch(self, dotted_path: str, value: JsonValue) -> JsonObject: - return self.settings_pages.build_dotted_patch(dotted_path, value) - - def _persisted_value_matches(self, dotted_path: str, expected_value: object) -> bool: - return self.settings_pages.persisted_value_matches(dotted_path, expected_value) - - def _has_persisted_value(self, dotted_path: str) -> bool: - return self.settings_pages.has_persisted_value(dotted_path) - - def _lookup_optional_dotted_path(self, root: JsonObject, dotted_path: str) -> object: - return self.settings_pages.lookup_optional_dotted_path(root, dotted_path) - - def _format_settings_display_value(self, setting_path: str, value: object) -> str: - return self.settings_pages.format_settings_display_value(setting_path, value) - - def _format_settings_provenance(self, provenance: str) -> str: - return self.settings_pages.format_settings_provenance(provenance) - - def _build_form_page_components(self, title: str, form_components: list[AnyComponent]) -> list[AnyComponent]: - return self.library_pages.build_form_page_components(title=title, form_components=form_components) - - def _build_current_tag_banner_components(self, current_tag_status: CurrentTagStatus | None) -> list[AnyComponent]: - return self.library_pages.build_current_tag_banner_components(current_tag_status) - - def _build_disc_library_components(self, discs: list[DiscTable]) -> list[AnyComponent]: - return self.library_pages.build_disc_library_components(discs) - - def _build_disc_library_header(self) -> AnyComponent: - return self.library_pages._build_disc_library_header() - - def _build_disc_library_row(self, disc: DiscTable) -> AnyComponent: - return self.library_pages._build_disc_library_row(disc) - - def _build_disc_header_cell(self, label: str, class_name: str) -> AnyComponent: - return self.library_pages._build_disc_header_cell(label, class_name) - - def _build_disc_value_cell(self, label: str, value: str | None, class_name: str) -> AnyComponent: - return self.library_pages._build_disc_value_cell(label, value, class_name) - - def _build_new_disc_form_components(self, prefill_current: bool) -> list[AnyComponent]: - return self.library_pages.build_new_disc_form_components(prefill_current) - - def _build_edit_disc_form_components(self, tag_id: str) -> list[AnyComponent]: - return self.library_pages.build_edit_disc_form_components(tag_id) - - def _build_delete_disc_form_components(self, tag_id: str) -> list[AnyComponent]: - return self.library_pages.build_delete_disc_form_components(tag_id) - - async def _current_tag_banner_event_stream( - self, - request: Request, - poll_interval_seconds: float = 0.5, - ) -> AsyncIterator[bytes]: - async for payload in self.library_pages.current_tag_banner_event_stream(request, poll_interval_seconds): - yield payload - - def _serialize_current_tag_components(self, components: list[AnyComponent]) -> str: - return self.library_pages.serialize_current_tag_components(components) - - @staticmethod - def _sonos_field_name_for_error(message: str) -> str: - if "coordinator" in message: - return "coordinator_uid" - return "uids" - - def _field_validation_error(self, field_name: str, message: str) -> HTTPException: - return HTTPException( - status_code=422, - detail={ - "form": [ - { - "loc": [field_name], - "msg": message, - } - ] - }, - ) - - -c.Page.model_rebuild() diff --git a/discstore/adapters/inbound/ui_pages/__init__.py b/discstore/adapters/inbound/ui_pages/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/adapters/inbound/ui_pages/library.py b/discstore/adapters/inbound/ui_pages/library.py deleted file mode 100644 index a0f0528b..00000000 --- a/discstore/adapters/inbound/ui_pages/library.py +++ /dev/null @@ -1,375 +0,0 @@ -import asyncio -import json -from collections.abc import AsyncIterator - -from fastapi import Request -from fastui import AnyComponent -from fastui import components as c -from fastui.events import BackEvent, GoToEvent, PageEvent -from pydantic import BaseModel, Field - -from discstore.domain.entities import CurrentTagStatus, DiscMetadata, DiscOption -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs - - -class DiscTable(DiscMetadata, DiscOption): - tag: str = Field(title="Tag ID") - uri: str = Field(title="URI / Path") - - -class DiscForm(BaseModel): - tag: str = Field(title="Tag ID") - uri: str = Field(title="URI / Path") - artist: str | None = Field(None, title="Artist") - album: str | None = Field(None, title="Album") - track: str | None = Field(None, title="Track") - shuffle: bool = Field(False, title="Shuffle") - - -class LibraryUIPageBuilder: - def __init__( - self, - list_discs: ListDiscs, - get_disc: GetDisc, - get_current_tag_status: GetCurrentTagStatus, - ): - self.list_discs = list_discs - self.get_disc = get_disc - self.get_current_tag_status = get_current_tag_status - - def build_index_page_components(self, toast: str | None = None) -> list[AnyComponent]: - discs = self.list_discs.execute() - discs_list = [ - DiscTable(tag=tag, uri=disc.uri, **disc.metadata.model_dump(), **disc.option.model_dump()) - for tag, disc in discs.items() - ] - - components: list[AnyComponent] = [ - c.Heading(text="Jukebox Admin", level=1), - c.Paragraph(text=f"šŸ“€ {len(discs)} disc(s) in library"), - c.ServerLoad( - path="/current-tag-banner/events", - sse=True, - sse_retry=2000, - ), - c.Div( - class_name="d-flex flex-wrap gap-2", - components=[ - c.Button(text="āž• Add a new disc", on_click=GoToEvent(url="/discs/new")), - c.Button( - text="šŸ”Š Sonos Speakers", - on_click=GoToEvent(url="/sonos"), - class_name="btn btn-secondary", - ), - c.Button(text="āš™ļø Settings", on_click=GoToEvent(url="/settings"), class_name="btn btn-secondary"), - ], - ), - c.Toast( - title="Toast", - body=[c.Paragraph(text="šŸŽ‰ Disc added")], - open_trigger=PageEvent(name="toast-add-disc-success"), - position="bottom-end", - ), - c.Toast( - title="Toast", - body=[c.Paragraph(text="šŸŽ‰ Disc edited")], - open_trigger=PageEvent(name="toast-edit-disc-success"), - position="bottom-end", - ), - c.Toast( - title="Toast", - body=[c.Paragraph(text="šŸ—‘ļø Disc removed")], - open_trigger=PageEvent(name="toast-remove-disc-success"), - position="bottom-end", - ), - *self.build_disc_library_components(discs_list), - ] - - page_components: list[AnyComponent] = [c.Page(components=components)] - - if toast in {"toast-add-disc-success", "toast-edit-disc-success", "toast-remove-disc-success"}: - page_components.append(c.FireEvent(event=PageEvent(name=toast))) - - return page_components - - def build_form_page_components(self, title: str, form_components: list[AnyComponent]) -> list[AnyComponent]: - return [ - c.Page( - components=[ - c.Heading(text=title, level=1), - *form_components, - c.Div( - class_name="mt-3", - components=[ - c.Link( - components=[c.Text(text="Back to Library")], - on_click=GoToEvent(url="/"), - ) - ], - ), - ] - ) - ] - - def build_current_tag_banner_components( - self, - current_tag_status: CurrentTagStatus | None, - ) -> list[AnyComponent]: - if current_tag_status is None: - return [] - - if current_tag_status.known_in_library: - return [ - c.Div( - class_name="alert alert-info mb-3 d-flex flex-column flex-md-row gap-3 justify-content-between align-items-md-center", - components=[ - c.Div( - class_name="mb-0", - components=[ - c.Heading(text="Known disc on reader", level=4), - c.Paragraph(text=f'Tag "{current_tag_status.tag_id}" is already in the library.'), - ], - ), - c.Button( - text="Edit this disc", - on_click=GoToEvent(url=f"/discs/{current_tag_status.tag_id}/edit"), - ), - ], - ) - ] - - return [ - c.Div( - class_name="alert alert-warning mb-3 d-flex flex-column flex-md-row gap-3 justify-content-between align-items-md-center", - components=[ - c.Div( - class_name="mb-0", - components=[ - c.Heading(text="Unknown disc on reader", level=4), - c.Paragraph(text=f'Tag "{current_tag_status.tag_id}" is ready to be added to the library.'), - ], - ), - c.Button(text="Add this disc", on_click=GoToEvent(url="/discs/new?prefill=current")), - ], - ) - ] - - def build_disc_library_components(self, discs: list[DiscTable]) -> list[AnyComponent]: - if not discs: - return [c.Paragraph(text="No disc found")] - - return [ - c.Div( - class_name="border rounded mt-3 mb-5 overflow-hidden", - components=[ - self._build_disc_library_header(), - *[self._build_disc_library_row(disc) for disc in discs], - ], - ) - ] - - def build_new_disc_form_components(self, prefill_current: bool) -> list[AnyComponent]: - initial = None - - if prefill_current: - current_tag_status = self.get_current_tag_status.execute() - if current_tag_status is None: - return [ - c.Error( - title="No current tag available", - description="There is no tag on the reader right now, so the form cannot be prefilled.", - ) - ] - if current_tag_status.known_in_library: - return [ - c.Error( - title="Current tag already known", - description=f'Tag "{current_tag_status.tag_id}" is already in the library.', - ) - ] - initial = {"tag": current_tag_status.tag_id, "shuffle": False} - - return [ - c.ModelForm( - model=DiscForm, - submit_url="/api/ui/discs", - method="POST", - initial=initial, - ) - ] - - def build_edit_disc_form_components(self, tag_id: str) -> list[AnyComponent]: - if not tag_id: - return [ - c.Error( - title="No disc selected", - description="Edit mode requires an existing disc tag ID.", - ) - ] - try: - disc = self.get_disc.execute(tag_id) - except ValueError as err: - return [ - c.Error( - title="Disc not found", - description=str(err), - ) - ] - - return [ - c.ModelForm( - model=DiscForm, - submit_url=f"/api/ui/discs/{tag_id}", - method="POST", - initial={ - "tag": tag_id, - "uri": disc.uri, - "artist": disc.metadata.artist, - "album": disc.metadata.album, - "track": disc.metadata.track, - "shuffle": disc.option.shuffle, - }, - ), - c.Button( - text="šŸ—‘ļø Delete this disc", - on_click=GoToEvent(url=f"/discs/{tag_id}/delete"), - class_name="btn btn-danger mt-3", - ), - ] - - def build_delete_disc_form_components(self, tag_id: str) -> list[AnyComponent]: - if not tag_id: - return [c.Error(title="No disc selected", description="Delete mode requires an existing disc tag ID.")] - try: - _ = self.get_disc.execute(tag_id) - except ValueError as err: - return [c.Error(title="Disc not found", description=str(err))] - - return [ - c.Paragraph(text=f'Are you sure you want to delete the disc with tag "{tag_id}"?'), - c.Div( - class_name="alert alert-danger", - components=[c.Paragraph(text="This action cannot be undone.")], - ), - c.Div( - class_name="d-flex gap-2 mt-3", - components=[ - c.Form( - form_fields=[], - submit_url=f"/api/ui/discs/{tag_id}/delete", - method="POST", - ), - c.Button( - text="Cancel", - on_click=BackEvent(), - class_name="btn btn-secondary", - ), - ], - ), - ] - - async def current_tag_banner_event_stream( - self, - request: Request, - poll_interval_seconds: float = 0.5, - ) -> AsyncIterator[bytes]: - previous_payload: str | None = None - - while True: - payload = self.serialize_current_tag_components( - self.build_current_tag_banner_components(self.get_current_tag_status.execute()) - ) - if payload != previous_payload: - previous_payload = payload - yield f"data: {payload}\n\n".encode() - - if await request.is_disconnected(): - break - - await asyncio.sleep(poll_interval_seconds) - - @staticmethod - def serialize_current_tag_components(components: list[AnyComponent]) -> str: - return json.dumps([component.model_dump(by_alias=True, exclude_none=True) for component in components]) - - def _build_disc_library_header(self) -> AnyComponent: - return c.Div( - class_name="d-none d-lg-block px-3 py-2 bg-light-subtle", - components=[ - c.Div( - class_name="row g-2 align-items-center", - components=[ - self._build_disc_header_cell("Tag ID", "col-lg"), - self._build_disc_header_cell("URI / Path", "col-lg-3"), - self._build_disc_header_cell("Artist", "col-lg-2 text-lg-center"), - self._build_disc_header_cell("Album", "col-lg-2 text-lg-center"), - self._build_disc_header_cell("Track", "col-lg-2 text-lg-center"), - self._build_disc_header_cell("Shuffle", "col-lg-1 text-lg-center"), - c.Div( - class_name="col-lg-auto d-flex justify-content-lg-end", - components=[ - c.Button( - text="Edit āœļø", - class_name="btn btn-secondary invisible", - ) - ], - ), - ], - ) - ], - ) - - def _build_disc_library_row(self, disc: DiscTable) -> AnyComponent: - return c.Div( - class_name="px-3 py-2 border-top", - components=[ - c.Div( - class_name="row g-2 align-items-center", - components=[ - self._build_disc_value_cell("Tag ID", disc.tag, "col-12 col-lg"), - self._build_disc_value_cell("URI / Path", disc.uri, "col-12 col-lg-3"), - self._build_disc_value_cell("Artist", disc.artist, "col-6 col-md-3 col-lg-2 text-lg-center"), - self._build_disc_value_cell("Album", disc.album, "col-6 col-md-3 col-lg-2 text-lg-center"), - self._build_disc_value_cell("Track", disc.track, "col-6 col-md-3 col-lg-2 text-lg-center"), - self._build_disc_value_cell( - "Shuffle", "āœ“" if disc.shuffle else "Ɨ", "col-6 col-md-3 col-lg-1 text-lg-center" - ), - c.Div( - class_name="col-12 col-lg-auto d-flex justify-content-lg-end", - components=[ - c.Button( - text="Edit āœļø", - on_click=GoToEvent(url=f"/discs/{disc.tag}/edit"), - class_name="btn btn-secondary", - ), - ], - ), - ], - ) - ], - ) - - def _build_disc_header_cell(self, label: str, class_name: str) -> AnyComponent: - justify_class = "justify-content-lg-start" - if "text-lg-center" in class_name: - justify_class = "justify-content-lg-center" - elif "text-lg-end" in class_name: - justify_class = "justify-content-lg-end" - - return c.Div( - class_name=f"{class_name} d-flex align-items-center {justify_class}", - components=[ - c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-0"), - ], - ) - - def _build_disc_value_cell(self, label: str, value: str | None, class_name: str) -> AnyComponent: - return c.Div( - class_name=class_name, - components=[ - c.Paragraph(text=label, class_name="d-lg-none text-uppercase text-muted small fw-semibold mb-1"), - c.Paragraph(text=value or "—", class_name="mb-0 text-break"), - ], - ) diff --git a/discstore/adapters/inbound/ui_pages/settings.py b/discstore/adapters/inbound/ui_pages/settings.py deleted file mode 100644 index 763a16c2..00000000 --- a/discstore/adapters/inbound/ui_pages/settings.py +++ /dev/null @@ -1,525 +0,0 @@ -import json -from itertools import groupby -from typing import cast -from urllib.parse import urlencode - -from fastapi import HTTPException -from fastui import AnyComponent -from fastui import components as c -from fastui.components.forms import FormFieldInput, FormFieldSelect, FormFieldTextarea -from fastui.events import GoToEvent, PageEvent -from fastui.forms import SelectOption - -from jukebox.settings.definitions import ( - EditableSettingDisplay, - build_editable_setting_displays, - get_setting_definition, -) -from jukebox.settings.errors import SettingsError -from jukebox.settings.service_protocols import SettingsService -from jukebox.settings.types import JsonObject, JsonValue - -_MISSING = object() - - -class SettingsUIPageBuilder: - def __init__(self, settings_service: SettingsService): - self.settings_service = settings_service - - def build_settings_success_response(self, message: str) -> list[AnyComponent]: - query = urlencode( - { - "toast": "toast-settings-success", - "toast_message": message, - } - ) - return [ - c.FireEvent(event=GoToEvent(url=f"/settings?{query}")), - ] - - def reset_setting(self, setting_path: str) -> list[AnyComponent]: - definition = get_setting_definition(setting_path) - if definition is None: - raise HTTPException(status_code=404, detail=f"Unknown setting path: {setting_path}") - - try: - result = self.settings_service.reset_persisted_value(setting_path) - except SettingsError as err: - if not self.has_persisted_value(setting_path): - return self.build_settings_success_response( - "Settings reset, but effective settings are still unavailable." - ) - return self.build_settings_edit_page_components(setting_path, reset_error=str(err)) - except HTTPException: - raise - except Exception as err: - raise HTTPException(status_code=500, detail=f"Server error: {str(err)}") - - return self.build_settings_success_response(str(result["message"])) - - def build_settings_page_components( - self, - toast: str | None = None, - toast_message: str | None = None, - ) -> list[AnyComponent]: - settings, effective_settings_error = self.get_settings_displays() - components: list[AnyComponent] = [ - c.Heading(text="Settings", level=1), - c.Div( - class_name="d-flex flex-wrap gap-2 mb-4", - components=[ - c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), - ], - ), - ] - if effective_settings_error: - components.append( - c.Error( - title="Effective settings unavailable", - description=( - f"{effective_settings_error} Persisted overrides are still shown below so you can inspect" - " and repair saved values." - ), - ) - ) - - for section, entries_iter in groupby(settings, key=lambda entry: entry.section): - entries = list(entries_iter) - components.extend(self.build_settings_section_components(section, entries)) - - components.append( - c.Toast( - title="Toast", - body=[c.Paragraph(text=toast_message or "Settings saved.")], - open_trigger=PageEvent(name="toast-settings-success"), - position="bottom-end", - ) - ) - - page_components: list[AnyComponent] = [c.Page(components=components)] - if toast == "toast-settings-success": - page_components.append(c.FireEvent(event=PageEvent(name=toast))) - - return page_components - - def build_settings_section_components( - self, - section: str, - settings: list[EditableSettingDisplay], - ) -> list[AnyComponent]: - first_setting = settings[0] - section_components: list[AnyComponent] = [ - c.Heading(text=first_setting.section_label, level=2), - ] - if first_setting.section_description: - section_components.append(c.Paragraph(text=first_setting.section_description, class_name="mb-2")) - - section_components.append( - c.Div( - class_name="border rounded overflow-hidden mb-4", - components=[self.build_settings_row(setting, index) for index, setting in enumerate(settings)], - ) - ) - - return [*section_components] - - def build_settings_row(self, setting: EditableSettingDisplay, index: int) -> AnyComponent: - info_components: list[AnyComponent] = [ - c.Heading(text=setting.label, level=4), - c.Paragraph(text=setting.path, class_name="text-muted small mb-1"), - c.Paragraph(text=setting.description, class_name="mb-2"), - ] - - badge_components = self.build_settings_badges(setting) - if badge_components: - info_components.append( - c.Div( - class_name="d-flex flex-wrap gap-2 mb-3", - components=badge_components, - ) - ) - info_components.append(self.build_settings_value_summary(setting)) - - action_components: list[AnyComponent] = [ - c.Button( - text="Manage Speakers šŸ”Š" if setting.path == "jukebox.player.sonos.selected_group" else "Edit āœļø", - on_click=( - GoToEvent(url="/sonos") - if setting.path == "jukebox.player.sonos.selected_group" - else GoToEvent(url=f"/settings/{setting.path}/edit") - ), - class_name="btn btn-secondary text-nowrap", - ) - ] - row_class_name = "px-3 py-3" - if index > 0: - row_class_name += " border-top" - - return c.Div( - class_name=row_class_name, - components=[ - c.Div( - class_name="d-flex flex-column flex-xl-row gap-3 justify-content-between align-items-xl-start", - components=[ - c.Div(class_name="flex-grow-1", components=info_components), - c.Div(class_name="d-grid gap-2 align-self-start", components=action_components), - ], - ) - ], - ) - - def build_settings_edit_page_components( - self, - setting_path: str, - reset_error: str | None = None, - ) -> list[AnyComponent]: - settings, effective_settings_error = self.get_settings_displays() - setting = next((candidate for candidate in settings if candidate.path == setting_path), None) - if setting is None: - return [ - c.Page( - components=[ - c.Heading(text="Edit setting", level=1), - c.Error(title="Setting not found", description=f"Unknown setting path: {setting_path}"), - c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), - c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), - ] - ) - ] - - components: list[AnyComponent] = [ - c.Heading(text=f"Edit {setting.label}", level=1), - c.Paragraph( - text=f"{setting.section_label} setting", class_name="text-uppercase text-muted small fw-semibold mb-1" - ), - c.Paragraph(text=setting.path, class_name="text-muted small mb-1"), - c.Paragraph(text=setting.description, class_name="mb-3"), - ] - - badge_components = self.build_settings_badges(setting) - if badge_components: - components.append( - c.Div( - class_name="d-flex flex-wrap gap-2 mb-3", - components=badge_components, - ) - ) - - if reset_error: - components.append( - c.Error( - title="Reset failed", - description=reset_error, - ) - ) - - if effective_settings_error: - components.append( - c.Error( - title="Effective settings unavailable", - description=( - f"{effective_settings_error} Showing persisted and default values where possible so this" - " setting can still be reviewed or repaired." - ), - ) - ) - - components.append( - c.Div( - class_name="border rounded p-3 mb-4 bg-light-subtle", - components=[ - c.Heading(text="Current values", level=3), - self.build_settings_value_summary(setting), - ], - ) - ) - - components.append( - c.Div( - class_name="border rounded p-3 mb-4", - components=[ - c.Heading(text="Update override", level=3), - c.Paragraph(text=self.build_settings_edit_guidance(setting), class_name="mb-3"), - self.build_settings_edit_form(setting), - ], - ) - ) - - if setting.is_persisted: - components.extend( - [ - c.Div( - class_name="border rounded p-3 mb-4", - components=[ - c.Heading(text="Reset override", level=3), - c.Paragraph( - text=( - "Reset removes the persisted override entirely. Use it to fall back to defaults," - " environment overrides, or CLI overrides." - ) - ), - self.build_settings_reset_form(setting.path), - ], - ) - ] - ) - - components.append( - c.Div( - class_name="mt-3 d-flex flex-wrap gap-3", - components=[ - c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), - c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), - ], - ) - ) - - return [c.Page(components=components)] - - def build_settings_edit_form(self, setting: EditableSettingDisplay) -> AnyComponent: - initial_value = setting.persisted_value if setting.is_persisted else setting.effective_value - field_description = setting.description - if setting.field_type == "object": - field_description = ( - f"{field_description} Enter a JSON object matching the persisted setting shape. " - "Leave blank to persist null. Use Reset to remove the persisted override." - ) - if setting.requires_restart: - field_description = f"{field_description} Takes effect after restart." - - if setting.choices: - options: list[SelectOption] = [ - { - "value": choice.value, - "label": choice.label, - } - for choice in setting.choices - ] - form_field = FormFieldSelect( - name="value", - title=setting.label, - options=options, - initial=None if initial_value is None else str(initial_value), - description=field_description, - required=True, - vanilla=True, - ) - elif setting.field_type == "object": - form_field = FormFieldTextarea( - name="value", - title=setting.label, - initial=json.dumps(initial_value, indent=2) if initial_value is not None else "", - description=field_description, - required=False, - rows=12, - placeholder="Enter a JSON object. Leave blank to persist null.", - ) - else: - form_field = FormFieldInput( - name="value", - title=setting.label, - initial=None if initial_value is None else str(initial_value), - description=field_description, - required=True, - html_type="number" if setting.field_type == "integer" else "text", - ) - - return c.Form( - form_fields=[form_field], - submit_url=f"/api/ui/settings/{setting.path}", - method="POST", - footer=[c.Button(text="Save", html_type="submit", class_name="btn btn-primary")], - ) - - def build_settings_reset_form(self, setting_path: str) -> AnyComponent: - return c.Form( - form_fields=[], - submit_url=f"/api/ui/settings/{setting_path}/reset", - method="POST", - footer=[c.Button(text="Reset", html_type="submit", class_name="btn btn-outline-danger text-nowrap px-3")], - ) - - def get_settings_displays(self) -> tuple[list[EditableSettingDisplay], str | None]: - persisted_settings = self.settings_service.get_persisted_settings_view() - effective_settings_error: str | None = None - try: - effective_settings_view = self.settings_service.get_effective_settings_view() - except SettingsError as err: - effective_settings_view = {} - effective_settings_error = str(err) - - return build_editable_setting_displays(persisted_settings, effective_settings_view), effective_settings_error - - def build_settings_badges(self, setting: EditableSettingDisplay) -> list[AnyComponent]: - badge_components: list[AnyComponent] = [] - if setting.is_persisted and not setting.is_pinned_default: - badge_components.append(c.Paragraph(text="Configured", class_name="badge text-bg-success text-uppercase")) - if setting.is_pinned_default: - badge_components.append(c.Paragraph(text="Pinned default", class_name="badge text-bg-info text-uppercase")) - if setting.requires_restart: - badge_components.append( - c.Paragraph(text="Restart required", class_name="badge text-bg-warning text-uppercase") - ) - if setting.advanced: - badge_components.append(c.Paragraph(text="Advanced", class_name="badge text-bg-dark text-uppercase")) - return badge_components - - def build_settings_value_summary(self, setting: EditableSettingDisplay) -> AnyComponent: - return c.Div( - class_name="row g-3", - components=[ - self.build_settings_value_cell( - "Default", - self.format_settings_display_value(setting.path, setting.default_value), - ), - self.build_settings_value_cell( - "Persisted override", - self.format_settings_display_value(setting.path, setting.persisted_value) - if setting.is_persisted - else "None", - ), - self.build_settings_value_cell( - "Effective value", - self.format_settings_display_value(setting.path, setting.effective_value), - ), - self.build_settings_value_cell( - "Source", - self.format_settings_provenance(setting.provenance), - ), - ], - ) - - def build_settings_value_cell(self, label: str, value: str) -> AnyComponent: - return c.Div( - class_name="col-12 col-md-6 col-xl-3", - components=[ - c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-1"), - c.Paragraph(text=value, class_name="mb-0 text-break"), - ], - ) - - def build_settings_edit_guidance(self, setting: EditableSettingDisplay) -> str: - guidance = "Save a persisted override for this setting." - if setting.choices: - guidance = f"{guidance} Choose one of the supported options below." - elif setting.field_type == "object": - guidance = ( - f"{guidance} Provide a JSON object matching the stored setting shape," - " or leave the field blank to persist null." - ) - - return f"{guidance} The effective value may still be superseded by environment or CLI overrides." - - def build_settings_patch(self, setting_path: str, raw_value: str) -> JsonObject: - definition = get_setting_definition(setting_path) - if definition is None: - raise ValueError(f"Unknown setting path: {setting_path}") - - if definition.choices and raw_value not in {choice.value for choice in definition.choices}: - raise ValueError("Choose a valid option.") - - if definition.field_type == "integer": - try: - value: JsonValue = int(raw_value) - except ValueError as err: - raise ValueError("Enter a valid integer.") from err - elif definition.field_type == "number": - try: - value = float(raw_value) - except ValueError as err: - raise ValueError("Enter a valid number.") from err - elif definition.field_type == "object": - if raw_value.strip() == "": - value = None - return self.build_dotted_patch(setting_path, value) - try: - value = cast(JsonValue, json.loads(raw_value)) - except json.JSONDecodeError as err: - raise ValueError("Enter valid JSON.") from err - if not isinstance(value, dict): - raise ValueError("Enter a JSON object.") - else: - value = raw_value - - return self.build_dotted_patch(setting_path, value) - - def build_dotted_patch(self, dotted_path: str, value: JsonValue) -> JsonObject: - patch: JsonObject = {} - cursor = patch - parts = dotted_path.split(".") - for part in parts[:-1]: - child: JsonObject = {} - cursor[part] = child - cursor = child - cursor[parts[-1]] = value - return patch - - def persisted_value_matches(self, dotted_path: str, expected_value: object) -> bool: - return ( - self.lookup_optional_dotted_path(self.settings_service.get_persisted_settings_view(), dotted_path) - == expected_value - ) - - def has_persisted_value(self, dotted_path: str) -> bool: - return ( - self.lookup_optional_dotted_path(self.settings_service.get_persisted_settings_view(), dotted_path) - is not _MISSING - ) - - def lookup_optional_dotted_path(self, root: JsonObject, dotted_path: str) -> object: - current: JsonObject = root - parts = dotted_path.split(".") - for part in parts[:-1]: - child = current.get(part, _MISSING) - if not isinstance(child, dict): - return _MISSING - current = cast(JsonObject, child) - return current.get(parts[-1], _MISSING) - - def format_settings_display_value(self, setting_path: str, value: object) -> str: - if value is None: - return "null" - - definition = get_setting_definition(setting_path) - if definition is not None and definition.choices and isinstance(value, str): - choice_labels = {choice.value: choice.label for choice in definition.choices} - if value in choice_labels: - return choice_labels[value] - - if setting_path == "jukebox.player.sonos.selected_group" and isinstance(value, dict): - selected_group = cast(dict[str, object], value) - members = selected_group.get("members") - coordinator_uid = selected_group.get("coordinator_uid") - if isinstance(members, list) and isinstance(coordinator_uid, str): - member_uids = [] - for member in members: - if not isinstance(member, dict): - continue - selected_member = cast(dict[str, object], member) - uid = selected_member.get("uid") - if not isinstance(uid, str): - continue - member_uids.append(uid) - if member_uids: - return "{} (coordinator); members: {}".format(coordinator_uid, ", ".join(member_uids)) - - if isinstance(value, bool): - return "true" if value else "false" - if isinstance(value, (int, float)): - return str(value) - if isinstance(value, str): - return value - try: - return json.dumps(value, sort_keys=True, separators=(", ", ": ")) - except TypeError: - return str(value) - - def format_settings_provenance(self, provenance: str) -> str: - labels = { - "default": "Default", - "file": "Settings file", - "env": "Environment override", - "cli": "CLI override", - "mixed": "Mixed source", - } - return labels.get(provenance, provenance) diff --git a/discstore/adapters/inbound/ui_pages/sonos.py b/discstore/adapters/inbound/ui_pages/sonos.py deleted file mode 100644 index b11b97f2..00000000 --- a/discstore/adapters/inbound/ui_pages/sonos.py +++ /dev/null @@ -1,501 +0,0 @@ -from urllib.parse import urlencode - -from fastui import AnyComponent -from fastui import components as c -from fastui.components.forms import FormFieldSelect -from fastui.events import GoToEvent, PageEvent -from fastui.forms import SelectOption -from pydantic import BaseModel, Field, field_validator - -from jukebox.settings.entities import SelectedSonosGroupSettings -from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository -from jukebox.settings.service_protocols import SettingsService -from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError -from jukebox.sonos.selection import ( - GetSonosSelectionStatus, - SonosSelectionStatus, -) -from jukebox.sonos.service import SonosService - - -class SonosSelectionForm(BaseModel): - uids: list[str] = Field(default_factory=list, title="Speakers") - coordinator_uid: str | None = Field(None, title="Coordinator") - - @field_validator("uids", mode="before") - @classmethod - def coerce_single_uid_to_list(cls, value): - if isinstance(value, str): - return [value] - return value - - -class SonosUIPageBuilder: - def __init__(self, settings_service: SettingsService, sonos_service: SonosService): - self.settings_service = settings_service - self.sonos_service = sonos_service - - def build_sonos_success_response(self, message: str) -> list[AnyComponent]: - query = urlencode( - { - "toast": "toast-sonos-success", - "toast_message": message, - } - ) - return [ - c.FireEvent(event=GoToEvent(url=f"/sonos?{query}")), - ] - - def build_sonos_edit_error_response( - self, - message: str, - uids: list[str], - coordinator_uid: str | None, - ) -> list[AnyComponent]: - query = urlencode( - [ - ("error_message", message), - *[("uids", uid) for uid in uids], - *([("coordinator_uid", coordinator_uid)] if coordinator_uid is not None else []), - ] - ) - return [ - c.FireEvent(event=GoToEvent(url=f"/sonos/edit?{query}")), - ] - - def build_sonos_page_components( - self, - toast: str | None = None, - toast_message: str | None = None, - error_message: str | None = None, - ) -> list[AnyComponent]: - selected_group = self._get_selected_group() - status: SonosSelectionStatus | None = None - speakers: list[DiscoveredSonosSpeaker] = [] - discovery_error = error_message - - try: - status = GetSonosSelectionStatus( - selected_group_repository=SettingsSelectedSonosGroupRepository(self.settings_service), - sonos_service=self.sonos_service, - ).execute() - speakers = self.sonos_service.list_network_speakers() - except SonosDiscoveryError as err: - discovery_error = str(err) - - components: list[AnyComponent] = [ - c.Heading(text="Sonos Speakers", level=1), - c.Div( - class_name="d-flex flex-wrap gap-3 mb-4", - components=[ - c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), - c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), - ], - ), - ] - - if discovery_error: - components.append( - c.Error( - title="Sonos discovery unavailable", - description=discovery_error, - ) - ) - - components.extend(self._build_saved_selection_components(status=status, selected_group=selected_group)) - - action_components: list[AnyComponent] = [ - c.Button(text="Edit selection", on_click=GoToEvent(url="/sonos/edit")), - ] - if selected_group is not None: - action_components.append(self._build_reset_form(button_text="Clear saved selection")) - components.append( - c.Div( - class_name="d-flex flex-wrap gap-2 mb-4", - components=action_components, - ) - ) - - if discovery_error is None: - components.extend( - self._build_discovered_speakers_components(speakers=speakers, selected_group=selected_group) - ) - - components.append( - c.Toast( - title="Toast", - body=[c.Paragraph(text=toast_message or "Sonos settings saved.")], - open_trigger=PageEvent(name="toast-sonos-success"), - position="bottom-end", - ) - ) - - page_components: list[AnyComponent] = [c.Page(components=components)] - if toast == "toast-sonos-success": - page_components.append(c.FireEvent(event=PageEvent(name=toast))) - - return page_components - - def build_sonos_edit_page_components( - self, - error_message: str | None = None, - field_errors: dict[str, str] | None = None, - submitted_uids: list[str] | None = None, - submitted_coordinator_uid: str | None = None, - ) -> list[AnyComponent]: - selected_group = self._get_selected_group() - components: list[AnyComponent] = [ - c.Heading(text="Edit Sonos Selection", level=1), - c.Paragraph( - text="Choose one or more visible speakers and select the coordinator used for playback.", - class_name="mb-3", - ), - ] - - try: - speakers = self.sonos_service.list_network_speakers() - except SonosDiscoveryError as err: - components.append( - c.Error( - title="Sonos discovery unavailable", - description=error_message or str(err), - ) - ) - components.extend(self._build_navigation_links()) - return [c.Page(components=components)] - - if not speakers: - components.append( - c.Error( - title="No Sonos speakers found", - description="No visible Sonos speakers are currently discoverable on the network.", - ) - ) - components.extend(self._build_navigation_links()) - return [c.Page(components=components)] - - components.append( - c.Div( - class_name="border rounded p-3 mb-4", - components=[ - c.Heading(text="Selection", level=3), - *self._build_edit_error_components(error_message), - *self._build_edit_saved_selection_components(selected_group, speakers), - c.Paragraph(text="Changes take effect after restart.", class_name="mb-3"), - self._build_selection_form( - speakers=speakers, - selected_group=selected_group, - field_errors=field_errors, - submitted_uids=submitted_uids, - submitted_coordinator_uid=submitted_coordinator_uid, - ), - ], - ) - ) - - components.extend(self._build_navigation_links()) - return [c.Page(components=components)] - - def _build_edit_error_components(self, error_message: str | None) -> list[AnyComponent]: - if not error_message: - return [] - - return [ - c.Div( - class_name="alert alert-danger mb-3", - components=[ - c.Paragraph(text="Selection not saved", class_name="fw-semibold mb-1"), - c.Paragraph(text=error_message, class_name="mb-0"), - ], - ) - ] - - def _build_edit_saved_selection_components( - self, - selected_group: SelectedSonosGroupSettings | None, - speakers: list[DiscoveredSonosSpeaker], - ) -> list[AnyComponent]: - if selected_group is None: - return [] - - speakers_by_uid = {speaker.uid: speaker for speaker in speakers} - coordinator = speakers_by_uid.get(selected_group.coordinator_uid) - coordinator_label = ( - f"{coordinator.name} [{coordinator.uid}]" if coordinator is not None else selected_group.coordinator_uid - ) - member_labels = [ - f"{speakers_by_uid[member.uid].name} [{member.uid}]" if member.uid in speakers_by_uid else member.uid - for member in selected_group.members - ] - - return [ - c.Div( - class_name="bg-light-subtle border rounded p-3 mb-3", - components=[ - c.Paragraph(text="Current saved selection", class_name="text-uppercase text-muted small mb-1"), - c.Paragraph(text=f"Coordinator: {coordinator_label}", class_name="mb-1"), - c.Paragraph(text="Members: {}".format(", ".join(member_labels)), class_name="mb-0"), - ], - ) - ] - - def _build_selection_form( - self, - speakers: list[DiscoveredSonosSpeaker], - selected_group: SelectedSonosGroupSettings | None, - field_errors: dict[str, str] | None = None, - submitted_uids: list[str] | None = None, - submitted_coordinator_uid: str | None = None, - ) -> AnyComponent: - selected_uids = ( - list(submitted_uids) - if submitted_uids is not None - else [member.uid for member in selected_group.members] - if selected_group is not None - else [] - ) - available_uids = {speaker.uid for speaker in speakers} - initial_uids = [uid for uid in selected_uids if uid in available_uids] - if submitted_uids is None and not initial_uids and speakers: - initial_uids = [speakers[0].uid] - - if submitted_coordinator_uid is not None and submitted_coordinator_uid in available_uids: - initial_coordinator_uid = submitted_coordinator_uid - elif selected_group is not None and selected_group.coordinator_uid in available_uids: - initial_coordinator_uid = selected_group.coordinator_uid - else: - initial_coordinator_uid = initial_uids[0] if initial_uids else speakers[0].uid - - speaker_options: list[SelectOption] = [ - { - "value": speaker.uid, - "label": self._build_speaker_option_label(speaker), - } - for speaker in speakers - ] - - return c.Form( - form_fields=[ - FormFieldSelect( - name="uids", - title="Speakers", - options=speaker_options, - initial=initial_uids, - description="Select the Sonos speakers that should participate in playback.", - required=True, - multiple=True, - error=field_errors.get("uids") if field_errors is not None else None, - vanilla=True, - ), - FormFieldSelect( - name="coordinator_uid", - title="Coordinator", - options=speaker_options, - initial=initial_coordinator_uid, - description="Choose the speaker that should coordinate the selected group.", - required=True, - error=field_errors.get("coordinator_uid") if field_errors is not None else None, - vanilla=True, - ), - ], - submit_url="/api/ui/sonos/edit", - method="POST", - footer=[c.Button(text="Save", html_type="submit", class_name="btn btn-primary")], - ) - - def _build_saved_selection_components( - self, - status: SonosSelectionStatus | None, - selected_group: SelectedSonosGroupSettings | None, - ) -> list[AnyComponent]: - if selected_group is None: - return [ - c.Div( - class_name="border rounded p-3 mb-4 bg-light-subtle", - components=[ - c.Heading(text="Saved selection", level=3), - c.Paragraph(text="No Sonos speaker selection is currently saved."), - ], - ) - ] - - components: list[AnyComponent] = [ - c.Heading(text="Saved selection", level=3), - ] - - if status is None: - components.extend( - [ - c.Paragraph(text=f"Coordinator: {selected_group.coordinator_uid}"), - c.Paragraph(text="Members: {}".format(", ".join(member.uid for member in selected_group.members))), - ] - ) - else: - status_label = { - "available": "Available", - "partial": "Partially available", - "unavailable": "Unavailable", - "not_selected": "Not selected", - }.get(status.availability.status, status.availability.status) - coordinator_label = self._format_saved_coordinator(status) - components.append(c.Paragraph(text=f"Status: {status_label}")) - components.append(c.Paragraph(text=f"Coordinator: {coordinator_label}")) - components.append( - c.Paragraph( - text="Members: {}".format( - ", ".join(self._format_status_member(member) for member in status.availability.members) - ) - ) - ) - - return [ - c.Div( - class_name="border rounded p-3 mb-4 bg-light-subtle", - components=components, - ) - ] - - def _build_discovered_speakers_components( - self, - speakers: list[DiscoveredSonosSpeaker], - selected_group: SelectedSonosGroupSettings | None, - ) -> list[AnyComponent]: - if not speakers: - return [ - c.Div( - class_name="border rounded p-3 mb-4", - components=[ - c.Heading(text="Discovered speakers", level=3), - c.Paragraph(text="No visible Sonos speakers found."), - ], - ) - ] - - selected_uids = {member.uid for member in selected_group.members} if selected_group is not None else set() - coordinator_uid = selected_group.coordinator_uid if selected_group is not None else None - - return [ - c.Heading(text="Discovered speakers", level=2), - c.Div( - class_name="border rounded overflow-hidden mb-4", - components=[ - self._build_speaker_header(), - *[ - self._build_speaker_row( - speaker=speaker, - is_selected=speaker.uid in selected_uids, - is_coordinator=speaker.uid == coordinator_uid, - ) - for speaker in speakers - ], - ], - ), - ] - - def _build_speaker_header(self) -> AnyComponent: - return c.Div( - class_name="d-none d-lg-block px-3 py-2 bg-light-subtle", - components=[ - c.Div( - class_name="row g-2 align-items-center", - components=[ - self._build_speaker_header_cell("Name", "col-lg-3"), - self._build_speaker_header_cell("Host", "col-lg-3"), - self._build_speaker_header_cell("Household", "col-lg-4"), - self._build_speaker_header_cell("Selection", "col-lg-2 text-lg-center"), - ], - ) - ], - ) - - def _build_speaker_row( - self, - speaker: DiscoveredSonosSpeaker, - is_selected: bool, - is_coordinator: bool, - ) -> AnyComponent: - selection_label = "Coordinator" if is_coordinator else "Selected" if is_selected else "Available" - return c.Div( - class_name="px-3 py-2 border-top", - components=[ - c.Div( - class_name="row g-2 align-items-center", - components=[ - self._build_speaker_value_cell("Name", speaker.name, "col-12 col-lg-3"), - self._build_speaker_value_cell("Host", speaker.host, "col-12 col-lg-3"), - self._build_speaker_value_cell("Household", speaker.household_id, "col-12 col-lg-4"), - self._build_speaker_value_cell( - "Selection", - selection_label, - "col-12 col-lg-2 text-lg-center", - ), - ], - ) - ], - ) - - def _build_speaker_header_cell(self, label: str, class_name: str) -> AnyComponent: - return c.Div( - class_name=f"{class_name} d-flex align-items-center", - components=[ - c.Paragraph(text=label, class_name="text-uppercase text-muted small fw-semibold mb-0"), - ], - ) - - def _build_speaker_value_cell(self, label: str, value: str, class_name: str) -> AnyComponent: - return c.Div( - class_name=class_name, - components=[ - c.Paragraph(text=label, class_name="d-lg-none text-uppercase text-muted small fw-semibold mb-1"), - c.Paragraph(text=value, class_name="mb-0 text-break"), - ], - ) - - def _build_navigation_links(self) -> list[AnyComponent]: - return [ - c.Div( - class_name="mt-3 d-flex flex-wrap gap-3", - components=[ - c.Link(components=[c.Text(text="Back to Sonos")], on_click=GoToEvent(url="/sonos")), - c.Link(components=[c.Text(text="Back to Settings")], on_click=GoToEvent(url="/settings")), - c.Link(components=[c.Text(text="Back to Library")], on_click=GoToEvent(url="/")), - ], - ) - ] - - def _build_reset_form(self, button_text: str) -> AnyComponent: - return c.Form( - form_fields=[], - submit_url="/api/ui/sonos/reset", - method="POST", - footer=[ - c.Button( - text=button_text, - html_type="submit", - class_name="btn btn-outline-danger text-nowrap px-3", - ) - ], - ) - - def _get_selected_group(self) -> SelectedSonosGroupSettings | None: - return SettingsSelectedSonosGroupRepository(self.settings_service).get_selected_group() - - @staticmethod - def _build_speaker_option_label(speaker: DiscoveredSonosSpeaker) -> str: - return f"{speaker.name} ({speaker.host})" - - @staticmethod - def _format_status_member(member) -> str: - if member.speaker is not None: - return f"{member.speaker.name} [{member.uid}]" - return f"{member.uid} [unavailable]" - - @staticmethod - def _format_saved_coordinator(status: SonosSelectionStatus) -> str: - if status.selected_group is None: - return "unknown" - - for member in status.availability.members: - if member.uid == status.selected_group.coordinator_uid and member.speaker is not None: - return f"{member.speaker.name} [{member.uid}]" - return status.selected_group.coordinator_uid diff --git a/discstore/adapters/outbound/__init__.py b/discstore/adapters/outbound/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/adapters/outbound/json_library_adapter.py b/discstore/adapters/outbound/json_library_adapter.py deleted file mode 100644 index be5696b1..00000000 --- a/discstore/adapters/outbound/json_library_adapter.py +++ /dev/null @@ -1,3 +0,0 @@ -from jukebox.adapters.outbound.json_library_adapter import JsonLibraryAdapter - -__all__ = ["JsonLibraryAdapter"] diff --git a/discstore/adapters/outbound/text_current_tag_adapter.py b/discstore/adapters/outbound/text_current_tag_adapter.py deleted file mode 100644 index 6ecd82f4..00000000 --- a/discstore/adapters/outbound/text_current_tag_adapter.py +++ /dev/null @@ -1,3 +0,0 @@ -from jukebox.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter - -__all__ = ["TextCurrentTagAdapter"] diff --git a/discstore/command_handlers.py b/discstore/command_handlers.py deleted file mode 100644 index 50a2a7ab..00000000 --- a/discstore/command_handlers.py +++ /dev/null @@ -1,32 +0,0 @@ -from collections.abc import Callable -from typing import Protocol - -from jukebox.settings.service_protocols import SettingsService - -from .commands import InteractiveCliCommand - - -class LibraryController(Protocol): - def run(self, command: object) -> None: ... - - -class InteractiveLibraryController(Protocol): - def run(self) -> None: ... - - -def execute_library_command( - verbose: bool, - command: object, - settings_service: SettingsService, - build_cli_controller: Callable[[str], LibraryController], - build_interactive_cli_controller: Callable[[str], InteractiveLibraryController], -) -> None: - runtime_config = settings_service.resolve_admin_runtime(verbose=verbose) - - if isinstance(command, InteractiveCliCommand): - interactive_cli = build_interactive_cli_controller(runtime_config.library_path) - interactive_cli.run() - return - - cli = build_cli_controller(runtime_config.library_path) - cli.run(command) diff --git a/discstore/commands.py b/discstore/commands.py deleted file mode 100644 index 50bb5978..00000000 --- a/discstore/commands.py +++ /dev/null @@ -1,59 +0,0 @@ -from enum import StrEnum -from typing import Literal, Self - -from pydantic import BaseModel, model_validator - - -class CliTagSourceCommand(BaseModel): - tag: str | None = None - use_current_tag: bool = False - - @model_validator(mode="after") - def validate_tag_source(self) -> Self: - has_explicit_tag = bool(self.tag) - if has_explicit_tag == self.use_current_tag: - raise ValueError("Exactly one tag source must be provided: explicit tag or --from-current.") - return self - - -class CliAddCommand(CliTagSourceCommand): - type: Literal["add"] - uri: str - track: str | None = None - artist: str | None = None - album: str | None = None - - -class CliListCommandModes(StrEnum): - table = "table" - line = "line" - - -class CliListCommand(BaseModel): - type: Literal["list"] - mode: CliListCommandModes = CliListCommandModes.table - - -class CliRemoveCommand(CliTagSourceCommand): - type: Literal["remove"] - - -class CliEditCommand(CliTagSourceCommand): - type: Literal["edit"] - uri: str | None = None - track: str | None = None - artist: str | None = None - album: str | None = None - - -class CliGetCommand(CliTagSourceCommand): - type: Literal["get"] - - -class CliSearchCommand(BaseModel): - type: Literal["search"] - query: str - - -class InteractiveCliCommand(BaseModel): - type: Literal["interactive"] diff --git a/discstore/di_container.py b/discstore/di_container.py deleted file mode 100644 index cc34f1bb..00000000 --- a/discstore/di_container.py +++ /dev/null @@ -1,42 +0,0 @@ -from discstore.adapters.inbound.cli_controller import CLIController -from discstore.adapters.inbound.interactive_cli_controller import ( - InteractiveCLIController, -) -from discstore.adapters.outbound.json_library_adapter import JsonLibraryAdapter -from discstore.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter -from discstore.domain.use_cases.add_disc import AddDisc -from discstore.domain.use_cases.edit_disc import EditDisc -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus -from discstore.domain.use_cases.get_disc import GetDisc -from discstore.domain.use_cases.list_discs import ListDiscs -from discstore.domain.use_cases.remove_disc import RemoveDisc -from discstore.domain.use_cases.resolve_tag_id import ResolveTagId -from discstore.domain.use_cases.search_discs import SearchDiscs -from jukebox.shared.config_utils import get_current_tag_path - - -def build_cli_controller(library_path: str): - repository = JsonLibraryAdapter(library_path) - current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) - get_current_tag_status = GetCurrentTagStatus(current_tag_repository, repository) - return CLIController( - AddDisc(repository), - ListDiscs(repository), - RemoveDisc(repository), - EditDisc(repository), - GetDisc(repository), - SearchDiscs(repository), - ResolveTagId(get_current_tag_status), - ) - - -def build_interactive_cli_controller(library_path: str): - repository = JsonLibraryAdapter(library_path) - current_tag_repository = TextCurrentTagAdapter(get_current_tag_path(library_path)) - return InteractiveCLIController( - AddDisc(repository), - ListDiscs(repository), - RemoveDisc(repository), - EditDisc(repository), - GetCurrentTagStatus(current_tag_repository, repository), - ) diff --git a/discstore/domain/__init__.py b/discstore/domain/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/discstore/domain/entities/__init__.py b/discstore/domain/entities/__init__.py deleted file mode 100644 index 56da5db2..00000000 --- a/discstore/domain/entities/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -from jukebox.domain.entities.disc import Disc, DiscMetadata, DiscOption -from jukebox.domain.entities.library import Library - -from .current_tag_status import CurrentTagStatus - -__all__ = ["CurrentTagStatus", "Disc", "DiscMetadata", "DiscOption", "Library"] diff --git a/discstore/domain/entities/current_tag_status.py b/discstore/domain/entities/current_tag_status.py deleted file mode 100644 index a985cc92..00000000 --- a/discstore/domain/entities/current_tag_status.py +++ /dev/null @@ -1,6 +0,0 @@ -from pydantic import BaseModel - - -class CurrentTagStatus(BaseModel): - tag_id: str - known_in_library: bool diff --git a/discstore/domain/repositories/__init__.py b/discstore/domain/repositories/__init__.py deleted file mode 100644 index 4e8cdaa3..00000000 --- a/discstore/domain/repositories/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from jukebox.domain.repositories.current_tag_repository import CurrentTagRepository -from jukebox.domain.repositories.library_repository import LibraryRepository - -__all__ = ["CurrentTagRepository", "LibraryRepository"] diff --git a/discstore/domain/use_cases/__init__.py b/discstore/domain/use_cases/__init__.py deleted file mode 100644 index d4e64393..00000000 --- a/discstore/domain/use_cases/__init__.py +++ /dev/null @@ -1,19 +0,0 @@ -from .add_disc import AddDisc -from .edit_disc import EditDisc -from .get_current_tag_status import GetCurrentTagStatus -from .get_disc import GetDisc -from .list_discs import ListDiscs -from .remove_disc import RemoveDisc -from .resolve_tag_id import ResolveTagId -from .search_discs import SearchDiscs - -__all__ = [ - "AddDisc", - "EditDisc", - "GetDisc", - "GetCurrentTagStatus", - "ListDiscs", - "RemoveDisc", - "ResolveTagId", - "SearchDiscs", -] diff --git a/discstore/domain/use_cases/add_disc.py b/discstore/domain/use_cases/add_disc.py deleted file mode 100644 index 05c746cc..00000000 --- a/discstore/domain/use_cases/add_disc.py +++ /dev/null @@ -1,11 +0,0 @@ -from discstore.domain.entities import Disc -from discstore.domain.repositories import LibraryRepository - - -class AddDisc: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute(self, tag_id: str, disc: Disc) -> Disc: - self.repository.add_disc(tag_id, disc) - return disc diff --git a/discstore/domain/use_cases/edit_disc.py b/discstore/domain/use_cases/edit_disc.py deleted file mode 100644 index 5e20a02b..00000000 --- a/discstore/domain/use_cases/edit_disc.py +++ /dev/null @@ -1,38 +0,0 @@ -from discstore.domain.entities import Disc, DiscMetadata, DiscOption -from discstore.domain.repositories import LibraryRepository - - -class EditDisc: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute( - self, - tag_id: str, - uri: str | None = None, - metadata: DiscMetadata | None = None, - option: DiscOption | None = None, - ) -> Disc: - current_disc = self.repository.get_disc(tag_id) - if current_disc is None: - raise ValueError(f"Tag does not exist: tag_id='{tag_id}'") - - new_uri = uri if uri is not None else current_disc.uri - - new_metadata = current_disc.metadata - if metadata is not None: - current_data = current_disc.metadata.model_dump() - new_data = metadata.model_dump(exclude_unset=True) - current_data.update(new_data) - new_metadata = DiscMetadata(**current_data) - - new_option = current_disc.option - if option is not None: - current_opt_data = current_disc.option.model_dump() - new_opt_data = option.model_dump(exclude_unset=True) - current_opt_data.update(new_opt_data) - new_option = DiscOption(**current_opt_data) - - updated_disc = Disc(uri=new_uri, metadata=new_metadata, option=new_option) - self.repository.update_disc(tag_id, updated_disc) - return updated_disc diff --git a/discstore/domain/use_cases/get_current_tag_status.py b/discstore/domain/use_cases/get_current_tag_status.py deleted file mode 100644 index e6c35eab..00000000 --- a/discstore/domain/use_cases/get_current_tag_status.py +++ /dev/null @@ -1,15 +0,0 @@ -from discstore.domain.entities import CurrentTagStatus -from discstore.domain.repositories import CurrentTagRepository, LibraryRepository - - -class GetCurrentTagStatus: - def __init__(self, current_tag_repository: CurrentTagRepository, library: LibraryRepository): - self.current_tag_repository = current_tag_repository - self.library = library - - def execute(self) -> CurrentTagStatus | None: - tag_id = self.current_tag_repository.get() - if tag_id is None: - return None - - return CurrentTagStatus(tag_id=tag_id, known_in_library=self.library.get_disc(tag_id) is not None) diff --git a/discstore/domain/use_cases/get_disc.py b/discstore/domain/use_cases/get_disc.py deleted file mode 100644 index 0fcd3588..00000000 --- a/discstore/domain/use_cases/get_disc.py +++ /dev/null @@ -1,14 +0,0 @@ -from discstore.domain.entities import Disc -from discstore.domain.repositories import LibraryRepository - - -class GetDisc: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute(self, tag_id: str) -> Disc: - disc = self.repository.get_disc(tag_id) - if disc is None: - raise ValueError(f"Tag not found: tag_id='{tag_id}'") - - return disc diff --git a/discstore/domain/use_cases/list_discs.py b/discstore/domain/use_cases/list_discs.py deleted file mode 100644 index 24249dfc..00000000 --- a/discstore/domain/use_cases/list_discs.py +++ /dev/null @@ -1,10 +0,0 @@ -from discstore.domain.entities import Disc -from discstore.domain.repositories import LibraryRepository - - -class ListDiscs: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute(self) -> dict[str, Disc]: - return self.repository.list_discs() diff --git a/discstore/domain/use_cases/remove_disc.py b/discstore/domain/use_cases/remove_disc.py deleted file mode 100644 index e2633f10..00000000 --- a/discstore/domain/use_cases/remove_disc.py +++ /dev/null @@ -1,9 +0,0 @@ -from discstore.domain.repositories import LibraryRepository - - -class RemoveDisc: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute(self, tag_id: str) -> None: - self.repository.remove_disc(tag_id) diff --git a/discstore/domain/use_cases/resolve_tag_id.py b/discstore/domain/use_cases/resolve_tag_id.py deleted file mode 100644 index 40332f0c..00000000 --- a/discstore/domain/use_cases/resolve_tag_id.py +++ /dev/null @@ -1,21 +0,0 @@ -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus - - -class ResolveTagId: - def __init__(self, get_current_tag_status: GetCurrentTagStatus): - self.get_current_tag_status = get_current_tag_status - - def execute(self, tag_id: str | None, use_current_tag: bool) -> str: - has_explicit_tag_id = bool(tag_id) - if has_explicit_tag_id == use_current_tag: - raise ValueError("Exactly one tag source must be provided: explicit tag or --from-current.") - - if has_explicit_tag_id: - assert tag_id is not None - return tag_id - - current_tag_status = self.get_current_tag_status.execute() - if current_tag_status is None: - raise ValueError("No current tag is available.") - - return current_tag_status.tag_id diff --git a/discstore/domain/use_cases/search_discs.py b/discstore/domain/use_cases/search_discs.py deleted file mode 100644 index d61d415a..00000000 --- a/discstore/domain/use_cases/search_discs.py +++ /dev/null @@ -1,27 +0,0 @@ -from discstore.domain.entities import Disc -from discstore.domain.repositories import LibraryRepository - - -class SearchDiscs: - def __init__(self, repository: LibraryRepository): - self.repository = repository - - def execute(self, query: str) -> dict[str, Disc]: - query_lower = query.lower() - results = {} - - for tag_id, disc in self.repository.list_discs().items(): - if query_lower in tag_id.lower(): - results[tag_id] = disc - continue - - metadata = disc.metadata - if ( - (metadata.artist and query_lower in metadata.artist.lower()) - or (metadata.album and query_lower in metadata.album.lower()) - or (metadata.track and query_lower in metadata.track.lower()) - or (metadata.playlist and query_lower in metadata.playlist.lower()) - ): - results[tag_id] = disc - - return results diff --git a/tests/jukebox/admin/test_app.py b/tests/jukebox/admin/test_app.py index 450792e6..aee37d0d 100644 --- a/tests/jukebox/admin/test_app.py +++ b/tests/jukebox/admin/test_app.py @@ -5,16 +5,6 @@ from pydantic import ValidationError from typer.testing import CliRunner -from discstore.commands import ( - CliAddCommand, - CliEditCommand, - CliGetCommand, - CliListCommand, - CliListCommandModes, - CliRemoveCommand, - CliSearchCommand, - InteractiveCliCommand, -) from jukebox.admin.app import _prompt_for_sonos_household_selection, app from jukebox.admin.commands import ( ApiCommand, @@ -26,6 +16,16 @@ SonosShowCommand, UiCommand, ) +from jukebox.admin.library_commands import ( + CliAddCommand, + CliEditCommand, + CliGetCommand, + CliListCommand, + CliListCommandModes, + CliRemoveCommand, + CliSearchCommand, + InteractiveCliCommand, +) from jukebox.admin.pn532_commands import Pn532ProbeCommand, Pn532ProfilesCommand, Pn532SelectCommand from jukebox.admin.sonos_households import GroupedSonosHousehold from jukebox.sonos.discovery import DiscoveredSonosSpeaker diff --git a/tests/jukebox/domain/entities/test_disc_metadata.py b/tests/jukebox/domain/entities/test_disc_metadata.py index f272e731..79de797f 100644 --- a/tests/jukebox/domain/entities/test_disc_metadata.py +++ b/tests/jukebox/domain/entities/test_disc_metadata.py @@ -1,4 +1,4 @@ -from discstore.domain.entities import DiscMetadata +from jukebox.domain.entities import DiscMetadata def test_all_fields_optional(): diff --git a/tests/jukebox/domain/entities/test_disc_option.py b/tests/jukebox/domain/entities/test_disc_option.py index abad822e..ce4c2f0b 100644 --- a/tests/jukebox/domain/entities/test_disc_option.py +++ b/tests/jukebox/domain/entities/test_disc_option.py @@ -1,4 +1,4 @@ -from discstore.domain.entities import DiscOption +from jukebox.domain.entities import DiscOption def test_default_values(): diff --git a/tests/jukebox/domain/entities/test_discs.py b/tests/jukebox/domain/entities/test_discs.py index 5dea88c1..5c1db1b0 100644 --- a/tests/jukebox/domain/entities/test_discs.py +++ b/tests/jukebox/domain/entities/test_discs.py @@ -1,7 +1,7 @@ import pytest from pydantic import ValidationError -from discstore.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption def test_minimal_disc(): diff --git a/tests/jukebox/domain/entities/test_library.py b/tests/jukebox/domain/entities/test_library.py index 2e31dad4..3a50e3d0 100644 --- a/tests/jukebox/domain/entities/test_library.py +++ b/tests/jukebox/domain/entities/test_library.py @@ -1,4 +1,4 @@ -from discstore.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.entities import Disc, DiscMetadata, Library def test_library(): From 16f567bc5404a7dfd70a2c5115fa51b713b13546 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sat, 2 May 2026 18:54:19 +0200 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=93=A6=20remove=20discstore=20from=20?= =?UTF-8?q?build=20config?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f57b56fe..cc2d2eab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ requires = ["uv_build>=0.8.7,<0.12.0"] build-backend = "uv_build" [tool.uv.build-backend] -module-name = ["jukebox", "discstore", "pn532"] +module-name = ["jukebox", "pn532"] module-root = "" [project.scripts] From aa45c74a7ca09c528aee1187073372c8613fa143 Mon Sep 17 00:00:00 2001 From: Gudsfile Date: Sun, 3 May 2026 01:22:48 +0200 Subject: [PATCH 8/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20migrate=20discstore=20?= =?UTF-8?q?tests=20to=20jukebox=20test=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move all tests from tests/discstore/ to their new locations under tests/jukebox/, updating all imports from discstore.* to jukebox.* and from tests.discstore.* to tests.jukebox.*. Also fix test_di_container.py to patch the new jukebox module paths instead of the old discstore paths. --- tests/discstore/adapters/inbound/__init__.py | 0 tests/discstore/domain/__init__.py | 0 tests/discstore/domain/use_cases/__init__.py | 0 .../adapters/inbound/admin}/__init__.py | 0 .../inbound/admin}/test_api_controller.py | 12 ++-- .../inbound/admin}/test_cli_controller.py | 6 +- .../inbound/admin}/test_cli_display.py | 4 +- .../admin}/test_interactive_cli_controller.py | 4 +- .../inbound/admin}/test_ui_controller.py | 58 +++++++++---------- tests/jukebox/admin/test_di_container.py | 6 +- .../admin/test_library_command_handlers.py} | 8 ++- .../domain/use_cases/library}/__init__.py | 0 .../domain/use_cases/library}/mock_repo.py | 4 +- .../use_cases/library}/test_add_disc.py | 6 +- .../use_cases/library}/test_edit_disc.py | 4 +- .../library}/test_get_current_tag_status.py | 4 +- .../use_cases/library}/test_get_disc.py | 6 +- .../use_cases/library}/test_list_discs.py | 6 +- .../use_cases/library}/test_remove_disc.py | 6 +- .../use_cases/library}/test_resolve_tag_id.py | 4 +- .../use_cases/library}/test_search_discs.py | 4 +- 21 files changed, 74 insertions(+), 68 deletions(-) delete mode 100644 tests/discstore/adapters/inbound/__init__.py delete mode 100644 tests/discstore/domain/__init__.py delete mode 100644 tests/discstore/domain/use_cases/__init__.py rename tests/{discstore => jukebox/adapters/inbound/admin}/__init__.py (100%) rename tests/{discstore/adapters/inbound => jukebox/adapters/inbound/admin}/test_api_controller.py (99%) rename tests/{discstore/adapters/inbound => jukebox/adapters/inbound/admin}/test_cli_controller.py (95%) rename tests/{discstore/adapters/inbound => jukebox/adapters/inbound/admin}/test_cli_display.py (91%) rename tests/{discstore/adapters/inbound => jukebox/adapters/inbound/admin}/test_interactive_cli_controller.py (95%) rename tests/{discstore/adapters/inbound => jukebox/adapters/inbound/admin}/test_ui_controller.py (96%) rename tests/{discstore/test_command_handlers.py => jukebox/admin/test_library_command_handlers.py} (91%) rename tests/{discstore/adapters => jukebox/domain/use_cases/library}/__init__.py (100%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/mock_repo.py (93%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_add_disc.py (85%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_edit_disc.py (98%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_get_current_tag_status.py (90%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_get_disc.py (77%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_list_discs.py (64%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_remove_disc.py (76%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_resolve_tag_id.py (92%) rename tests/{discstore/domain/use_cases => jukebox/domain/use_cases/library}/test_search_discs.py (96%) diff --git a/tests/discstore/adapters/inbound/__init__.py b/tests/discstore/adapters/inbound/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/discstore/domain/__init__.py b/tests/discstore/domain/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/discstore/domain/use_cases/__init__.py b/tests/discstore/domain/use_cases/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/discstore/__init__.py b/tests/jukebox/adapters/inbound/admin/__init__.py similarity index 100% rename from tests/discstore/__init__.py rename to tests/jukebox/adapters/inbound/admin/__init__.py diff --git a/tests/discstore/adapters/inbound/test_api_controller.py b/tests/jukebox/adapters/inbound/admin/test_api_controller.py similarity index 99% rename from tests/discstore/adapters/inbound/test_api_controller.py rename to tests/jukebox/adapters/inbound/admin/test_api_controller.py index 495d4ca4..69dffbaa 100644 --- a/tests/discstore/adapters/inbound/test_api_controller.py +++ b/tests/jukebox/adapters/inbound/admin/test_api_controller.py @@ -11,7 +11,7 @@ from fastapi import HTTPException from fastapi.routing import APIRoute - from discstore.adapters.inbound.api.models import ( + from jukebox.adapters.inbound.admin.api.models import ( DiscInput, DiscPatchInput, DiscPatchMetadataInput, @@ -19,12 +19,12 @@ SettingsPatchInput, SettingsResetInput, ) - from discstore.adapters.inbound.api_controller import ( + from jukebox.adapters.inbound.admin.api_controller import ( APIController, SonosSelectionInput, ) - from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption - from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption + from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus from jukebox.settings.errors import InvalidSettingsError from jukebox.sonos.discovery import DiscoveredSonosSpeaker, SonosDiscoveryError from jukebox.sonos.service import InspectedSelectedSonosGroup @@ -121,11 +121,11 @@ def build_inspected_group( def test_dependencies_import_failure(mocker): - sys.modules.pop("discstore.adapters.inbound.api_controller", None) + sys.modules.pop("jukebox.adapters.inbound.admin.api_controller", None) mocker.patch.dict("sys.modules", {"fastapi": None}) with pytest.raises(ModuleNotFoundError) as err: - import discstore.adapters.inbound.api_controller # noqa: F401 + import jukebox.adapters.inbound.admin.api_controller # noqa: F401 assert "The `api_controller` module requires the optional `api` dependencies." in str(err.value) assert "pip install 'gukebox[api]'" in str(err.value) diff --git a/tests/discstore/adapters/inbound/test_cli_controller.py b/tests/jukebox/adapters/inbound/admin/test_cli_controller.py similarity index 95% rename from tests/discstore/adapters/inbound/test_cli_controller.py rename to tests/jukebox/adapters/inbound/admin/test_cli_controller.py index e44aca28..5b7a95b6 100644 --- a/tests/discstore/adapters/inbound/test_cli_controller.py +++ b/tests/jukebox/adapters/inbound/admin/test_cli_controller.py @@ -2,9 +2,9 @@ import pytest -from discstore.adapters.inbound.cli_controller import CLIController -from discstore.commands import CliAddCommand, CliEditCommand, CliGetCommand, CliRemoveCommand -from discstore.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.adapters.inbound.admin.cli_controller import CLIController +from jukebox.admin.library_commands import CliAddCommand, CliEditCommand, CliGetCommand, CliRemoveCommand +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption def build_controller(): diff --git a/tests/discstore/adapters/inbound/test_cli_display.py b/tests/jukebox/adapters/inbound/admin/test_cli_display.py similarity index 91% rename from tests/discstore/adapters/inbound/test_cli_display.py rename to tests/jukebox/adapters/inbound/admin/test_cli_display.py index 633f564d..7f45e591 100644 --- a/tests/discstore/adapters/inbound/test_cli_display.py +++ b/tests/jukebox/adapters/inbound/admin/test_cli_display.py @@ -3,8 +3,8 @@ import pytest -from discstore.adapters.inbound.cli_display import display_library_line, display_library_table -from discstore.domain.entities import Disc, DiscMetadata, DiscOption +from jukebox.adapters.inbound.admin.cli_display import display_library_line, display_library_table +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption @pytest.fixture diff --git a/tests/discstore/adapters/inbound/test_interactive_cli_controller.py b/tests/jukebox/adapters/inbound/admin/test_interactive_cli_controller.py similarity index 95% rename from tests/discstore/adapters/inbound/test_interactive_cli_controller.py rename to tests/jukebox/adapters/inbound/admin/test_interactive_cli_controller.py index 2a713633..36bed8a2 100644 --- a/tests/discstore/adapters/inbound/test_interactive_cli_controller.py +++ b/tests/jukebox/adapters/inbound/admin/test_interactive_cli_controller.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock, patch -from discstore.adapters.inbound.interactive_cli_controller import InteractiveCLIController -from discstore.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption +from jukebox.adapters.inbound.admin.interactive_cli_controller import InteractiveCLIController +from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption def build_controller(): diff --git a/tests/discstore/adapters/inbound/test_ui_controller.py b/tests/jukebox/adapters/inbound/admin/test_ui_controller.py similarity index 96% rename from tests/discstore/adapters/inbound/test_ui_controller.py rename to tests/jukebox/adapters/inbound/admin/test_ui_controller.py index 54702809..da076917 100644 --- a/tests/discstore/adapters/inbound/test_ui_controller.py +++ b/tests/jukebox/adapters/inbound/admin/test_ui_controller.py @@ -21,11 +21,11 @@ def build_speaker(uid, name, host, household_id): def test_dependencies_import_failure(mocker): - sys.modules.pop("discstore.adapters.inbound.ui_controller", None) + sys.modules.pop("jukebox.adapters.inbound.admin.ui_controller", None) mocker.patch.dict("sys.modules", {"fastui": None}) with pytest.raises(ModuleNotFoundError) as err: - import discstore.adapters.inbound.ui_controller # noqa: F401 + import jukebox.adapters.inbound.admin.ui_controller # noqa: F401 assert "The `ui_controller` module requires the optional `ui` dependencies." in str(err.value) assert "pip install 'gukebox[ui]'" in str(err.value) @@ -34,7 +34,7 @@ def test_dependencies_import_failure(mocker): def build_controller(): - from discstore.adapters.inbound.ui_controller import UIController + from jukebox.adapters.inbound.admin.ui_controller import UIController from jukebox.sonos.service import InspectedSelectedSonosGroup, SonosService settings_service = MagicMock() @@ -135,7 +135,7 @@ def walk_components(components): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_ui_controller_registers_fastui_routes_and_page_structure(): - from discstore.domain.entities import Disc, DiscMetadata, DiscOption + from jukebox.domain.entities import Disc, DiscMetadata, DiscOption controller = build_controller() controller.list_discs.execute.return_value = { @@ -442,7 +442,7 @@ def test_sonos_edit_page_renders_speaker_and_coordinator_selects(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_sonos_selection_saves_and_redirects(): - from discstore.adapters.inbound.ui_controller import SonosSelectionForm + from jukebox.adapters.inbound.admin.ui_controller import SonosSelectionForm controller = build_controller() controller.settings_service.patch_persisted_settings.return_value = { @@ -481,7 +481,7 @@ async def test_update_sonos_selection_saves_and_redirects(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_sonos_selection_returns_field_error_for_invalid_coordinator(): - from discstore.adapters.inbound.ui_controller import SonosSelectionForm + from jukebox.adapters.inbound.admin.ui_controller import SonosSelectionForm controller = build_controller() route = next( @@ -537,7 +537,7 @@ def test_sonos_edit_page_renders_error_banner_and_preserves_submitted_values(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_sonos_selection_saves_single_speaker_selection(): - from discstore.adapters.inbound.ui_controller import SonosSelectionForm + from jukebox.adapters.inbound.admin.ui_controller import SonosSelectionForm controller = build_controller() controller.settings_service.patch_persisted_settings.return_value = {"message": "Settings saved."} @@ -572,7 +572,7 @@ async def test_update_sonos_selection_saves_single_speaker_selection(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_sonos_selection_redirects_when_write_succeeds_but_effective_settings_stay_invalid(): - from discstore.adapters.inbound.ui_controller import SonosSelectionForm + from jukebox.adapters.inbound.admin.ui_controller import SonosSelectionForm from jukebox.settings.errors import InvalidSettingsError controller = build_controller() @@ -747,7 +747,7 @@ def test_settings_edit_page_renders_empty_object_field_with_placeholder_when_no_ @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_setting_builds_scalar_patch_and_redirects_with_service_message(): - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm controller = build_controller() controller.settings_service.patch_persisted_settings.return_value = { @@ -771,7 +771,7 @@ async def test_update_setting_builds_scalar_patch_and_redirects_with_service_mes @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_setting_builds_object_patch_from_json_text(): - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm controller = build_controller() controller.settings_service.patch_persisted_settings.return_value = {"message": "Settings saved."} @@ -806,7 +806,7 @@ async def test_update_setting_builds_object_patch_from_json_text(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_setting_treats_blank_object_text_as_none(): - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm controller = build_controller() controller.settings_service.patch_persisted_settings.return_value = {"message": "Settings saved."} @@ -836,7 +836,7 @@ async def test_update_setting_treats_blank_object_text_as_none(): async def test_update_setting_returns_field_error_for_invalid_json(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm controller = build_controller() route = next( @@ -864,7 +864,7 @@ async def test_update_setting_returns_field_error_for_invalid_json(): async def test_update_setting_returns_field_error_for_non_object_json(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm controller = build_controller() route = next( @@ -890,7 +890,7 @@ async def test_update_setting_returns_field_error_for_non_object_json(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_setting_redirects_when_write_succeeds_but_effective_settings_stay_invalid(): - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm from jukebox.settings.errors import InvalidSettingsError controller = build_controller() @@ -935,7 +935,7 @@ def raise_after_persist(patch): async def test_update_setting_returns_field_error_for_shared_validation_failure(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import SettingValueForm + from jukebox.adapters.inbound.admin.ui_controller import SettingValueForm from jukebox.settings.errors import InvalidSettingsError controller = build_controller() @@ -1060,7 +1060,7 @@ def test_ui_controller_does_not_register_get_reset_setting_route(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_disc_library_components_render_empty_and_editable_states(): - from discstore.adapters.inbound.ui_controller import DiscTable + from jukebox.adapters.inbound.admin.ui_controller import DiscTable controller = build_controller() empty_components = controller._build_disc_library_components([]) @@ -1089,7 +1089,7 @@ def test_disc_library_components_render_empty_and_editable_states(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_current_tag_banner_for_unknown_disc_offers_add_cta(): - from discstore.domain.entities import CurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus controller = build_controller() @@ -1107,7 +1107,7 @@ def test_current_tag_banner_for_unknown_disc_offers_add_cta(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_current_tag_banner_for_known_disc_is_informational_only(): - from discstore.domain.entities import CurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus controller = build_controller() @@ -1136,7 +1136,7 @@ def test_new_disc_form_components_render_blank_add_form(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_new_disc_form_components_can_prefill_current_tag(): - from discstore.domain.entities import CurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus controller = build_controller() controller.get_current_tag_status.execute.return_value = CurrentTagStatus(tag_id="tag-123", known_in_library=False) @@ -1151,7 +1151,7 @@ def test_new_disc_form_components_can_prefill_current_tag(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_edit_disc_form_components_prefill_existing_disc(): - from discstore.domain.entities import Disc, DiscMetadata, DiscOption + from jukebox.domain.entities import Disc, DiscMetadata, DiscOption controller = build_controller() controller.get_disc.execute.return_value = Disc( @@ -1177,7 +1177,7 @@ def test_edit_disc_form_components_prefill_existing_disc(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") def test_disc_form_helpers_return_errors_for_invalid_current_tag_state_or_missing_edit_target(): - from discstore.domain.entities import CurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus controller = build_controller() @@ -1213,7 +1213,7 @@ def test_form_page_components_include_back_link_and_form(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_current_tag_banner_event_stream_emits_serialized_updates(): - from discstore.domain.entities import CurrentTagStatus + from jukebox.domain.entities import CurrentTagStatus controller = build_controller() controller.get_current_tag_status.execute.side_effect = [CurrentTagStatus(tag_id="tag-123", known_in_library=False)] @@ -1231,8 +1231,8 @@ async def test_current_tag_banner_event_stream_emits_serialized_updates(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_create_disc_returns_success_toast(): - from discstore.adapters.inbound.ui_controller import DiscForm - from discstore.domain.entities import Disc, DiscMetadata, DiscOption + from jukebox.adapters.inbound.admin.ui_controller import DiscForm + from jukebox.domain.entities import Disc, DiscMetadata, DiscOption controller = build_controller() route = next(route for route in controller.app.routes if getattr(route, "path", None) == "/api/ui/discs") @@ -1259,7 +1259,7 @@ async def test_create_disc_returns_success_toast(): async def test_create_disc_returns_conflict_when_add_fails(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import DiscForm + from jukebox.adapters.inbound.admin.ui_controller import DiscForm controller = build_controller() controller.add_disc.execute.side_effect = ValueError("Already existing tag") @@ -1282,8 +1282,8 @@ async def test_create_disc_returns_conflict_when_add_fails(): @pytest.mark.skipif(not FASTUI_INSTALLED, reason="FastUI dependencies are not installed") @pytest.mark.anyio async def test_update_disc_uses_edit_path(): - from discstore.adapters.inbound.ui_controller import DiscForm - from discstore.domain.entities import DiscMetadata, DiscOption + from jukebox.adapters.inbound.admin.ui_controller import DiscForm + from jukebox.domain.entities import DiscMetadata, DiscOption controller = build_controller() route = next( @@ -1312,7 +1312,7 @@ async def test_update_disc_uses_edit_path(): async def test_update_disc_rejects_tag_changes(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import DiscForm + from jukebox.adapters.inbound.admin.ui_controller import DiscForm controller = build_controller() route = next( @@ -1340,7 +1340,7 @@ async def test_update_disc_rejects_tag_changes(): async def test_update_disc_returns_field_error_when_edit_target_is_missing(): from fastapi import HTTPException - from discstore.adapters.inbound.ui_controller import DiscForm + from jukebox.adapters.inbound.admin.ui_controller import DiscForm controller = build_controller() controller.edit_disc.execute.side_effect = ValueError("Tag does not exist: tag_id='tag-123'") diff --git a/tests/jukebox/admin/test_di_container.py b/tests/jukebox/admin/test_di_container.py index 6777c4a8..7b99cdee 100644 --- a/tests/jukebox/admin/test_di_container.py +++ b/tests/jukebox/admin/test_di_container.py @@ -93,7 +93,8 @@ def test_build_admin_api_app_wiring(mocker, bootstrap_mocks): mock_api_instance = MagicMock() mock_api_controller_class = MagicMock(return_value=mock_api_instance) mocker.patch.dict( - "sys.modules", {"discstore.adapters.inbound.api_controller": MagicMock(APIController=mock_api_controller_class)} + "sys.modules", + {"jukebox.adapters.inbound.admin.api_controller": MagicMock(APIController=mock_api_controller_class)}, ) services = AdminServices(settings=MagicMock(), sonos=MagicMock()) @@ -118,7 +119,8 @@ def test_build_admin_ui_app_wiring(mocker, bootstrap_mocks): mock_ui_instance = MagicMock() mock_ui_controller_class = MagicMock(return_value=mock_ui_instance) mocker.patch.dict( - "sys.modules", {"discstore.adapters.inbound.ui_controller": MagicMock(UIController=mock_ui_controller_class)} + "sys.modules", + {"jukebox.adapters.inbound.admin.ui_controller": MagicMock(UIController=mock_ui_controller_class)}, ) services = AdminServices(settings=MagicMock(), sonos=MagicMock()) diff --git a/tests/discstore/test_command_handlers.py b/tests/jukebox/admin/test_library_command_handlers.py similarity index 91% rename from tests/discstore/test_command_handlers.py rename to tests/jukebox/admin/test_library_command_handlers.py index 1ce9b489..326dc33f 100644 --- a/tests/discstore/test_command_handlers.py +++ b/tests/jukebox/admin/test_library_command_handlers.py @@ -1,7 +1,11 @@ from unittest.mock import MagicMock, create_autospec -from discstore.command_handlers import InteractiveLibraryController, LibraryController, execute_library_command -from discstore.commands import CliSearchCommand, InteractiveCliCommand +from jukebox.admin.library_command_handlers import ( + InteractiveLibraryController, + LibraryController, + execute_library_command, +) +from jukebox.admin.library_commands import CliSearchCommand, InteractiveCliCommand from jukebox.settings.entities import ResolvedAdminRuntimeConfig from jukebox.settings.service_protocols import SettingsService diff --git a/tests/discstore/adapters/__init__.py b/tests/jukebox/domain/use_cases/library/__init__.py similarity index 100% rename from tests/discstore/adapters/__init__.py rename to tests/jukebox/domain/use_cases/library/__init__.py diff --git a/tests/discstore/domain/use_cases/mock_repo.py b/tests/jukebox/domain/use_cases/library/mock_repo.py similarity index 93% rename from tests/discstore/domain/use_cases/mock_repo.py rename to tests/jukebox/domain/use_cases/library/mock_repo.py index 3cf49d8f..042f57a5 100644 --- a/tests/discstore/domain/use_cases/mock_repo.py +++ b/tests/jukebox/domain/use_cases/library/mock_repo.py @@ -1,5 +1,5 @@ -from discstore.domain.entities import Disc, Library -from discstore.domain.repositories import LibraryRepository +from jukebox.domain.entities import Disc, Library +from jukebox.domain.repositories import LibraryRepository class MockRepo(LibraryRepository): diff --git a/tests/discstore/domain/use_cases/test_add_disc.py b/tests/jukebox/domain/use_cases/library/test_add_disc.py similarity index 85% rename from tests/discstore/domain/use_cases/test_add_disc.py rename to tests/jukebox/domain/use_cases/library/test_add_disc.py index 2aeba4fd..ec0bb77e 100644 --- a/tests/discstore/domain/use_cases/test_add_disc.py +++ b/tests/jukebox/domain/use_cases/library/test_add_disc.py @@ -1,8 +1,8 @@ import pytest -from discstore.domain.entities import Disc, DiscMetadata, Library -from discstore.domain.use_cases.add_disc import AddDisc -from tests.discstore.domain.use_cases.mock_repo import MockRepo +from jukebox.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.use_cases.library.add_disc import AddDisc +from tests.jukebox.domain.use_cases.library.mock_repo import MockRepo @pytest.fixture diff --git a/tests/discstore/domain/use_cases/test_edit_disc.py b/tests/jukebox/domain/use_cases/library/test_edit_disc.py similarity index 98% rename from tests/discstore/domain/use_cases/test_edit_disc.py rename to tests/jukebox/domain/use_cases/library/test_edit_disc.py index e987264b..cb0e58c4 100644 --- a/tests/discstore/domain/use_cases/test_edit_disc.py +++ b/tests/jukebox/domain/use_cases/library/test_edit_disc.py @@ -1,7 +1,7 @@ import pytest -from discstore.domain.entities import Disc, DiscMetadata, DiscOption, Library -from discstore.domain.use_cases.edit_disc import EditDisc +from jukebox.domain.entities import Disc, DiscMetadata, DiscOption, Library +from jukebox.domain.use_cases.library.edit_disc import EditDisc from .mock_repo import MockRepo diff --git a/tests/discstore/domain/use_cases/test_get_current_tag_status.py b/tests/jukebox/domain/use_cases/library/test_get_current_tag_status.py similarity index 90% rename from tests/discstore/domain/use_cases/test_get_current_tag_status.py rename to tests/jukebox/domain/use_cases/library/test_get_current_tag_status.py index 05ff432a..89512980 100644 --- a/tests/discstore/domain/use_cases/test_get_current_tag_status.py +++ b/tests/jukebox/domain/use_cases/library/test_get_current_tag_status.py @@ -1,7 +1,7 @@ from unittest.mock import MagicMock -from discstore.domain.entities import CurrentTagStatus -from discstore.domain.use_cases.get_current_tag_status import GetCurrentTagStatus +from jukebox.domain.entities import CurrentTagStatus +from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus def test_get_current_tag_status_returns_none_without_current_tag(): diff --git a/tests/discstore/domain/use_cases/test_get_disc.py b/tests/jukebox/domain/use_cases/library/test_get_disc.py similarity index 77% rename from tests/discstore/domain/use_cases/test_get_disc.py rename to tests/jukebox/domain/use_cases/library/test_get_disc.py index 4ddef676..ea2b9671 100644 --- a/tests/discstore/domain/use_cases/test_get_disc.py +++ b/tests/jukebox/domain/use_cases/library/test_get_disc.py @@ -1,8 +1,8 @@ import pytest -from discstore.domain.entities import Disc, DiscMetadata, Library -from discstore.domain.use_cases.get_disc import GetDisc -from tests.discstore.domain.use_cases.mock_repo import MockRepo +from jukebox.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.use_cases.library.get_disc import GetDisc +from tests.jukebox.domain.use_cases.library.mock_repo import MockRepo def test_get_existing_disc(): diff --git a/tests/discstore/domain/use_cases/test_list_discs.py b/tests/jukebox/domain/use_cases/library/test_list_discs.py similarity index 64% rename from tests/discstore/domain/use_cases/test_list_discs.py rename to tests/jukebox/domain/use_cases/library/test_list_discs.py index d87c817c..c8411e74 100644 --- a/tests/discstore/domain/use_cases/test_list_discs.py +++ b/tests/jukebox/domain/use_cases/library/test_list_discs.py @@ -1,6 +1,6 @@ -from discstore.domain.entities import Disc, DiscMetadata, Library -from discstore.domain.use_cases.list_discs import ListDiscs -from tests.discstore.domain.use_cases.mock_repo import MockRepo +from jukebox.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.use_cases.library.list_discs import ListDiscs +from tests.jukebox.domain.use_cases.library.mock_repo import MockRepo def test_list_discs_returns_all_discs(): diff --git a/tests/discstore/domain/use_cases/test_remove_disc.py b/tests/jukebox/domain/use_cases/library/test_remove_disc.py similarity index 76% rename from tests/discstore/domain/use_cases/test_remove_disc.py rename to tests/jukebox/domain/use_cases/library/test_remove_disc.py index 8094dcf6..2da22c00 100644 --- a/tests/discstore/domain/use_cases/test_remove_disc.py +++ b/tests/jukebox/domain/use_cases/library/test_remove_disc.py @@ -1,8 +1,8 @@ import pytest -from discstore.domain.entities import Disc, DiscMetadata, Library -from discstore.domain.use_cases.remove_disc import RemoveDisc -from tests.discstore.domain.use_cases.mock_repo import MockRepo +from jukebox.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.use_cases.library.remove_disc import RemoveDisc +from tests.jukebox.domain.use_cases.library.mock_repo import MockRepo @pytest.fixture diff --git a/tests/discstore/domain/use_cases/test_resolve_tag_id.py b/tests/jukebox/domain/use_cases/library/test_resolve_tag_id.py similarity index 92% rename from tests/discstore/domain/use_cases/test_resolve_tag_id.py rename to tests/jukebox/domain/use_cases/library/test_resolve_tag_id.py index 30b56eb3..79a5a0a1 100644 --- a/tests/discstore/domain/use_cases/test_resolve_tag_id.py +++ b/tests/jukebox/domain/use_cases/library/test_resolve_tag_id.py @@ -2,8 +2,8 @@ import pytest -from discstore.domain.entities import CurrentTagStatus -from discstore.domain.use_cases.resolve_tag_id import ResolveTagId +from jukebox.domain.entities import CurrentTagStatus +from jukebox.domain.use_cases.library.resolve_tag_id import ResolveTagId def test_resolve_tag_id_returns_explicit_tag_without_loading_current_tag_status(): diff --git a/tests/discstore/domain/use_cases/test_search_discs.py b/tests/jukebox/domain/use_cases/library/test_search_discs.py similarity index 96% rename from tests/discstore/domain/use_cases/test_search_discs.py rename to tests/jukebox/domain/use_cases/library/test_search_discs.py index a37f8e50..696ab9e7 100644 --- a/tests/discstore/domain/use_cases/test_search_discs.py +++ b/tests/jukebox/domain/use_cases/library/test_search_discs.py @@ -1,5 +1,5 @@ -from discstore.domain.entities import Disc, DiscMetadata, Library -from discstore.domain.use_cases.search_discs import SearchDiscs +from jukebox.domain.entities import Disc, DiscMetadata, Library +from jukebox.domain.use_cases.library.search_discs import SearchDiscs from .mock_repo import MockRepo