gukebox 1.9.1__tar.gz → 1.10.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.
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/PKG-INFO +7 -9
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/README.md +3 -3
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/export_router.py +29 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/models.py +38 -1
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/pn532_router.py +102 -0
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api/sonos_router.py +75 -0
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/api_controller.py +99 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/cli_controller.py +21 -10
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/cli_display.py +112 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/interactive_cli_controller.py +54 -7
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-34dQf4uL.js +5 -0
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/static/assets/index-CioYPxa4.css +1 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/static/index.html +3 -3
- gukebox-1.10.0.dev1/jukebox/adapters/inbound/admin/ui_controller.py +31 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/app.py +23 -2
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/command_handlers.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/di_container.py +12 -3
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/library_commands.py +4 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/pn532_command_handlers.py +8 -18
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/services.py +1 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/disc.py +13 -2
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/resolve_tag_id.py +8 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/pn532/profiles.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/entities.py +1 -1
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/file_settings_repository.py +6 -5
- gukebox-1.10.0.dev1/jukebox/settings/pn532_configuration.py +39 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/pyproject.toml +12 -16
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/pyproject.toml.orig +11 -13
- gukebox-1.9.1/jukebox/adapters/inbound/admin/api_controller.py +0 -194
- gukebox-1.9.1/jukebox/adapters/inbound/admin/cli_display.py +0 -63
- gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-BzFrC-C3.js +0 -4
- gukebox-1.9.1/jukebox/adapters/inbound/admin/static/assets/index-pWJnAHA9.css +0 -1
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_controller.py +0 -362
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/library.py +0 -381
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/settings.py +0 -525
- gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages/sonos.py +0 -501
- gukebox-1.9.1/jukebox/shared/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/LICENSE +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/current_tag_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/discs_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/api/settings_router.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/admin/static/favicon.svg +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/inbound/cli_controller.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/inbound/admin/ui_pages → gukebox-1.10.0.dev1/jukebox/adapters/outbound}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/json_library_adapter.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound → gukebox-1.10.0.dev1/jukebox/adapters/outbound/players}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/players/dryrun_player_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/players/sonos_player_adapter.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound/players → gukebox-1.10.0.dev1/jukebox/adapters/outbound/readers}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/readers/dryrun_reader_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/readers/pn532_reader_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/sonos_discovery_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/adapters/outbound/text_current_tag_adapter.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/cli_presentation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/commands.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/library_command_handlers.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/pn532_commands.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/admin/sonos_households.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/app.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/di_container.py +0 -0
- {gukebox-1.9.1/jukebox/adapters/outbound/readers → gukebox-1.10.0.dev1/jukebox/domain}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/current_tag_state.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/current_tag_status.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/library.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/playback_state.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/entities/tag_event.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/ports/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/ports/player_port.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/ports/reader_port.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/current_tag_repository.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/repositories/library_repository.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/handle_tag_event.py +0 -0
- {gukebox-1.9.1/jukebox/domain → gukebox-1.10.0.dev1/jukebox/domain/use_cases/library}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/add_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/edit_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/get_current_tag_status.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/get_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/list_discs.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/remove_disc.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/library/search_discs.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/sync_current_tag.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/transition_current_tag.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/domain/use_cases/transition_playback.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/pn532/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/definitions.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/dict_utils.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/migration.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/repositories.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/resolve.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/runtime_resolver.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/runtime_validation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/selected_sonos_group_repository.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/service_protocols.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/timing_validation.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/types.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/validation_rules.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/settings/view_utils.py +0 -0
- {gukebox-1.9.1/jukebox/domain/use_cases/library → gukebox-1.10.0.dev1/jukebox/shared}/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/shared/config_utils.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/shared/errors.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/shared/logger.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/shared/terminal_ui.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/shared/timing.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/sonos/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/sonos/discovery.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/sonos/selection.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/jukebox/sonos/service.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/pn532/__init__.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.0.dev1}/pn532/pn532.py +0 -0
- {gukebox-1.9.1 → gukebox-1.10.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
|
+
Version: 1.10.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
|
|
@@ -32,18 +32,16 @@ Classifier: Programming Language :: Python :: 3 :: Only
|
|
|
32
32
|
Classifier: Programming Language :: Python :: 3.11
|
|
33
33
|
Classifier: Programming Language :: Python :: 3.12
|
|
34
34
|
Classifier: Programming Language :: Python :: 3.13
|
|
35
|
-
Requires-Dist: pydantic==2.14.
|
|
35
|
+
Requires-Dist: pydantic==2.14.1
|
|
36
36
|
Requires-Dist: questionary==2.1.1
|
|
37
|
-
Requires-Dist: soco==0.
|
|
37
|
+
Requires-Dist: soco==0.32.0
|
|
38
38
|
Requires-Dist: typer==0.27.3
|
|
39
|
-
Requires-Dist: fastapi==0.
|
|
39
|
+
Requires-Dist: fastapi==0.162.0 ; extra == 'api'
|
|
40
40
|
Requires-Dist: uvicorn==0.54.0 ; extra == 'api'
|
|
41
41
|
Requires-Dist: pyserial==3.5 ; extra == 'pn532'
|
|
42
42
|
Requires-Dist: spidev==3.8 ; extra == 'pn532'
|
|
43
43
|
Requires-Dist: lgpio==0.2.2.0 ; python_full_version < '3.13' and extra == 'pn532'
|
|
44
44
|
Requires-Dist: gukebox[api] ; extra == 'ui'
|
|
45
|
-
Requires-Dist: fastui==0.9.0 ; extra == 'ui'
|
|
46
|
-
Requires-Dist: python-multipart==0.0.32 ; extra == 'ui'
|
|
47
45
|
Requires-Python: >=3.11, <3.14
|
|
48
46
|
Project-URL: Repository, https://github.com/Gudsfile/jukebox
|
|
49
47
|
Provides-Extra: api
|
|
@@ -168,9 +166,9 @@ Launch the web UI (requires the `ui` extra):
|
|
|
168
166
|
jukebox-admin ui
|
|
169
167
|
```
|
|
170
168
|
|
|
171
|
-

|
|
172
170
|
|
|
173
|
-

|
|
174
172
|
|
|
175
173
|
## Usage
|
|
176
174
|
|
|
@@ -242,7 +240,7 @@ uv run --extra ui jukebox-admin ui
|
|
|
242
240
|
```
|
|
243
241
|
|
|
244
242
|
> [!NOTE]
|
|
245
|
-
> The admin UI is
|
|
243
|
+
> The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
|
|
246
244
|
|
|
247
245
|
#### `library`
|
|
248
246
|
|
|
@@ -115,9 +115,9 @@ Launch the web UI (requires the `ui` extra):
|
|
|
115
115
|
jukebox-admin ui
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
-

|
|
119
119
|
|
|
120
|
-

|
|
121
121
|
|
|
122
122
|
## Usage
|
|
123
123
|
|
|
@@ -189,7 +189,7 @@ uv run --extra ui jukebox-admin ui
|
|
|
189
189
|
```
|
|
190
190
|
|
|
191
191
|
> [!NOTE]
|
|
192
|
-
> The admin UI is
|
|
192
|
+
> The admin UI is a Svelte SPA (see `admin-ui/`), served at `/` by the `jukebox-admin ui` process.
|
|
193
193
|
|
|
194
194
|
#### `library`
|
|
195
195
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Callable
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
from fastapi import APIRouter, HTTPException
|
|
6
|
+
from fastapi.responses import FileResponse
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _file_download(path: str, filename: str, missing_detail: str) -> FileResponse:
|
|
10
|
+
# Serve the bytes as stored on disk: a backup must not be rebuilt from parsed (or migrated) data.
|
|
11
|
+
if not os.path.isfile(path):
|
|
12
|
+
raise HTTPException(status_code=404, detail=missing_detail)
|
|
13
|
+
return FileResponse(path, media_type="application/json", filename=filename)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def build_export_router(library_path: str, settings_path: str, today: Callable[[], date] = date.today) -> APIRouter:
|
|
17
|
+
router = APIRouter(prefix="/api/v1/export", tags=["export"])
|
|
18
|
+
|
|
19
|
+
@router.get("/library", summary="Download the library file")
|
|
20
|
+
def export_library() -> FileResponse:
|
|
21
|
+
return _file_download(library_path, f"library-{today().isoformat()}.json", "No library file to export.")
|
|
22
|
+
|
|
23
|
+
@router.get("/settings", summary="Download the persisted settings file")
|
|
24
|
+
def export_settings() -> FileResponse:
|
|
25
|
+
return _file_download(
|
|
26
|
+
settings_path, f"settings-{today().isoformat()}.json", "No persisted settings file to export."
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
return router
|
|
@@ -3,6 +3,8 @@ from typing import Any
|
|
|
3
3
|
from pydantic import BaseModel, RootModel, computed_field
|
|
4
4
|
|
|
5
5
|
from jukebox.domain.entities import CurrentTagStatus, Disc
|
|
6
|
+
from jukebox.settings.entities import SelectedSonosGroupSettings
|
|
7
|
+
from jukebox.sonos.discovery import DiscoveredSonosSpeaker
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
class DiscInput(Disc):
|
|
@@ -30,7 +32,6 @@ class DiscPatchMetadataInput(BaseModel):
|
|
|
30
32
|
|
|
31
33
|
class DiscPatchOptionInput(BaseModel):
|
|
32
34
|
shuffle: bool | None = None
|
|
33
|
-
is_test: bool | None = None
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
class DiscPatchInput(BaseModel):
|
|
@@ -84,3 +85,39 @@ class EditableSettingDisplayOutput(BaseModel):
|
|
|
84
85
|
class SettingsDisplaysOutput(BaseModel):
|
|
85
86
|
settings: list[EditableSettingDisplayOutput]
|
|
86
87
|
effective_settings_error: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class SonosSpeakerOutput(DiscoveredSonosSpeaker):
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class SelectedSonosGroupOutput(SelectedSonosGroupSettings):
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class SonosSelectionMemberAvailabilityOutput(BaseModel):
|
|
99
|
+
uid: str
|
|
100
|
+
status: str
|
|
101
|
+
speaker: SonosSpeakerOutput | None = None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SonosSelectionAvailabilityOutput(BaseModel):
|
|
105
|
+
status: str
|
|
106
|
+
members: list[SonosSelectionMemberAvailabilityOutput]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SonosSelectionOutput(BaseModel):
|
|
110
|
+
selected_group: SelectedSonosGroupOutput | None = None
|
|
111
|
+
availability: SonosSelectionAvailabilityOutput
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SonosSelectionInput(BaseModel):
|
|
115
|
+
uids: list[str]
|
|
116
|
+
coordinator_uid: str | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class SonosSelectionUpdateOutput(BaseModel):
|
|
120
|
+
selected_group: SelectedSonosGroupOutput
|
|
121
|
+
availability: SonosSelectionAvailabilityOutput
|
|
122
|
+
message: str
|
|
123
|
+
restart_required: bool
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
from pydantic import BaseModel
|
|
3
|
+
|
|
4
|
+
from jukebox.pn532.profiles import PN532_PROFILES, Pn532BoardProfile, SpiConnectionParams, resolve_connection_params
|
|
5
|
+
from jukebox.settings.definitions import get_setting_definition
|
|
6
|
+
from jukebox.settings.entities import Pn532SpiSettings
|
|
7
|
+
from jukebox.settings.errors import SettingsError
|
|
8
|
+
from jukebox.settings.pn532_configuration import save_pn532_configuration
|
|
9
|
+
from jukebox.settings.service_protocols import SettingsService
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Pn532PinsOutput(BaseModel):
|
|
13
|
+
reset: int | None
|
|
14
|
+
cs: int | None
|
|
15
|
+
irq: int | None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Pn532ProfileOutput(BaseModel):
|
|
19
|
+
name: str
|
|
20
|
+
label: str
|
|
21
|
+
protocol: str
|
|
22
|
+
defaults: Pn532PinsOutput
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Pn532ConfigOutput(BaseModel):
|
|
26
|
+
board_profile: str
|
|
27
|
+
protocol: str
|
|
28
|
+
overrides: Pn532PinsOutput
|
|
29
|
+
resolved: Pn532PinsOutput
|
|
30
|
+
profiles: list[Pn532ProfileOutput]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Pn532ConfigInput(BaseModel):
|
|
34
|
+
board_profile: Pn532BoardProfile
|
|
35
|
+
spi: Pn532SpiSettings
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _pins(params: SpiConnectionParams) -> Pn532PinsOutput:
|
|
39
|
+
return Pn532PinsOutput(reset=params.reset, cs=params.cs, irq=params.irq)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _profile_labels() -> dict[str, str]:
|
|
43
|
+
definition = get_setting_definition("jukebox.reader.pn532.board_profile")
|
|
44
|
+
return {choice.value: choice.label for choice in definition.choices} if definition else {}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_pn532_router(settings_service: SettingsService) -> APIRouter:
|
|
48
|
+
router = APIRouter(prefix="/api/v1", tags=["pn532"])
|
|
49
|
+
|
|
50
|
+
@router.get(
|
|
51
|
+
"/pn532",
|
|
52
|
+
response_model=Pn532ConfigOutput,
|
|
53
|
+
summary="Get the effective PN532 config (profile defaults merged with pin overrides)",
|
|
54
|
+
)
|
|
55
|
+
def get_pn532() -> Pn532ConfigOutput:
|
|
56
|
+
try:
|
|
57
|
+
return _build_config_output(settings_service)
|
|
58
|
+
except Exception as err:
|
|
59
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
60
|
+
|
|
61
|
+
@router.put(
|
|
62
|
+
"/pn532",
|
|
63
|
+
response_model=Pn532ConfigOutput,
|
|
64
|
+
summary="Save the PN532 board profile and SPI pin overrides",
|
|
65
|
+
)
|
|
66
|
+
def put_pn532(payload: Pn532ConfigInput) -> Pn532ConfigOutput:
|
|
67
|
+
try:
|
|
68
|
+
save_pn532_configuration(
|
|
69
|
+
settings_service,
|
|
70
|
+
payload.board_profile,
|
|
71
|
+
PN532_PROFILES[payload.board_profile].default_protocol,
|
|
72
|
+
payload.spi.model_dump(),
|
|
73
|
+
)
|
|
74
|
+
return _build_config_output(settings_service)
|
|
75
|
+
except SettingsError as err:
|
|
76
|
+
raise HTTPException(status_code=400, detail=str(err))
|
|
77
|
+
except Exception as err:
|
|
78
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
79
|
+
|
|
80
|
+
return router
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _build_config_output(settings_service: SettingsService) -> Pn532ConfigOutput:
|
|
84
|
+
labels = _profile_labels()
|
|
85
|
+
pn532 = settings_service.get_effective_settings().jukebox.reader.pn532
|
|
86
|
+
overrides = SpiConnectionParams(reset=pn532.spi.reset, cs=pn532.spi.cs, irq=pn532.spi.irq)
|
|
87
|
+
resolved = resolve_connection_params(pn532.board_profile, pn532.protocol, overrides)
|
|
88
|
+
return Pn532ConfigOutput(
|
|
89
|
+
board_profile=pn532.board_profile,
|
|
90
|
+
protocol=pn532.protocol,
|
|
91
|
+
overrides=_pins(overrides),
|
|
92
|
+
resolved=_pins(resolved),
|
|
93
|
+
profiles=[
|
|
94
|
+
Pn532ProfileOutput(
|
|
95
|
+
name=name,
|
|
96
|
+
label=labels.get(name, name),
|
|
97
|
+
protocol=profile.default_protocol,
|
|
98
|
+
defaults=_pins(profile.connections[profile.default_protocol]),
|
|
99
|
+
)
|
|
100
|
+
for name, profile in PN532_PROFILES.items()
|
|
101
|
+
],
|
|
102
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
from fastapi import APIRouter, HTTPException
|
|
2
|
+
|
|
3
|
+
from jukebox.adapters.inbound.admin.api.models import (
|
|
4
|
+
SelectedSonosGroupOutput,
|
|
5
|
+
SonosSelectionAvailabilityOutput,
|
|
6
|
+
SonosSelectionInput,
|
|
7
|
+
SonosSelectionMemberAvailabilityOutput,
|
|
8
|
+
SonosSelectionOutput,
|
|
9
|
+
SonosSelectionUpdateOutput,
|
|
10
|
+
SonosSpeakerOutput,
|
|
11
|
+
)
|
|
12
|
+
from jukebox.settings.selected_sonos_group_repository import SettingsSelectedSonosGroupRepository
|
|
13
|
+
from jukebox.settings.service_protocols import SettingsService
|
|
14
|
+
from jukebox.sonos.discovery import SonosDiscoveryError
|
|
15
|
+
from jukebox.sonos.selection import GetSonosSelectionStatus, SaveSonosSelection
|
|
16
|
+
from jukebox.sonos.service import SonosService
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_sonos_router(settings_service: SettingsService, sonos_service: SonosService) -> APIRouter:
|
|
20
|
+
router = APIRouter(prefix="/api/v1", tags=["sonos"])
|
|
21
|
+
|
|
22
|
+
@router.get(
|
|
23
|
+
"/sonos/speakers", response_model=list[SonosSpeakerOutput], summary="List Sonos speakers on the network"
|
|
24
|
+
)
|
|
25
|
+
def get_sonos_speakers():
|
|
26
|
+
try:
|
|
27
|
+
return sonos_service.list_network_speakers()
|
|
28
|
+
except SonosDiscoveryError as err:
|
|
29
|
+
raise HTTPException(status_code=502, detail=str(err))
|
|
30
|
+
except Exception as err:
|
|
31
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
32
|
+
|
|
33
|
+
@router.get("/sonos/selection", response_model=SonosSelectionOutput, summary="Get the selected Sonos group")
|
|
34
|
+
def get_sonos_selection():
|
|
35
|
+
try:
|
|
36
|
+
return GetSonosSelectionStatus(
|
|
37
|
+
SettingsSelectedSonosGroupRepository(settings_service),
|
|
38
|
+
sonos_service,
|
|
39
|
+
).execute()
|
|
40
|
+
except SonosDiscoveryError as err:
|
|
41
|
+
raise HTTPException(status_code=502, detail=str(err))
|
|
42
|
+
except Exception as err:
|
|
43
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
44
|
+
|
|
45
|
+
@router.put("/sonos/selection", response_model=SonosSelectionUpdateOutput, summary="Save the selected Sonos group")
|
|
46
|
+
def put_sonos_selection(payload: SonosSelectionInput):
|
|
47
|
+
try:
|
|
48
|
+
result = SaveSonosSelection(
|
|
49
|
+
SettingsSelectedSonosGroupRepository(settings_service),
|
|
50
|
+
sonos_service,
|
|
51
|
+
).execute(payload.uids, coordinator_uid=payload.coordinator_uid)
|
|
52
|
+
return SonosSelectionUpdateOutput(
|
|
53
|
+
selected_group=SelectedSonosGroupOutput(**result.selected_group.model_dump()),
|
|
54
|
+
availability=SonosSelectionAvailabilityOutput(
|
|
55
|
+
status="available",
|
|
56
|
+
members=[
|
|
57
|
+
SonosSelectionMemberAvailabilityOutput(
|
|
58
|
+
uid=member.uid,
|
|
59
|
+
status="available",
|
|
60
|
+
speaker=SonosSpeakerOutput(**member.model_dump()),
|
|
61
|
+
)
|
|
62
|
+
for member in result.members
|
|
63
|
+
],
|
|
64
|
+
),
|
|
65
|
+
message=result.settings_message,
|
|
66
|
+
restart_required=result.restart_required,
|
|
67
|
+
)
|
|
68
|
+
except SonosDiscoveryError as err:
|
|
69
|
+
raise HTTPException(status_code=502, detail=str(err))
|
|
70
|
+
except ValueError as err:
|
|
71
|
+
raise HTTPException(status_code=400, detail=str(err))
|
|
72
|
+
except Exception as err:
|
|
73
|
+
raise HTTPException(status_code=500, detail=f"Server error: {err!s}")
|
|
74
|
+
|
|
75
|
+
return router
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from jukebox.shared.errors import MissingOptionalDependencyError
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from fastapi import FastAPI
|
|
5
|
+
|
|
6
|
+
from jukebox.adapters.inbound.admin.api.current_tag_router import build_current_tag_router
|
|
7
|
+
from jukebox.adapters.inbound.admin.api.discs_router import build_discs_router
|
|
8
|
+
from jukebox.adapters.inbound.admin.api.export_router import build_export_router
|
|
9
|
+
from jukebox.adapters.inbound.admin.api.models import (
|
|
10
|
+
CurrentTagDiscOutput,
|
|
11
|
+
CurrentTagStatusOutput,
|
|
12
|
+
DiscInput,
|
|
13
|
+
DiscOutput,
|
|
14
|
+
DiscPatchInput,
|
|
15
|
+
SettingsPatchInput,
|
|
16
|
+
SettingsResetInput,
|
|
17
|
+
SonosSelectionInput,
|
|
18
|
+
)
|
|
19
|
+
from jukebox.adapters.inbound.admin.api.pn532_router import build_pn532_router
|
|
20
|
+
from jukebox.adapters.inbound.admin.api.settings_router import build_settings_router
|
|
21
|
+
from jukebox.adapters.inbound.admin.api.sonos_router import build_sonos_router
|
|
22
|
+
except ModuleNotFoundError as e:
|
|
23
|
+
if e.name != "fastapi":
|
|
24
|
+
raise
|
|
25
|
+
raise MissingOptionalDependencyError("The `api_controller` module", "api", "jukebox-admin api") from e
|
|
26
|
+
from jukebox.domain.use_cases import AddDisc, EditDisc, GetCurrentTagStatus, GetDisc, ListDiscs, RemoveDisc
|
|
27
|
+
from jukebox.settings.service_protocols import SettingsService
|
|
28
|
+
from jukebox.sonos.service import SonosService
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"APIController",
|
|
32
|
+
"CurrentTagDiscOutput",
|
|
33
|
+
"CurrentTagStatusOutput",
|
|
34
|
+
"DiscInput",
|
|
35
|
+
"DiscOutput",
|
|
36
|
+
"DiscPatchInput",
|
|
37
|
+
"SettingsPatchInput",
|
|
38
|
+
"SettingsResetInput",
|
|
39
|
+
"SonosSelectionInput",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class APIController:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
add_disc: AddDisc,
|
|
47
|
+
list_discs: ListDiscs,
|
|
48
|
+
remove_disc: RemoveDisc,
|
|
49
|
+
edit_disc: EditDisc,
|
|
50
|
+
get_disc: GetDisc,
|
|
51
|
+
get_current_tag_status: GetCurrentTagStatus,
|
|
52
|
+
settings_service: SettingsService,
|
|
53
|
+
sonos_service: SonosService,
|
|
54
|
+
library_path: str,
|
|
55
|
+
settings_path: str,
|
|
56
|
+
):
|
|
57
|
+
self.add_disc = add_disc
|
|
58
|
+
self.list_discs = list_discs
|
|
59
|
+
self.remove_disc = remove_disc
|
|
60
|
+
self.edit_disc = edit_disc
|
|
61
|
+
self.get_disc = get_disc
|
|
62
|
+
self.get_current_tag_status = get_current_tag_status
|
|
63
|
+
self.settings_service = settings_service
|
|
64
|
+
self.sonos_service = sonos_service
|
|
65
|
+
self.library_path = library_path
|
|
66
|
+
self.settings_path = settings_path
|
|
67
|
+
self.app = FastAPI(
|
|
68
|
+
title="Jukebox Admin API",
|
|
69
|
+
description="API for managing Jukebox disc library and settings",
|
|
70
|
+
docs_url="/docs",
|
|
71
|
+
redoc_url="/redoc",
|
|
72
|
+
)
|
|
73
|
+
self.register_routes()
|
|
74
|
+
|
|
75
|
+
def register_routes(self):
|
|
76
|
+
self.app.include_router(
|
|
77
|
+
build_discs_router(
|
|
78
|
+
add_disc=self.add_disc,
|
|
79
|
+
list_discs=self.list_discs,
|
|
80
|
+
remove_disc=self.remove_disc,
|
|
81
|
+
edit_disc=self.edit_disc,
|
|
82
|
+
get_disc=self.get_disc,
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
self.app.include_router(
|
|
86
|
+
build_current_tag_router(
|
|
87
|
+
get_current_tag_status=self.get_current_tag_status,
|
|
88
|
+
add_disc=self.add_disc,
|
|
89
|
+
edit_disc=self.edit_disc,
|
|
90
|
+
get_disc=self.get_disc,
|
|
91
|
+
remove_disc=self.remove_disc,
|
|
92
|
+
)
|
|
93
|
+
)
|
|
94
|
+
self.app.include_router(build_settings_router(self.settings_service))
|
|
95
|
+
self.app.include_router(
|
|
96
|
+
build_sonos_router(settings_service=self.settings_service, sonos_service=self.sonos_service)
|
|
97
|
+
)
|
|
98
|
+
self.app.include_router(build_pn532_router(self.settings_service))
|
|
99
|
+
self.app.include_router(build_export_router(self.library_path, self.settings_path))
|
|
@@ -1,6 +1,13 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
1
3
|
import typer
|
|
2
4
|
|
|
3
|
-
from jukebox.adapters.inbound.admin.cli_display import
|
|
5
|
+
from jukebox.adapters.inbound.admin.cli_display import (
|
|
6
|
+
build_remove_confirmation,
|
|
7
|
+
display_disc,
|
|
8
|
+
display_library_line,
|
|
9
|
+
display_library_table,
|
|
10
|
+
)
|
|
4
11
|
from jukebox.admin.library_commands import (
|
|
5
12
|
CliAddCommand,
|
|
6
13
|
CliEditCommand,
|
|
@@ -51,7 +58,7 @@ class CLIController:
|
|
|
51
58
|
self.search_discs_flow(command)
|
|
52
59
|
|
|
53
60
|
def add_disc_flow(self, command: CliAddCommand) -> None:
|
|
54
|
-
tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag)
|
|
61
|
+
tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
|
|
55
62
|
option = DiscOption()
|
|
56
63
|
metadata = DiscMetadata(track=command.track, artist=command.artist, album=command.album)
|
|
57
64
|
|
|
@@ -70,7 +77,14 @@ class CLIController:
|
|
|
70
77
|
typer.echo(f"Displaying mode not implemented yet: mode='{command.mode}'", err=True)
|
|
71
78
|
|
|
72
79
|
def remove_disc_flow(self, command: CliRemoveCommand) -> None:
|
|
73
|
-
tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag)
|
|
80
|
+
tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
|
|
81
|
+
if not command.yes:
|
|
82
|
+
disc = self.get_disc.execute(tag)
|
|
83
|
+
if not sys.stdin.isatty():
|
|
84
|
+
raise ValueError(f"Refusing to remove disc '{tag}' without confirmation: pass --yes to confirm.")
|
|
85
|
+
if not typer.confirm(build_remove_confirmation(tag, disc), default=False):
|
|
86
|
+
typer.echo("Disc not removed")
|
|
87
|
+
return
|
|
74
88
|
self.remove_disc.execute(tag)
|
|
75
89
|
typer.echo("🗑️ Disc successfully removed")
|
|
76
90
|
|
|
@@ -86,7 +100,7 @@ class CLIController:
|
|
|
86
100
|
metadata = DiscMetadata(**metadata_fields) if metadata_fields else None
|
|
87
101
|
|
|
88
102
|
self.edit_disc.execute(
|
|
89
|
-
tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag),
|
|
103
|
+
tag_id=self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id),
|
|
90
104
|
uri=command.uri,
|
|
91
105
|
metadata=metadata,
|
|
92
106
|
option=None,
|
|
@@ -94,12 +108,9 @@ class CLIController:
|
|
|
94
108
|
typer.echo("✅ Disc successfully edited")
|
|
95
109
|
|
|
96
110
|
def get_disc_flow(self, command: CliGetCommand) -> None:
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
display_disc(tag, disc)
|
|
101
|
-
except ValueError as e:
|
|
102
|
-
typer.echo(str(e), err=True)
|
|
111
|
+
tag = self.resolve_tag_id.execute(command.tag, command.use_current_tag, command.expected_tag_id)
|
|
112
|
+
disc = self.get_disc.execute(tag)
|
|
113
|
+
display_disc(tag, disc)
|
|
103
114
|
|
|
104
115
|
def search_discs_flow(self, command: CliSearchCommand) -> None:
|
|
105
116
|
results = self.search_discs.execute(command.query)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
|
|
3
|
+
from rich.cells import cell_len
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
from rich.table import Table
|
|
6
|
+
|
|
7
|
+
from jukebox.domain.entities import Disc
|
|
8
|
+
|
|
9
|
+
MAX_COL_WIDTH = 20
|
|
10
|
+
|
|
11
|
+
# A new field also needs a place in TABLE_DROP_ORDER (and maybe TABLE_COL_BOUNDS) below
|
|
12
|
+
DISC_FIELDS: list[tuple[str, Callable[[Disc], str]]] = [
|
|
13
|
+
("Type", lambda disc: disc.metadata.display_type),
|
|
14
|
+
("Title", lambda disc: disc.metadata.display_title),
|
|
15
|
+
("URI", lambda disc: disc.uri),
|
|
16
|
+
("Artist", lambda disc: disc.metadata.artist or "/"),
|
|
17
|
+
("Album", lambda disc: disc.metadata.album or "/"),
|
|
18
|
+
("Track", lambda disc: disc.metadata.track or "/"),
|
|
19
|
+
("Playlist", lambda disc: disc.metadata.playlist or "/"),
|
|
20
|
+
("Shuffle", lambda disc: str(disc.option.shuffle)),
|
|
21
|
+
]
|
|
22
|
+
LABEL_WIDTH = max(len(label) for label, _ in DISC_FIELDS)
|
|
23
|
+
|
|
24
|
+
# Table only: Type and Title get a guaranteed width, and Title may grow past MAX_COL_WIDTH.
|
|
25
|
+
# The raw fields (redundant with Title) shrink first, then disappear in TABLE_DROP_ORDER.
|
|
26
|
+
TABLE_COL_BOUNDS: dict[str, tuple[int, int]] = {"Type": (11, 11), "Title": (24, 40), "Shuffle": (5, 7)}
|
|
27
|
+
RAW_COL_BOUNDS = (8, MAX_COL_WIDTH)
|
|
28
|
+
TABLE_DROP_ORDER = ["Playlist", "Track", "Album", "Artist", "Shuffle", "URI"]
|
|
29
|
+
COL_CHROME = 3 # left border + padding on both sides
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _disc_lines(disc: Disc) -> list[str]:
|
|
33
|
+
return [f" {label:<{LABEL_WIDTH}} : {value(disc)}" for label, value in DISC_FIELDS]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def display_disc(tag_id: str, disc: Disc, console: Console | None = None) -> None:
|
|
37
|
+
_console = console or Console()
|
|
38
|
+
_console.print(f"\n📀 Disc: {tag_id}")
|
|
39
|
+
for line in _disc_lines(disc):
|
|
40
|
+
_console.print(line)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def build_remove_confirmation(tag_id: str, disc: Disc) -> str:
|
|
44
|
+
suffix = f" ({disc.metadata.display_title})" if disc.metadata.has_title else ""
|
|
45
|
+
return f'Delete disc "{tag_id}"{suffix}?'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def display_library_line(discs: dict[str, Disc]) -> None:
|
|
49
|
+
if not discs:
|
|
50
|
+
print("The library is empty")
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
print("=== Discs Library ===\n")
|
|
54
|
+
for disc_id, disc in discs.items():
|
|
55
|
+
print(f"ID : {disc_id}")
|
|
56
|
+
for line in _disc_lines(disc):
|
|
57
|
+
print(line)
|
|
58
|
+
print("-" * 30)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def display_library_table(discs: dict[str, Disc], console: Console | None = None) -> None:
|
|
62
|
+
_console = console or Console()
|
|
63
|
+
if not discs:
|
|
64
|
+
_console.print("The library is empty")
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# The ID is what users copy into `library get`, so it never shrinks
|
|
68
|
+
id_width = max(cell_len(str(disc_id)) for disc_id in discs)
|
|
69
|
+
widths = _table_widths(discs, available=_console.width - 1 - (id_width + COL_CHROME))
|
|
70
|
+
fields = [(label, value) for label, value in DISC_FIELDS if label in widths]
|
|
71
|
+
|
|
72
|
+
table = Table(title="Discs Library")
|
|
73
|
+
table.add_column("ID", no_wrap=True, min_width=id_width)
|
|
74
|
+
for label, _ in fields:
|
|
75
|
+
table.add_column(label, no_wrap=True, width=widths[label])
|
|
76
|
+
|
|
77
|
+
for disc_id, disc in discs.items():
|
|
78
|
+
table.add_row(str(disc_id), *(value(disc) for _, value in fields))
|
|
79
|
+
|
|
80
|
+
_console.print(table)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _table_widths(discs: dict[str, Disc], available: int) -> dict[str, int]:
|
|
84
|
+
"""Width of each shown column, in DISC_FIELDS order, which is also the order they grow in."""
|
|
85
|
+
targets: dict[str, int] = {}
|
|
86
|
+
minimums: dict[str, int] = {}
|
|
87
|
+
for label, value in DISC_FIELDS:
|
|
88
|
+
low, high = TABLE_COL_BOUNDS.get(label, RAW_COL_BOUNDS)
|
|
89
|
+
natural = max(cell_len(label), *(cell_len(value(disc)) for disc in discs.values()))
|
|
90
|
+
targets[label] = min(natural, high)
|
|
91
|
+
minimums[label] = min(targets[label], low)
|
|
92
|
+
|
|
93
|
+
shown = [label for label, _ in DISC_FIELDS]
|
|
94
|
+
for label in TABLE_DROP_ORDER:
|
|
95
|
+
if sum(minimums[col] + COL_CHROME for col in shown) <= available:
|
|
96
|
+
break
|
|
97
|
+
shown.remove(label)
|
|
98
|
+
|
|
99
|
+
spare = available - sum(minimums[col] + COL_CHROME for col in shown)
|
|
100
|
+
widths = {}
|
|
101
|
+
for label in shown:
|
|
102
|
+
extra = min(max(spare, 0), targets[label] - minimums[label])
|
|
103
|
+
widths[label] = minimums[label] + extra
|
|
104
|
+
spare -= extra
|
|
105
|
+
# Only Type and Title are left and still too wide (very long ID): give up their minimums
|
|
106
|
+
for label in reversed(shown):
|
|
107
|
+
if spare >= 0:
|
|
108
|
+
break
|
|
109
|
+
cut = min(-spare, widths[label] - 1)
|
|
110
|
+
widths[label] -= cut
|
|
111
|
+
spare += cut
|
|
112
|
+
return widths
|