Download albums/tracks from local files (#56)

* download albums/tracks from local files

* checks
This commit is contained in:
Harvey 2026-04-17 23:59:05 +00:00 committed by GitHub
parent 351f31dff6
commit 89405d1c78
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 313 additions and 10 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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]]:
...

View file

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

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Play, Shuffle, ListPlus, ListStart, ListMusic } from 'lucide-svelte';
import { Play, Shuffle, ListPlus, ListStart, ListMusic, Download } from 'lucide-svelte';
import ContextMenu from './ContextMenu.svelte';
import type { MenuItem } from './ContextMenu.svelte';
import { integrationStore } from '$lib/stores/integration';
@ -12,6 +12,8 @@
type AlbumCardMeta
} from '$lib/utils/albumCardPlayback';
import { openGlobalPlaylistModal } from './AddToPlaylistModal.svelte';
import { downloadFile } from '$lib/utils/downloadHelper';
import { API } from '$lib/constants';
interface Props {
mbid: string;
@ -36,7 +38,7 @@
}
function getMenuItems(): MenuItem[] {
return [
const items: MenuItem[] = [
{
label: 'Add to Queue',
icon: ListPlus,
@ -56,6 +58,14 @@
}
}
];
if ($integrationStore.localfiles) {
items.push({
label: 'Download Album',
icon: Download,
onclick: () => downloadFile(API.download.localAlbumByMbid(mbid))
});
}
return items;
}
async function handlePlay(e: Event) {

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Shuffle, Play, ListPlus, ListStart, ListMusic } from 'lucide-svelte';
import { Shuffle, Play, ListPlus, ListStart, ListMusic, Download } from 'lucide-svelte';
import ContextMenu from '$lib/components/ContextMenu.svelte';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
@ -15,6 +15,7 @@
onAddAllToQueue?: () => void;
onPlayAllNext?: () => void;
onAddAllToPlaylist?: () => void;
onDownload?: () => void;
icon: Snippet;
}
@ -29,6 +30,7 @@
onAddAllToQueue,
onPlayAllNext,
onAddAllToPlaylist,
onDownload,
icon
}: Props = $props();
@ -46,6 +48,9 @@
if (onAddAllToPlaylist) {
items.push({ label: 'Add All to Playlist', icon: ListMusic, onclick: onAddAllToPlaylist });
}
if (onDownload) {
items.push({ label: 'Download Album', icon: Download, onclick: onDownload });
}
return items;
});
</script>

View file

