Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,11 @@ upgrade and is unsupported. The `/app/config` and `/app/cache` mounts must be wr
support SQLite WAL locking, `fsync`, and atomic file replacement. This includes ordinary
Docker bind mounts and named volumes, plus local Unraid shares and TrueNAS datasets with
the usual container permissions. NFS, SMB, and other network mounts are safe only when
they provide those SQLite filesystem guarantees.
they provide those SQLite filesystem guarantees. On Docker Desktop for Windows, prefer
named Docker volumes over Windows-path bind mounts for `/app/config` and `/app/cache`;
the Windows mount translation does not reliably honor atomic file replacement. The
startup upgrade detects this and falls back to a verified direct copy, but named
volumes remain the recommended setup on Windows.

### 3. First-run setup

Expand Down
10 changes: 10 additions & 0 deletions backend/api/v1/routes/library_operations_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,16 @@ async def restore_review(
return await _review_action(review_id, "restore", body, admin, service)


@router.post("/reviews/{review_id}/dismiss", response_model=ReviewActionResponse)
async def dismiss_review(
admin: CurrentAdminDep,
review_id: str,
service: LibraryReviewServiceDep,
body: ReviewActionRequest = MsgSpecBody(ReviewActionRequest),
) -> ReviewActionResponse:
return await _review_action(review_id, "dismiss", body, admin, service)


@router.post("/reviews/{review_id}/candidate", response_model=ReviewActionResponse)
async def accept_review_candidate(
admin: CurrentAdminDep,
Expand Down
17 changes: 14 additions & 3 deletions backend/api/v1/routes/library_policies_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@
LibrarySettingsResponse,
LibrarySettingsUpdateRequest,
)
from core.dependencies import TargetLibraryPolicyServiceDep
from core.dependencies import (
LegacyPendingMigrationServiceDep,
TargetLibraryPolicyServiceDep,
)
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from middleware import CurrentAdminDep

Expand All @@ -39,12 +42,16 @@ async def get_library_settings(
@router.put("", response_model=LibrarySettingsResponse)
async def update_library_settings(
service: TargetLibraryPolicyServiceDep,
pending_migration: LegacyPendingMigrationServiceDep,
request: LibrarySettingsUpdateRequest = MsgSpecBody(LibrarySettingsUpdateRequest),
) -> LibrarySettingsResponse:
return await service.save_settings(
response = await service.save_settings(
request.settings,
expected_policy_revision=request.expected_policy_revision,
)
if response.enabled:
await pending_migration.schedule()
return response


@router.get("/policy-tree", response_model=LibraryPolicyTreeResponse)
Expand Down Expand Up @@ -80,6 +87,10 @@ async def get_restorable_library_roots(
@router.post("/restore-roots", response_model=LibrarySettingsResponse)
async def restore_library_roots(
service: TargetLibraryPolicyServiceDep,
pending_migration: LegacyPendingMigrationServiceDep,
request: LibraryRestoreRootsRequest = MsgSpecBody(LibraryRestoreRootsRequest),
) -> LibrarySettingsResponse:
return await service.restore_roots(request)
response = await service.restore_roots(request)
if response.enabled:
await pending_migration.schedule()
return response
41 changes: 39 additions & 2 deletions backend/api/v1/routes/library_scan_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
ScanEstimateResponse,
ScanRunCurrentResponse,
ScanRunDetailResponse,
ScanRunFailureItem,
ScanRunFailuresResponse,
ScanRunHistoryResponse,
ScanRunRequestBody,
ScanRunRequestedResponse,
Expand All @@ -27,6 +29,8 @@
from core.dependencies import (
LibraryAdministrativeWorkServiceDep,
LibraryPolicyResolverDep,
MbProviderAvailabilityDep,
NativeLibraryStoreDep,
TargetIdentificationQueueDep,
TargetLibraryScanCoordinatorDep,
)
Expand Down Expand Up @@ -116,6 +120,7 @@ async def library_activity(
coordinator: TargetLibraryScanCoordinatorDep,
identification: TargetIdentificationQueueDep,
administrative_work: LibraryAdministrativeWorkServiceDep,
mb_provider_available: MbProviderAvailabilityDep,
) -> LibraryActivityResponse:
revisions = await identification.stream_revisions()
runs = await coordinator.current()
Expand Down Expand Up @@ -270,7 +275,7 @@ async def library_activity(
state = "pausing"
elif control_state == "paused":
state = "paused"
elif waiting:
elif counts.get("running", 0) or identification_snapshot["claimable_count"]:
state = "running"
elif identification_snapshot["failure_event_id"] is not None:
state = "failed"
Expand Down Expand Up @@ -298,13 +303,17 @@ async def library_activity(
needs_review_count=counts.get("needs_review", 0),
failed_count=counts.get("failed", 0),
deferred_count=identification_snapshot["deferred_count"],
deferred_reason_counts=identification_snapshot[
"deferred_reason_counts"
],
attention_count=identification_snapshot["attention_count"],
priority_band=(
_IDENTIFICATION_PRIORITY_LABELS.get(active_priority, "Queued work")
if active_priority is not None
else None
),
oldest_backlog_at=identification_snapshot["started_at"],
provider_unavailable=bool(identification_snapshot["deferred_count"]),
provider_unavailable=not mb_provider_available(),
control_revision=identification_snapshot["control_revision"],
failure_event_id=identification_snapshot["failure_event_id"],
failure_at=identification_snapshot["failure_at"],
Expand Down Expand Up @@ -532,6 +541,34 @@ async def scan_run_detail(
return ScanRunDetailResponse(snapshot=await coordinator.snapshot(run_id))


@router.get("/scan-runs/{run_id}/failures", response_model=ScanRunFailuresResponse)
async def scan_run_failures(
run_id: str,
_: CurrentAdminDep,
store: NativeLibraryStoreDep,
limit: int = Query(default=50, ge=1, le=200),
cursor: int | None = Query(default=None, ge=1),
) -> ScanRunFailuresResponse:
await store.get_scan_run(run_id)
items, next_cursor = await store.list_scan_run_failures(
run_id, limit=limit, cursor_rowid=cursor
)
return ScanRunFailuresResponse(
items=[
ScanRunFailureItem(
root_id=item.root_id,
relative_path=item.relative_path,
failure_code=item.failure_code,
failure_detail=item.failure_detail,
phase=item.phase,
recorded_at=item.recorded_at,
)
for item in items
],
next_cursor=next_cursor,
)


async def _control(
run_id: str,
action: str,
Expand Down
1 change: 1 addition & 0 deletions backend/api/v1/routes/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ def _track_to_response(t) -> PlaylistTrackResponse:
duration=t.duration,
created_at=t.created_at,
plex_rating_key=getattr(t, "plex_rating_key", None),
library_file_id=getattr(t, "library_file_id", None),
)


Expand Down
37 changes: 30 additions & 7 deletions backend/api/v1/routes/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,12 @@
get_navidrome_playback_service,
get_plex_playback_service,
)
from core.exceptions import ExternalServiceError, PlaybackNotAllowedError, ResourceNotFoundError
from core.exceptions import (
ExternalServiceError,
JellyfinAuthError,
PlaybackNotAllowedError,
ResourceNotFoundError,
)
from infrastructure.msgspec_fastapi import MsgSpecBody, MsgSpecRoute
from middleware import CurrentUserDep
from services.jellyfin_playback_service import JellyfinPlaybackService
Expand All @@ -32,16 +37,22 @@
async def stream_jellyfin_audio(
item_id: str,
request: Request,
current_user: CurrentUserDep,
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> StreamingResponse:
try:
range_header = request.headers.get("Range")
return await playback_service.proxy_stream(item_id, range_header=range_header)
return await playback_service.proxy_stream(
item_id, range_header=range_header, user_id=current_user.id
)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Audio item not found")
except PlaybackNotAllowedError as e:
logger.warning("Playback not allowed for %s: %s", item_id, e)
raise HTTPException(status_code=403, detail="Playback not allowed")
except JellyfinAuthError as e:
logger.warning("Jellyfin auth failure streaming %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to stream from Jellyfin")
except ExternalServiceError as e:
if "416" in str(e):
raise HTTPException(status_code=416, detail="Range not satisfiable")
Expand All @@ -51,15 +62,19 @@ async def stream_jellyfin_audio(
@router.head("/jellyfin/{item_id}")
async def head_jellyfin_audio(
item_id: str,
current_user: CurrentUserDep,
playback_service: JellyfinPlaybackService = Depends(get_jellyfin_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(item_id)
return await playback_service.proxy_head(item_id, user_id=current_user.id)
except ResourceNotFoundError:
raise HTTPException(status_code=404, detail="Audio item not found")
except PlaybackNotAllowedError as e:
logger.warning("Playback not allowed for %s: %s", item_id, e)
raise HTTPException(status_code=403, detail="Playback not allowed")
except JellyfinAuthError as e:
logger.warning("Jellyfin auth failure heading %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to resolve Jellyfin stream")
except ExternalServiceError as e:
logger.error("Jellyfin head stream error for %s: %s", item_id, e)
raise HTTPException(status_code=502, detail="Failed to resolve Jellyfin stream")
Expand Down Expand Up @@ -194,10 +209,11 @@ async def stream_local_file(
@router.head("/navidrome/{item_id}")
async def head_navidrome_audio(
item_id: str,
current_user: CurrentUserDep,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(item_id)
return await playback_service.proxy_head(item_id, user_id=current_user.id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
Expand All @@ -208,10 +224,13 @@ async def head_navidrome_audio(
async def stream_navidrome_audio(
item_id: str,
request: Request,
current_user: CurrentUserDep,
playback_service: NavidromePlaybackService = Depends(get_navidrome_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(item_id, request.headers.get("Range"))
return await playback_service.proxy_stream(
item_id, request.headers.get("Range"), user_id=current_user.id
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
Expand Down Expand Up @@ -254,10 +273,11 @@ async def navidrome_stopped(
@router.head("/plex/{part_key:path}")
async def head_plex_audio(
part_key: str,
current_user: CurrentUserDep,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> Response:
try:
return await playback_service.proxy_head(part_key)
return await playback_service.proxy_head(part_key, user_id=current_user.id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError:
Expand All @@ -268,10 +288,13 @@ async def head_plex_audio(
async def stream_plex_audio(
part_key: str,
request: Request,
current_user: CurrentUserDep,
playback_service: PlexPlaybackService = Depends(get_plex_playback_service),
) -> StreamingResponse:
try:
return await playback_service.proxy_stream(part_key, request.headers.get("Range"))
return await playback_service.proxy_stream(
part_key, request.headers.get("Range"), user_id=current_user.id
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid stream request")
except ExternalServiceError as e:
Expand Down
4 changes: 4 additions & 0 deletions backend/api/v1/schemas/advanced_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ class AdvancedSettings(AppStruct):
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
direct_remote_images_enabled: bool = True
prefer_local_cover_art: bool = True
audiodb_api_key: str = "123"
cache_ttl_audiodb_found: int = 604800
cache_ttl_audiodb_not_found: int = 86400
Expand Down Expand Up @@ -286,6 +287,7 @@ class AdvancedSettingsFrontend(AppStruct):
audiodb_enabled: bool = True
audiodb_name_search_fallback: bool = False
direct_remote_images_enabled: bool = True
prefer_local_cover_art: bool = True
audiodb_api_key: str = "123"
cache_ttl_audiodb_found: int = 168
cache_ttl_audiodb_not_found: int = 24
Expand Down Expand Up @@ -494,6 +496,7 @@ def from_backend(settings: AdvancedSettings) -> "AdvancedSettingsFrontend":
audiodb_enabled=settings.audiodb_enabled,
audiodb_name_search_fallback=settings.audiodb_name_search_fallback,
direct_remote_images_enabled=settings.direct_remote_images_enabled,
prefer_local_cover_art=settings.prefer_local_cover_art,
audiodb_api_key=_mask_api_key(settings.audiodb_api_key),
cache_ttl_audiodb_found=settings.cache_ttl_audiodb_found // 3600,
cache_ttl_audiodb_not_found=settings.cache_ttl_audiodb_not_found // 3600,
Expand Down Expand Up @@ -581,6 +584,7 @@ def to_backend(self) -> AdvancedSettings:
audiodb_enabled=self.audiodb_enabled,
audiodb_name_search_fallback=self.audiodb_name_search_fallback,
direct_remote_images_enabled=self.direct_remote_images_enabled,
prefer_local_cover_art=self.prefer_local_cover_art,
audiodb_api_key=self.audiodb_api_key,
cache_ttl_audiodb_found=self.cache_ttl_audiodb_found * 3600,
cache_ttl_audiodb_not_found=self.cache_ttl_audiodb_not_found * 3600,
Expand Down
12 changes: 12 additions & 0 deletions backend/api/v1/schemas/library_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,17 @@ class RepairApplyRequest(AppStruct):
confirmation: bool


class SuggestedEditionSummary(AppStruct):
release_mbid: str
release_group_mbid: str
title: str
track_count: int
competing_count: int
date: str | None = None
country: str | None = None
status: str | None = None


class RepairFindingResponse(AppStruct):
id: str
local_album_id: str
Expand All @@ -362,6 +373,7 @@ class RepairFindingResponse(AppStruct):
apply_eligible: bool
state: str
apply_result: str | None = None
suggested_edition: SuggestedEditionSummary | None = None
updated_at: float = 0.0
row_revision: int = 1

Expand Down
4 changes: 4 additions & 0 deletions backend/api/v1/schemas/library_policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class TypedLibrarySettings(AppStruct):
staging_path: str = ""
naming_template: str = DEFAULT_NAMING_TEMPLATE
acoustid_api_key: str = ""
# Master switch (GH #276): when False the target application stops claiming
# new scan/identification/organization work. Deliberately excluded from the
# policy revision hash so toggling never churns boundary transitions.
enabled: bool = True


class LibrarySettingsResponse(TypedLibrarySettings):
Expand Down
16 changes: 16 additions & 0 deletions backend/api/v1/schemas/library_scan_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,20 @@ class ScanRunDetailResponse(AppStruct):
snapshot: ScanRunSnapshot


class ScanRunFailureItem(AppStruct):
root_id: str
relative_path: str
failure_code: str
failure_detail: str
phase: Literal["discovering", "indexing", "reconciling"]
recorded_at: float


class ScanRunFailuresResponse(AppStruct):
items: list[ScanRunFailureItem]
next_cursor: int | None = None


class LibraryActivityItem(AppStruct):
kind: Literal["scan", "identification"]
state: str
Expand All @@ -67,6 +81,8 @@ class LibraryActivityItem(AppStruct):
needs_review_count: int = 0
failed_count: int = 0
deferred_count: int = 0
deferred_reason_counts: dict[str, int] = msgspec.field(default_factory=dict)
attention_count: int = 0
priority_band: str | None = None
oldest_backlog_at: float | None = None
provider_unavailable: bool = False
Expand Down
1 change: 1 addition & 0 deletions backend/api/v1/schemas/playlists.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class PlaylistTrackResponse(AppStruct):
duration: int | None = None
created_at: str = ""
plex_rating_key: str | None = None
library_file_id: str | None = None


class PlaylistSummaryResponse(AppStruct):
Expand Down
Loading