gukebox 1.3.2.dev1__tar.gz → 1.4.0.dev1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/PKG-INFO +1 -1
  2. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api/current_tag_router.py +1 -5
  3. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api/discs_router.py +1 -5
  4. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api_controller.py +1 -6
  5. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/cli_controller.py +1 -7
  6. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +1 -5
  7. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/ui_controller.py +1 -6
  8. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/ui_pages/library.py +1 -3
  9. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/cli_controller.py +8 -4
  10. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/players/sonos_player_adapter.py +5 -2
  11. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/cli_presentation.py +38 -30
  12. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/command_handlers.py +1 -1
  13. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/di_container.py +10 -8
  14. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/app.py +2 -1
  15. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/di_container.py +15 -10
  16. gukebox-1.4.0.dev1/jukebox/domain/entities/__init__.py +49 -0
  17. gukebox-1.4.0.dev1/jukebox/domain/entities/current_tag_state.py +32 -0
  18. gukebox-1.4.0.dev1/jukebox/domain/entities/playback_state.py +69 -0
  19. gukebox-1.4.0.dev1/jukebox/domain/use_cases/__init__.py +23 -0
  20. gukebox-1.4.0.dev1/jukebox/domain/use_cases/handle_tag_event.py +140 -0
  21. gukebox-1.4.0.dev1/jukebox/domain/use_cases/sync_current_tag.py +35 -0
  22. gukebox-1.4.0.dev1/jukebox/domain/use_cases/transition_current_tag.py +54 -0
  23. gukebox-1.4.0.dev1/jukebox/domain/use_cases/transition_playback.py +74 -0
  24. gukebox-1.4.0.dev1/jukebox/settings/errors.py +34 -0
  25. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/file_settings_repository.py +15 -4
  26. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/migration.py +3 -3
  27. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/resolve.py +57 -17
  28. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/runtime_resolver.py +5 -2
  29. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/pyproject.toml +1 -1
  30. gukebox-1.3.2.dev1/jukebox/domain/entities/__init__.py +0 -20
  31. gukebox-1.3.2.dev1/jukebox/domain/entities/current_tag_action.py +0 -11
  32. gukebox-1.3.2.dev1/jukebox/domain/entities/playback_action.py +0 -13
  33. gukebox-1.3.2.dev1/jukebox/domain/entities/playback_session.py +0 -48
  34. gukebox-1.3.2.dev1/jukebox/domain/use_cases/__init__.py +0 -5
  35. gukebox-1.3.2.dev1/jukebox/domain/use_cases/determine_action.py +0 -38
  36. gukebox-1.3.2.dev1/jukebox/domain/use_cases/determine_current_tag_action.py +0 -35
  37. gukebox-1.3.2.dev1/jukebox/domain/use_cases/handle_tag_event.py +0 -253
  38. gukebox-1.3.2.dev1/jukebox/settings/errors.py +0 -14
  39. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/LICENSE +0 -0
  40. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/README.md +0 -0
  41. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/__init__.py +0 -0
  42. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/__init__.py +0 -0
  43. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/__init__.py +0 -0
  44. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/__init__.py +0 -0
  45. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
  46. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api/models.py +0 -0
  47. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
  48. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/cli_display.py +0 -0
  49. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/ui_pages/__init__.py +0 -0
  50. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/ui_pages/settings.py +0 -0
  51. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -0
  52. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/__init__.py +0 -0
  53. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
  54. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/players/__init__.py +0 -0
  55. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
  56. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/readers/__init__.py +0 -0
  57. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
  58. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
  59. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
  60. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
  61. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/__init__.py +0 -0
  62. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/app.py +0 -0
  63. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/commands.py +0 -0
  64. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/library_command_handlers.py +0 -0
  65. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/library_commands.py +0 -0
  66. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/pn532_command_handlers.py +0 -0
  67. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/pn532_commands.py +0 -0
  68. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/services.py +0 -0
  69. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/admin/sonos_households.py +0 -0
  70. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/__init__.py +0 -0
  71. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/entities/current_tag_status.py +0 -0
  72. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/entities/disc.py +0 -0
  73. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/entities/library.py +0 -0
  74. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/entities/tag_event.py +0 -0
  75. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/errors.py +0 -0
  76. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/ports/__init__.py +0 -0
  77. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/ports/player_port.py +0 -0
  78. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/ports/reader_port.py +0 -0
  79. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/repositories/__init__.py +0 -0
  80. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/repositories/current_tag_repository.py +0 -0
  81. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/repositories/library_repository.py +0 -0
  82. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/__init__.py +0 -0
  83. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/add_disc.py +0 -0
  84. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
  85. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
  86. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/get_disc.py +0 -0
  87. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/list_discs.py +0 -0
  88. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
  89. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/resolve_tag_id.py +0 -0
  90. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/domain/use_cases/library/search_discs.py +0 -0
  91. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/pn532/__init__.py +0 -0
  92. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/pn532/profiles.py +0 -0
  93. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/__init__.py +0 -0
  94. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/definitions.py +0 -0
  95. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/dict_utils.py +0 -0
  96. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/entities.py +0 -0
  97. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/repositories.py +0 -0
  98. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/runtime_validation.py +0 -0
  99. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/selected_sonos_group_repository.py +0 -0
  100. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/service_protocols.py +0 -0
  101. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/timing_validation.py +0 -0
  102. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/types.py +0 -0
  103. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/validation_rules.py +0 -0
  104. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/settings/view_utils.py +0 -0
  105. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/__init__.py +0 -0
  106. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/config_utils.py +0 -0
  107. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/errors.py +0 -0
  108. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/logger.py +0 -0
  109. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/terminal_ui.py +0 -0
  110. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/shared/timing.py +0 -0
  111. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/sonos/__init__.py +0 -0
  112. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/sonos/discovery.py +0 -0
  113. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/sonos/selection.py +0 -0
  114. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/jukebox/sonos/service.py +0 -0
  115. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/pn532/__init__.py +0 -0
  116. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/pn532/pn532.py +0 -0
  117. {gukebox-1.3.2.dev1 → gukebox-1.4.0.dev1}/pn532/spi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: gukebox
