From 89405d1c7822e6b678b3d8fdcccfea03e48195df Mon Sep 17 00:00:00 2001 From: Harvey <64276030+HabiRabbu@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:59:05 +0000 Subject: [PATCH] Download albums/tracks from local files (#56) * download albums/tracks from local files * checks --- backend/api/v1/routes/download.py | 92 ++++++++++++++++++ backend/api/v1/schemas/local_files.py | 1 + backend/main.py | 2 + backend/repositories/lidarr/album.py | 19 ++++ backend/repositories/protocols/lidarr.py | 6 ++ backend/services/local_files_service.py | 93 +++++++++++++++++++ .../lib/components/AlbumCardOverlay.svelte | 14 ++- .../src/lib/components/AlbumSourceBar.svelte | 7 +- .../lib/components/SourceAlbumModal.svelte | 27 +++++- frontend/src/lib/constants.ts | 5 + frontend/src/lib/types.ts | 1 + frontend/src/lib/utils/downloadHelper.ts | 12 +++ .../src/lib/utils/libraryController.svelte.ts | 16 +++- frontend/src/routes/album/[id]/+page.svelte | 1 + .../routes/album/[id]/AlbumSourceBars.svelte | 3 + .../album/[id]/albumPageState.svelte.ts | 10 ++ .../album/[id]/albumPlaybackHandlers.ts | 14 ++- 17 files changed, 313 insertions(+), 10 deletions(-) create mode 100644 backend/api/v1/routes/download.py create mode 100644 frontend/src/lib/utils/downloadHelper.ts diff --git a/backend/api/v1/routes/download.py b/backend/api/v1/routes/download.py new file mode 100644 index 0000000..522cb58 --- /dev/null +++ b/backend/api/v1/routes/download.py @@ -0,0 +1,92 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from starlette.background import BackgroundTask +from starlette.responses import FileResponse + +from core.dependencies import get_local_files_service +from core.exceptions import ExternalServiceError, ResourceNotFoundError +from infrastructure.msgspec_fastapi import MsgSpecRoute +from services.local_files_service import LocalFilesService + +logger = logging.getLogger(__name__) + +router = APIRouter(route_class=MsgSpecRoute, prefix="/download", tags=["download"]) + + +@router.get("/local/track/{track_id}") +async def download_track( + track_id: int, + local_service: LocalFilesService = Depends(get_local_files_service), +) -> FileResponse: + try: + file_path, filename, media_type = await local_service.get_download_track(track_id) + return FileResponse( + path=file_path, + filename=filename, + media_type=media_type, + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found") + except FileNotFoundError: + raise HTTPException(status_code=404, detail="Track file not found on disk") + except PermissionError: + raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory") + except ExternalServiceError as e: + logger.error("Download error for track %s: %s", track_id, e) + raise HTTPException(status_code=502, detail="Failed to retrieve track file from Lidarr") + except OSError as e: + logger.error("OS error downloading track %s: %s", track_id, e) + raise HTTPException(status_code=500, detail="Failed to read track file") + + +@router.get("/local/album/{album_id}") +async def download_album( + album_id: int, + local_service: LocalFilesService = Depends(get_local_files_service), +) -> FileResponse: + try: + zip_path, zip_filename = await local_service.create_album_zip(album_id) + return FileResponse( + path=zip_path, + filename=zip_filename, + media_type="application/zip", + headers={"Content-Encoding": "identity"}, + background=BackgroundTask(zip_path.unlink, missing_ok=True), + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Album or track files not found") + except PermissionError: + raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory") + except ExternalServiceError as e: + logger.error("Download error for album %s: %s", album_id, e) + raise HTTPException(status_code=502, detail="Failed to retrieve album data from Lidarr") + except OSError as e: + logger.error("OS error creating album ZIP %s: %s", album_id, e) + raise HTTPException(status_code=500, detail="Failed to create album archive") + + +@router.get("/local/album/mbid/{mbid}") +async def download_album_by_mbid( + mbid: str, + local_service: LocalFilesService = Depends(get_local_files_service), +) -> FileResponse: + try: + zip_path, zip_filename = await local_service.create_album_zip_by_mbid(mbid) + return FileResponse( + path=zip_path, + filename=zip_filename, + media_type="application/zip", + headers={"Content-Encoding": "identity"}, + background=BackgroundTask(zip_path.unlink, missing_ok=True), + ) + except ResourceNotFoundError: + raise HTTPException(status_code=404, detail="Album or track files not found") + except PermissionError: + raise HTTPException(status_code=403, detail="Access denied: path is outside the music directory") + except ExternalServiceError as e: + logger.error("Download error for album MBID %s: %s", mbid, e) + raise HTTPException(status_code=502, detail="Failed to retrieve album data from Lidarr") + except OSError as e: + logger.error("OS error creating album ZIP for MBID %s: %s", mbid, e) + raise HTTPException(status_code=500, detail="Failed to create album archive") diff --git a/backend/api/v1/schemas/local_files.py b/backend/api/v1/schemas/local_files.py index 68427a5..7db7906 100644 --- a/backend/api/v1/schemas/local_files.py +++ b/backend/api/v1/schemas/local_files.py @@ -15,6 +15,7 @@ class LocalTrackInfo(AppStruct): class LocalAlbumMatch(AppStruct): found: bool + lidarr_album_id: int | None = None tracks: list[LocalTrackInfo] = [] total_size_bytes: int = 0 primary_format: str | None = None diff --git a/backend/main.py b/backend/main.py index facfb7e..85985fb 100644 --- a/backend/main.py +++ b/backend/main.py @@ -51,6 +51,7 @@ from api.v1.routes import scrobble as scrobble_routes from api.v1.routes import plex_library as plex_library_routes from api.v1.routes import plex_auth as plex_auth_routes from api.v1.routes import version as version_routes +from api.v1.routes import download as download_routes logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) @@ -342,6 +343,7 @@ v1_router.include_router(scrobble_routes.router) v1_router.include_router(profile.router) v1_router.include_router(playlists.router) v1_router.include_router(version_routes.router) +v1_router.include_router(download_routes.router) app.include_router(v1_router) mount_frontend(app) diff --git a/backend/repositories/lidarr/album.py b/backend/repositories/lidarr/album.py index 6f8bd93..811cbed 100644 --- a/backend/repositories/lidarr/album.py +++ b/backend/repositories/lidarr/album.py @@ -58,6 +58,25 @@ class LidarrAlbumRepository(LidarrHistoryRepository): async def get_all_albums(self) -> list[dict[str, Any]]: return await self._get_all_albums_raw() + async def get_album_by_id(self, album_id: int) -> dict[str, Any] | None: + try: + data = await self._get(f"/api/v1/album/{album_id}") + return data + except Exception as e: # noqa: BLE001 + logger.error("Failed to get album %s from Lidarr: %s", album_id, e) + return None + + async def get_album_by_mbid(self, mbid: str) -> dict[str, Any] | None: + """Look up a Lidarr album by MusicBrainz release-group ID.""" + try: + data = await self._get("/api/v1/album", params={"foreignAlbumId": mbid}) + if not data or not isinstance(data, list) or len(data) == 0: + return None + return data[0] + except Exception as e: # noqa: BLE001 + logger.error("Failed to get album by MBID %s from Lidarr: %s", mbid, e) + return None + async def search_for_album(self, term: str) -> list[dict]: params = {"term": term} return await self._get("/api/v1/album/lookup", params=params) diff --git a/backend/repositories/protocols/lidarr.py b/backend/repositories/protocols/lidarr.py index 971bda1..bb8291b 100644 --- a/backend/repositories/protocols/lidarr.py +++ b/backend/repositories/protocols/lidarr.py @@ -85,6 +85,12 @@ class LidarrRepositoryProtocol(Protocol): async def get_track_files_by_album(self, album_id: int) -> list[dict[str, Any]]: ... + async def get_album_by_id(self, album_id: int) -> dict[str, Any] | None: + ... + + async def get_album_by_mbid(self, mbid: str) -> dict[str, Any] | None: + ... + async def get_all_albums(self) -> list[dict[str, Any]]: ... diff --git a/backend/services/local_files_service.py b/backend/services/local_files_service.py index b426432..c91b408 100644 --- a/backend/services/local_files_service.py +++ b/backend/services/local_files_service.py @@ -1,7 +1,10 @@ import asyncio import logging import os +import re import shutil +import tempfile +import zipfile from collections.abc import AsyncGenerator from pathlib import Path from typing import Any @@ -44,6 +47,13 @@ CONTENT_TYPE_MAP: dict[str, str] = { ".opus": "audio/opus", } +_INVALID_FILENAME_CHARS = re.compile(r'[\x00-\x1f\\/:*?"<>|]') + + +def sanitize_filename(name: str) -> str: + """Replace characters that are invalid in filenames across OS platforms.""" + return _INVALID_FILENAME_CHARS.sub("_", name).strip() or "Untitled" + class LocalFilesService: _DEFAULT_STORAGE_STATS_TTL = 300 @@ -346,11 +356,94 @@ class LocalFilesService: return LocalAlbumMatch( found=bool(result_tracks), + lidarr_album_id=album_id, tracks=result_tracks, total_size_bytes=total_size, primary_format=primary_format, ) + async def get_download_track(self, track_file_id: int) -> tuple[Path, str, str]: + """Resolve a track file for download. Returns (path, filename, media_type).""" + lidarr_path = await self.get_track_file_path(track_file_id) + file_path = self._resolve_and_validate_path(lidarr_path) + suffix = file_path.suffix.lower() + if suffix not in AUDIO_EXTENSIONS: + raise ExternalServiceError(f"Unsupported audio format: {suffix}") + media_type = CONTENT_TYPE_MAP.get(suffix, "application/octet-stream") + filename = file_path.name + return file_path, filename, media_type + + async def create_album_zip(self, album_id: int) -> tuple[Path, str]: + """Build a ZIP of all tracks in an album. Returns (zip_path, zip_filename).""" + album_data = await self._lidarr.get_album_by_id(album_id) + if not album_data: + raise ResourceNotFoundError(f"Album {album_id} not found in Lidarr") + + album_title = album_data.get("title") or "Unknown Album" + artist_data = album_data.get("artist") or {} + artist_name = artist_data.get("artistName") or "Unknown Artist" + + result_tracks, _, _ = await self._build_track_list(album_id) + if not result_tracks: + raise ResourceNotFoundError(f"No track files found for album {album_id}") + + # Pre-resolve all paths in the async context + resolved: list[tuple[Path, LocalTrackInfo]] = [] + for track in result_tracks: + try: + lidarr_path = await self.get_track_file_path(track.track_file_id) + file_path = self._resolve_and_validate_path(lidarr_path) + resolved.append((file_path, track)) + except (ResourceNotFoundError, PermissionError, ExternalServiceError): + logger.warning( + "Skipping track %s in album %s ZIP", + track.track_file_id, + album_id, + ) + continue + + if not resolved: + raise ResourceNotFoundError(f"No accessible files for album {album_id}") + + zip_filename = sanitize_filename(f"{artist_name} - {album_title}.zip") + tmp_path = await asyncio.to_thread(self._write_zip_sync, resolved) + return tmp_path, zip_filename + + async def create_album_zip_by_mbid(self, mbid: str) -> tuple[Path, str]: + """Build a ZIP by MusicBrainz release-group ID.""" + album_data = await self._lidarr.get_album_by_mbid(mbid) + if not album_data: + raise ResourceNotFoundError(f"Album with MBID {mbid} not found in Lidarr") + album_id = album_data.get("id") + if not album_id: + raise ResourceNotFoundError(f"Album with MBID {mbid} has no Lidarr ID") + return await self.create_album_zip(album_id) + + @staticmethod + def _write_zip_sync( + resolved: list[tuple[Path, "LocalTrackInfo"]], + ) -> Path: + tmp = tempfile.NamedTemporaryFile(suffix=".zip", delete=False) + try: + multi_disc = len({t.disc_number for _, t in resolved}) > 1 + with zipfile.ZipFile(tmp, "w", zipfile.ZIP_STORED) as zf: + for file_path, track in sorted( + resolved, key=lambda r: (r[1].disc_number, r[1].track_number) + ): + ext = file_path.suffix.lower() + title = sanitize_filename(track.title) + if multi_disc: + arcname = f"{track.disc_number:02d}-{track.track_number:02d} {title}{ext}" + else: + arcname = f"{track.track_number:02d} {title}{ext}" + zf.write(file_path, arcname) + tmp.close() + return Path(tmp.name) + except BaseException: + tmp.close() + Path(tmp.name).unlink(missing_ok=True) + raise + def _library_album_to_summary( self, item: Any, album_id: int, track_file_count: int ) -> LocalAlbumSummary: diff --git a/frontend/src/lib/components/AlbumCardOverlay.svelte b/frontend/src/lib/components/AlbumCardOverlay.svelte index 7b489c8..c7c496f 100644 --- a/frontend/src/lib/components/AlbumCardOverlay.svelte +++ b/frontend/src/lib/components/AlbumCardOverlay.svelte @@ -1,5 +1,5 @@ diff --git a/frontend/src/lib/components/SourceAlbumModal.svelte b/frontend/src/lib/components/SourceAlbumModal.svelte index b5dfd97..9d27be9 100644 --- a/frontend/src/lib/components/SourceAlbumModal.svelte +++ b/frontend/src/lib/components/SourceAlbumModal.svelte @@ -1,7 +1,8 @@