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 @@