3
- Version: 1.3.2.dev1
3
+ Version: 1.4.0.dev1
4
4
  Summary: A Jukebox to play music on speakers using 'CD' with NFC tag
5
5
  Keywords: jukebox,music,nfc
6
6
  Author: Gudsfile
@@ -11,11 +11,7 @@ from jukebox.adapters.inbound.admin.api.models import (
11
11
  DiscPatchInput,
12
12
  )
13
13
  from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption
14
- from jukebox.domain.use_cases.library.add_disc import AddDisc
15
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
16
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
17
- from jukebox.domain.use_cases.library.get_disc import GetDisc
18
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
14
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, RemoveDisc
19
15
 
20
16
 
21
17
  def build_current_tag_router(
@@ -3,11 +3,7 @@ from pydantic import ValidationError
3
3
 
4
4
  from jukebox.adapters.inbound.admin.api.models import DiscInput, DiscOutput, DiscPatchInput
5
5
  from jukebox.domain.entities import Disc, DiscMetadata, DiscOption
6
- from jukebox.domain.use_cases.library.add_disc import AddDisc
7
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
8
- from jukebox.domain.use_cases.library.get_disc import GetDisc
9
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
10
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
6
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetDisc, ListDiscs, RemoveDisc
11
7
 
12
8
 
13
9
  def build_discs_router(
@@ -21,12 +21,7 @@ except ModuleNotFoundError as e:
21
21
  if e.name != "fastapi":
22
22
  raise
23
23
  raise MissingOptionalDependencyError("The `api_controller` module", "api", "jukebox-admin api") from e
24
- from jukebox.domain.use_cases.library.add_disc import AddDisc
25
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
26
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
27
- from jukebox.domain.use_cases.library.get_disc import GetDisc
28
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
29
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
24
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, ListDiscs, RemoveDisc
30
25
  from jukebox.settings.entities import SelectedSonosGroupSettings
31
26
  from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
32
27
  from jukebox.settings.service_protocols import SettingsService
@@ -10,13 +10,7 @@ from jukebox.admin.library_commands import (
10
10
  CliSearchCommand,
11
11
  )
12
12
  from jukebox.domain.entities import Disc, DiscMetadata, DiscOption
13
- from jukebox.domain.use_cases.library.add_disc import AddDisc
14
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
15
- from jukebox.domain.use_cases.library.get_disc import GetDisc
16
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
17
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
18
- from jukebox.domain.use_cases.library.resolve_tag_id import ResolveTagId
19
- from jukebox.domain.use_cases.library.search_discs import SearchDiscs
13
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetDisc, ListDiscs, RemoveDisc, ResolveTagId, SearchDiscs
20
14
 
21
15
 
22
16
  class CLIController:
@@ -2,11 +2,7 @@ import typer
2
2
 
3
3
  from jukebox.adapters.inbound.admin.cli_display import display_library_line, display_library_table
4
4
  from jukebox.domain.entities import CurrentTagStatus, Disc, DiscMetadata, DiscOption
5
- from jukebox.domain.use_cases.library.add_disc import AddDisc
6
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
7
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
8
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
9
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
5
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, ListDiscs, RemoveDisc
10
6
 
11
7
 
12
8
  class InteractiveCLIController:
@@ -19,12 +19,7 @@ from jukebox.adapters.inbound.admin.ui_pages.library import DiscForm, LibraryUIP
19
19
  from jukebox.adapters.inbound.admin.ui_pages.settings import SettingsUIPageBuilder
20
20
  from jukebox.adapters.inbound.admin.ui_pages.sonos import SonosSelectionForm, SonosUIPageBuilder
21
21
  from jukebox.domain.entities import Disc, DiscMetadata, DiscOption
22
- from jukebox.domain.use_cases.library.add_disc import AddDisc
23
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
24
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
25
- from jukebox.domain.use_cases.library.get_disc import GetDisc
26
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
27
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
22
+ from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, ListDiscs, RemoveDisc
28
23
  from jukebox.settings.definitions import get_setting_definition
29
24
  from jukebox.settings.errors import SettingsError
30
25
  from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
@@ -9,9 +9,7 @@ from fastui.events import BackEvent, GoToEvent, PageEvent
9
9
  from pydantic import BaseModel, Field
10
10
 
11
11
  from jukebox.domain.entities import CurrentTagStatus, DiscMetadata, DiscOption
12
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
13
- from jukebox.domain.use_cases.library.get_disc import GetDisc
14
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
12
+ from jukebox.domain.use_cases import GetCurrentTagStatus, GetDisc, ListDiscs
15
13
 
16
14
 
17
15
  class DiscTable(DiscMetadata, DiscOption):
@@ -1,9 +1,9 @@
1
1
  import time
2
2
  from time import sleep
3
3
 
4
- from jukebox.domain.entities import PlaybackSession, TagEvent
4
+ from jukebox.domain.entities import CurrentTagState, Idle, NoTag, PlaybackState, TagEvent
5
5
  from jukebox.domain.ports import ReaderPort
6
- from jukebox.domain.use_cases.handle_tag_event import HandleTagEvent
6
+ from jukebox.domain.use_cases import HandleTagEvent, SyncCurrentTag
7
7
  from jukebox.shared.timing import DEFAULT_LOOP_INTERVAL_SECONDS
8
8
 
9
9
 
@@ -14,21 +14,25 @@ class CLIController:
14
14
  self,
15
15
  reader: ReaderPort,
16
16
  handle_tag_event: HandleTagEvent,
17
+ sync_current_tag: SyncCurrentTag,
17
18
  loop_interval_seconds: float = DEFAULT_LOOP_INTERVAL_SECONDS,
18
19
  ):
19
20
  self.reader = reader
20
21
  self.handle_tag_event = handle_tag_event
22
+ self.sync_current_tag = sync_current_tag
21
23
  self.loop_interval_seconds = loop_interval_seconds
22
24
 
23
25
  def run(self):
24
26
  """Run the main event loop."""
25
- session = PlaybackSession()
27
+ state: PlaybackState = Idle()
28
+ current_tag_state: CurrentTagState = NoTag()
26
29
 
27
30
  while True:
28
31
  loop_started = time.monotonic()
29
32
  tag_id = self.reader.read()
30
33
  tag_event = TagEvent(tag_id=tag_id, timestamp=time.monotonic())
31
- session = self.handle_tag_event.execute(tag_event, session)
34
+ current_tag_state = self.sync_current_tag.execute(tag_event, current_tag_state)
35
+ state = self.handle_tag_event.execute(tag_event, state)
32
36
  remaining_sleep = self.loop_interval_seconds - (time.monotonic() - loop_started)
33
37
  if remaining_sleep > 0:
34
38
  sleep(remaining_sleep)
@@ -11,7 +11,7 @@ from urllib3.exceptions import HTTPError
11
11
  from jukebox.domain.errors import PlaybackError
12
12
  from jukebox.domain.ports import PlayerPort
13
13
  from jukebox.settings.entities import ResolvedSonosGroupRuntime
14
- from jukebox.settings.errors import InvalidSettingsError
14
+ from jukebox.settings.errors import ErrorCode, InvalidSettingsError
15
15
  from jukebox.sonos.service import (
16
16
  SonosPlaybackTarget,
17
17
  SonosPlaybackTargetResolver,
@@ -66,7 +66,10 @@ class SonosPlayerAdapter(PlayerPort):
66
66
 
67
67
  speaker_info = self._refresh_speaker_metadata()
68
68
  except (HTTPError, OSError, RequestException, RuntimeError, SoCoException, SoCoUPnPException) as err:
69
- raise InvalidSettingsError(f"Failed to initialize Sonos player: {err}") from err
69
+ raise InvalidSettingsError(
70
+ f"Failed to initialize Sonos player: {err}",
71
+ code=ErrorCode.INVALID_EFFECTIVE,
72
+ ) from err
70
73
 
71
74
  LOGGER.info(
72
75
  "Found `%s` with software version: %s",
@@ -3,8 +3,11 @@ import re
3
3
  from collections.abc import Iterable, Mapping
4
4
  from typing import cast
5
5
 
6
+ from pydantic import ValidationError
7
+
6
8
  from jukebox.settings.definitions import SETTINGS, get_setting_definition, is_editable_setting_path
7
9
  from jukebox.settings.errors import (
10
+ ErrorCode,
8
11
  InvalidSettingsError,
9
12
  MalformedSettingsFileError,
10
13
  SettingsError,
@@ -385,40 +388,45 @@ def _render_cli_error_message(err: BaseException) -> str:
385
388
 
386
389
 
387
390
  def _render_invalid_settings_error(err: InvalidSettingsError) -> str:
388
- message = str(err)
389
-
390
- if message.startswith("Unsupported settings path for write: '") or message.startswith(
391
- "Unsupported settings path for reset: '"
392
- ):
393
- dotted_path = _extract_quoted_path(message)
394
- if dotted_path is not None:
395
- return (
396
- f"Unsupported settings path: '{dotted_path}'. Use `jukebox-admin settings show --effective --json` "
397
- "to inspect supported editable paths."
398
- )
399
- return "Unsupported settings path."
400
-
401
- if message.startswith("Settings value for '"):
402
- dotted_path = _extract_quoted_path(message)
403
- if "must be valid JSON" in message:
404
- return f"Invalid value for '{dotted_path or 'setting'}'. Pass a JSON object or `null`."
405
- if "must be a JSON object or null" in message:
406
- return f"Invalid value for '{dotted_path or 'setting'}'. Expected a JSON object or `null`."
391
+ match err.code:
392
+ case ErrorCode.UNSUPPORTED_PATH:
393
+ if err.path is not None:
394
+ return (
395
+ f"Unsupported settings path: '{err.path}'. Use `jukebox-admin settings show --effective --json` "
396
+ "to inspect supported editable paths."
397
+ )
398
+ return "Unsupported settings path."
399
+ case ErrorCode.INVALID_JSON_VALUE:
400
+ return f"Invalid value for '{err.path or 'setting'}'. Pass a JSON object or `null`."
401
+ case ErrorCode.INVALID_JSON_TYPE:
402
+ return f"Invalid value for '{err.path or 'setting'}'. Expected a JSON object or `null`."
403
+ case ErrorCode.INVALID_UPDATE:
404
+ return f"Settings update rejected: {_extract_error_detail(err)}"
405
+ case ErrorCode.INVALID_FILE:
406
+ detail = _extract_error_detail(err)
407
+ if err.path is not None:
408
+ return f"Persisted settings are invalid at '{err.path}': {detail}"
409
+ return f"Persisted settings are invalid: {detail}"
410
+ case ErrorCode.INVALID_EFFECTIVE:
411
+ return f"Effective settings are invalid: {_extract_error_detail(err)}"
412
+ case ErrorCode.UNKNOWN_PATH:
413
+ return str(err)
414
+ case _:
415
+ return str(err)
407
416
 
408
- if message.startswith("Invalid settings update:"):
409
- return f"Settings update rejected: {_extract_compact_detail(message)}"
410
417
 
411
- if message.startswith("Invalid settings file at '"):
412
- filepath = _extract_quoted_path(message)
413
- detail = _extract_compact_detail(message)
414
- if filepath is not None:
415
- return f"Persisted settings are invalid at '{filepath}': {detail}"
416
- return f"Persisted settings are invalid: {detail}"
418
+ def _extract_error_detail(err: InvalidSettingsError) -> str:
419
+ if isinstance(err.__cause__, ValidationError):
420
+ return _format_validation_errors(err.__cause__)
421
+ return _extract_compact_detail(str(err))
417
422
 
418
- if message.startswith("Invalid effective settings"):
419
- return f"Effective settings are invalid: {_extract_compact_detail(message)}"
420
423
 
421
- return message
424
+ def _format_validation_errors(cause: ValidationError) -> str:
425
+ parts = []
426
+ for error in cause.errors(include_url=False):
427
+ location = ".".join(str(loc) for loc in error["loc"])
428
+ parts.append(f"{location}: {error['msg']}" if location else error["msg"])
429
+ return "; ".join(parts)
422
430
 
423
431
 
424
432
  def _extract_compact_detail(message: str) -> str:
@@ -58,7 +58,7 @@ def _build_server_app(
58
58
  try:
59
59
  return build_app(library_path, services)
60
60
  except ModuleNotFoundError as err:
61
- if err.name in {"fastapi", "fastui"} or "requires the optional" in str(err):
61
+ if err.name in {"fastapi", "fastui"}:
62
62
  _raise_optional_extra_error(command_name, extra_name, source_command, err)
63
63
  raise
64
64
 
@@ -3,14 +3,16 @@ from typing import cast
3
3
  from jukebox.adapters.outbound.json_library_adapter import JsonLibraryAdapter
4
4
  from jukebox.adapters.outbound.sonos_discovery_adapter import SoCoSonosDiscoveryAdapter
5
5
  from jukebox.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter
6
- from jukebox.domain.use_cases.library.add_disc import AddDisc
7
- from jukebox.domain.use_cases.library.edit_disc import EditDisc
8
- from jukebox.domain.use_cases.library.get_current_tag_status import GetCurrentTagStatus
9
- from jukebox.domain.use_cases.library.get_disc import GetDisc
10
- from jukebox.domain.use_cases.library.list_discs import ListDiscs
11
- from jukebox.domain.use_cases.library.remove_disc import RemoveDisc
12
- from jukebox.domain.use_cases.library.resolve_tag_id import ResolveTagId
13
- from jukebox.domain.use_cases.library.search_discs import SearchDiscs
6
+ from jukebox.domain.use_cases import (
7
+ AddDisc,
8
+ EditDisc,
9
+ GetCurrentTagStatus,
10
+ GetDisc,
11
+ ListDiscs,
12
+ RemoveDisc,
13
+ ResolveTagId,
14
+ SearchDiscs,
15
+ )
14
16
  from jukebox.settings.file_settings_repository import FileSettingsRepository
15
17
  from jukebox.settings.resolve import SettingsService as SettingsServiceImpl
16
18
  from jukebox.settings.resolve import build_environment_settings_overrides
@@ -115,13 +115,14 @@ def _run(state: JukeboxCliState) -> None:
115
115
  settings_service = build_settings_service(**{k: v for k, v in asdict(state).items() if k != "verbose"})
116
116
  runtime_resolver = build_runtime_resolver(settings_service)
117
117
  runtime_config = runtime_resolver.resolve(verbose=state.verbose)
118
- reader, handle_tag_event = build_jukebox(runtime_config)
118
+ reader, handle_tag_event, sync_current_tag = build_jukebox(runtime_config)
119
119
  except SettingsError as err:
120
120
  _exit_error(str(err))
121
121
 
122
122
  controller = CLIController(
123
123
  reader=reader,
124
124
  handle_tag_event=handle_tag_event,
125
+ sync_current_tag=sync_current_tag,
125
126
  loop_interval_seconds=runtime_config.loop_interval_seconds,
126
127
  )
127
128
  controller.run()
@@ -6,9 +6,13 @@ from jukebox.adapters.outbound.players.sonos_player_adapter import SonosPlayerAd
6
6
  from jukebox.adapters.outbound.readers.dryrun_reader_adapter import DryrunReaderAdapter
7
7
  from jukebox.adapters.outbound.sonos_discovery_adapter import SoCoSonosDiscoveryAdapter
8
8
  from jukebox.adapters.outbound.text_current_tag_adapter import TextCurrentTagAdapter
9
- from jukebox.domain.use_cases.determine_action import DetermineAction
10
- from jukebox.domain.use_cases.determine_current_tag_action import DetermineCurrentTagAction
11
- from jukebox.domain.use_cases.handle_tag_event import HandleTagEvent
9
+ from jukebox.domain.entities import (
10
+ CURRENT_TAG_ABSENCE_GRACE_SECONDS,
11
+ PLAYBACK_RETRY_DELAYS_SECONDS,
12
+ CurrentTagContext,
13
+ TransitionContext,
14
+ )
15
+ from jukebox.domain.use_cases import HandleTagEvent, SyncCurrentTag
12
16
  from jukebox.settings.entities import ResolvedJukeboxRuntimeConfig
13
17
  from jukebox.settings.file_settings_repository import FileSettingsRepository
14
18
  from jukebox.settings.resolve import SettingsService as SettingsServiceImpl
@@ -134,21 +138,22 @@ def build_jukebox(
134
138
  case _:
135
139
  raise ValueError(f"Unknown reader type: {config.reader_type}")
136
140
 
137
- determine_action = DetermineAction(
141
+ ctx = TransitionContext(
138
142
  pause_delay=config.pause_delay_seconds,
139
143
  max_pause_duration=config.pause_duration_seconds,
144
+ retry_delays=PLAYBACK_RETRY_DELAYS_SECONDS,
145
+ )
146
+ sync_current_tag = SyncCurrentTag(
147
+ repository=current_tag_repository,
148
+ ctx=CurrentTagContext(grace_seconds=CURRENT_TAG_ABSENCE_GRACE_SECONDS),
140
149
  )
141
- determine_current_tag_action = DetermineCurrentTagAction()
142
-
143
150
  handle_tag_event = HandleTagEvent(
144
151
  player=player,
145
152
  library=library,
146
- current_tag_repository=current_tag_repository,
147
- determine_action=determine_action,
148
- determine_current_tag_action=determine_current_tag_action,
153
+ ctx=ctx,
149
154
  )
150
155
 
151
- return reader, handle_tag_event
156
+ return reader, handle_tag_event, sync_current_tag
152
157
 
153
158
 
154
159
  def build_sonos_playback_target_resolver() -> SonosPlaybackTargetResolver:
@@ -0,0 +1,49 @@
1
+ from .current_tag_state import (
2
+ CURRENT_TAG_ABSENCE_GRACE_SECONDS,
3
+ CurrentTagCommand,
4
+ CurrentTagContext,
5
+ CurrentTagState,
6
+ NoTag,
7
+ TagPresent,
8
+ TagRemoved,
9
+ )
10
+ from .current_tag_status import CurrentTagStatus
11
+ from .disc import Disc, DiscMetadata, DiscOption
12
+ from .library import Library
13
+ from .playback_state import (
14
+ PLAYBACK_RETRY_DELAYS_SECONDS,
15
+ Idle,
16
+ Paused,
17
+ PlaybackCommand,
18
+ PlaybackState,
19
+ Playing,
20
+ RetryState,
21
+ TransitionContext,
22
+ Waiting,
23
+ )
24
+ from .tag_event import TagEvent
25
+
26
+ __all__ = [
27
+ "CURRENT_TAG_ABSENCE_GRACE_SECONDS",
28
+ "CurrentTagCommand",
29
+ "CurrentTagContext",
30
+ "CurrentTagState",
31
+ "CurrentTagStatus",
32
+ "Idle",
33
+ "NoTag",
34
+ "TagPresent",
35
+ "TagRemoved",
36
+ "Playing",
37
+ "Waiting",
38
+ "Paused",
39
+ "PLAYBACK_RETRY_DELAYS_SECONDS",
40
+ "PlaybackCommand",
41
+ "PlaybackState",
42
+ "RetryState",
43
+ "TransitionContext",
44
+ "TagEvent",
45
+ "Library",
46
+ "Disc",
47
+ "DiscMetadata",
48
+ "DiscOption",
49
+ ]
@@ -0,0 +1,32 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+ CurrentTagCommand = Literal["set", "clear"]
5
+
6
+ CURRENT_TAG_ABSENCE_GRACE_SECONDS: float = 1.0
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class NoTag:
11
+ last_event_timestamp: float | None = None
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class TagPresent:
16
+ tag: str
17
+ last_event_timestamp: float | None = None
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class TagRemoved:
22
+ tag: str
23
+ removed_at: float
24
+ last_event_timestamp: float | None = None
25
+
26
+
27
+ CurrentTagState = NoTag | TagPresent | TagRemoved
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CurrentTagContext:
32
+ grace_seconds: float
@@ -0,0 +1,69 @@
1
+ from dataclasses import dataclass
2
+ from typing import Literal
3
+
4
+ PlaybackCommand = Literal["play", "pause", "resume", "stop"]
5
+
6
+ PLAYBACK_RETRY_DELAYS_SECONDS: tuple[float, ...] = (0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0)
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class RetryState:
11
+ """Tracks retry timing for a failed playback command."""
12
+
13
+ action: PlaybackCommand
14
+ tag_id: str | None
15
+ first_failed_at: float
16
+ last_failed_at: float
17
+ attempt_count: int
18
+ next_retry_at: float | None
19
+ exhausted: bool = False
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.action == "play" and self.tag_id is None:
23
+ raise ValueError("RetryState action='play' requires tag_id")
24
+ if self.action != "play" and self.tag_id is not None:
25
+ raise ValueError(f"RetryState action='{self.action}' must not have tag_id")
26
+
27
+ def matches(self, *, action: PlaybackCommand, tag_id: str | None = None) -> bool:
28
+ return self.action == action and self.tag_id == tag_id
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Idle:
33
+ retry: RetryState | None = None
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Playing:
38
+ tag: str
39
+ retry: RetryState | None = None
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Waiting:
44
+ tag: str
45
+ removed_at: float
46
+ retry: RetryState | None = None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class Paused:
51
+ tag: str
52
+ paused_at: float
53
+ retry: RetryState | None = None
54
+
55
+
56
+ PlaybackState = Idle | Playing | Waiting | Paused
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TransitionContext:
61
+ """Configuration bundle for the playback state transition function."""
62
+
63
+ pause_delay: float
64
+ max_pause_duration: float
65
+ retry_delays: tuple[float, ...]
66
+
67
+ def __post_init__(self) -> None:
68
+ if not self.retry_delays:
69
+ raise ValueError("retry_delays must not be empty")
@@ -0,0 +1,23 @@
1
+ from .handle_tag_event import HandleTagEvent
2
+ from .library.add_disc import AddDisc
3
+ from .library.edit_disc import EditDisc
4
+ from .library.get_current_tag_status import GetCurrentTagStatus
5
+ from .library.get_disc import GetDisc
6
+ from .library.list_discs import ListDiscs
7
+ from .library.remove_disc import RemoveDisc
8
+ from .library.resolve_tag_id import ResolveTagId
9
+ from .library.search_discs import SearchDiscs
10
+ from .sync_current_tag import SyncCurrentTag
11
+
12
+ __all__ = [
13
+ "HandleTagEvent",
14
+ "AddDisc",
15
+ "EditDisc",
16
+ "GetCurrentTagStatus",
17
+ "GetDisc",
18
+ "ListDiscs",
19
+ "RemoveDisc",
20
+ "ResolveTagId",
21
+ "SearchDiscs",
22
+ "SyncCurrentTag",
23
+ ]