@ -1,7 +1,8 @@
<script lang="ts">
import { Shuffle, Play, X, ListPlus, ListStart, ListMusic, Info } from 'lucide-svelte';
import { Shuffle, Play, X, ListPlus, ListStart, ListMusic, Info, Download } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { API } from '$lib/constants';
import { downloadFile } from '$lib/utils/downloadHelper';
import { playerStore } from '$lib/stores/player.svelte';
import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
import { launchLocalPlayback } from '$lib/player/launchLocalPlayback';
@ -425,17 +426,26 @@
}
function getBulkMenuItems(): MenuItem[] {
return [
const items: MenuItem[] = [
{ label: 'Add All to Queue', icon: ListPlus, onclick: addAllToQueue },
{ label: 'Play All Next', icon: ListStart, onclick: playAllNext },
{ label: 'Add All to Playlist', icon: ListMusic, onclick: addAllToPlaylist }
];
if (sourceType === 'local' && album) {
const localAlbum = album as import('$lib/types').LocalAlbumSummary;
items.push({
label: 'Download Album',
icon: Download,
onclick: () => downloadFile(API.download.localAlbum(localAlbum.lidarr_album_id))
});
}
return items;
}
function getTrackContextMenuItems(index: number): MenuItem[] {
const queueItem = buildTrackQueueItem(index);
const hasQueueItem = queueItem !== null;
return [
const items: MenuItem[] = [
{
label: 'Add to Queue',
icon: ListPlus,
@ -459,6 +469,17 @@
disabled: !hasQueueItem
}
];
if (sourceType === 'local') {
const track = localTracks[index];
if (track) {
items.push({
label: 'Download',
icon: Download,
onclick: () => downloadFile(API.download.localTrack(track.track_file_id))
});
}
}
return items;
}
function getTrackName(index: number): string {

View file

@ -270,6 +270,11 @@ export const API = {
plexStopped: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/stopped`,
local: (trackId: number | string) => `/api/v1/stream/local/${trackId}`
},
download: {
localTrack: (trackId: number) => `/api/v1/download/local/track/${trackId}`,
localAlbum: (albumId: number) => `/api/v1/download/local/album/${albumId}`,
localAlbumByMbid: (mbid: string) => `/api/v1/download/local/album/mbid/${mbid}`
},
jellyfinLibrary: {
albumMatch: (mbid: string) => `/api/v1/jellyfin/albums/match/${mbid}`,
albums: (

View file

@ -1180,6 +1180,7 @@ export type LocalTrackInfo = {
export type LocalAlbumMatch = {
found: boolean;
lidarr_album_id?: number | null;
tracks: LocalTrackInfo[];
total_size_bytes: number;
primary_format?: string | null;

View file

@ -0,0 +1,12 @@
/**
* Trigger a browser-native file download via an invisible anchor element.
* The server's Content-Disposition header determines the saved filename.
*/
export function downloadFile(url: string): void {
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}

View file

@ -4,7 +4,10 @@ import { toastStore } from '$lib/stores/toast';
import { playerStore } from '$lib/stores/player.svelte';
import type { QueueItem } from '$lib/player/types';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
import { ListPlus, ListStart, ListMusic } from 'lucide-svelte';
import { ListPlus, ListStart, ListMusic, Download } from 'lucide-svelte';
import { downloadFile } from '$lib/utils/downloadHelper';
import { API } from '$lib/constants';
import type { LocalAlbumSummary } from '$lib/types';
export const PAGE_SIZE = 48;
@ -339,7 +342,7 @@ export function createLibraryController<TAlbum>(
function getAlbumMenuItems(album: TAlbum): MenuItem[] {
const isLoading = menuLoadingAlbumId === adapter.getAlbumId(album);
return [
const items: MenuItem[] = [
{
label: 'Add to Queue',
icon: ListPlus,
@ -359,6 +362,15 @@ export function createLibraryController<TAlbum>(
disabled: isLoading
}
];
if (adapter.sourceType === 'local') {
const localAlbum = album as LocalAlbumSummary;
items.push({
label: 'Download Album',
icon: Download,
onclick: () => downloadFile(API.download.localAlbum(localAlbum.lidarr_album_id))
});
}
return items;
}
function init(): void {

View file

@ -128,6 +128,7 @@
plexEnabled={$integrationStore.plex}
jellyfinCallbacks={state.jellyfinCallbacks}
localCallbacks={state.localCallbacks}
localDownloadCallback={state.localDownloadCallback}
navidromeCallbacks={state.navidromeCallbacks}
plexCallbacks={state.plexCallbacks}
onTrackLinksUpdate={state.handleTrackLinksUpdate}

View file

@ -39,6 +39,7 @@
plexEnabled: boolean;
jellyfinCallbacks: SourceCallbacks;
localCallbacks: SourceCallbacks;
localDownloadCallback?: { callback: (() => void) | undefined };
navidromeCallbacks: SourceCallbacks;
plexCallbacks: SourceCallbacks;
onTrackLinksUpdate: (links: YouTubeTrackLink[]) => void;
@ -67,6 +68,7 @@
plexEnabled,
jellyfinCallbacks,
localCallbacks,
localDownloadCallback,
navidromeCallbacks,
plexCallbacks,
onTrackLinksUpdate,
@ -129,6 +131,7 @@
onAddAllToQueue={localCallbacks.onAddAllToQueue}
onPlayAllNext={localCallbacks.onPlayAllNext}
onAddAllToPlaylist={localCallbacks.onAddAllToPlaylist}
onDownload={localDownloadCallback?.callback}
>
{#snippet icon()}
<LocalFilesIcon class="h-5 w-5" />

View file

@ -43,6 +43,8 @@ import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
import { launchLocalPlayback } from '$lib/player/launchLocalPlayback';
import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback';
import { launchPlexPlayback } from '$lib/player/launchPlexPlayback';
import { downloadFile } from '$lib/utils/downloadHelper';
import { API } from '$lib/constants';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
import {
fetchAlbumBasic,
@ -692,6 +694,13 @@ export function createAlbumPageState(albumIdGetter: () => string) {
);
}
const localDownloadCallback = $derived<{ callback: (() => void) | undefined }>(
(() => {
const id = localMatch?.lidarr_album_id;
return { callback: id ? () => downloadFile(API.download.localAlbum(id)) : undefined };
})()
);
const jellyfinCallbacks: SourceCallbacks = buildSourceCallbacks(
() => jellyfinMatch,
launchJellyfinPlayback,
@ -890,6 +899,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
},
jellyfinCallbacks,
localCallbacks,
localDownloadCallback,
navidromeCallbacks,
plexCallbacks,
...eventHandlers,

View file

@ -27,7 +27,9 @@ import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback';
import { launchPlexPlayback } from '$lib/player/launchPlexPlayback';
import { playerStore } from '$lib/stores/player.svelte';
import type { MenuItem } from '$lib/components/ContextMenu.svelte';
import { ListPlus, ListStart, ListMusic } from 'lucide-svelte';
import { ListPlus, ListStart, ListMusic, Download } from 'lucide-svelte';
import { downloadFile } from '$lib/utils/downloadHelper';
import { API } from '$lib/constants';
import type { SourceCallbacks } from './albumPageState.svelte';
export function getPlaybackMeta(album: AlbumBasicInfo): PlaybackMeta {
@ -153,7 +155,7 @@ export function getTrackContextMenuItems(
resolvedPlex
);
const hasSource = queueItem !== null;
return [
const items: MenuItem[] = [
{
label: 'Add to Queue',
icon: ListPlus,
@ -179,6 +181,14 @@ export function getTrackContextMenuItems(
disabled: !hasSource
}
];
if (resolvedLocal) {
items.push({
label: 'Download',
icon: Download,
onclick: () => downloadFile(API.download.localTrack(resolvedLocal.track_file_id))
});
}
return items;
}
function getSourceQueueItems(