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
17 changed files with 313 additions and 10 deletions
+92
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")
+1
View File
@@ -15,6 +15,7 @@ class LocalTrackInfo(AppStruct):
class LocalAlbumMatch(AppStruct): class LocalAlbumMatch(AppStruct):
found: bool found: bool
lidarr_album_id: int | None = None
tracks: list[LocalTrackInfo] = [] tracks: list[LocalTrackInfo] = []
total_size_bytes: int = 0 total_size_bytes: int = 0
primary_format: str | None = None primary_format: str | None = None
+2
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_library as plex_library_routes
from api.v1.routes import plex_auth as plex_auth_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 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') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) 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(profile.router)
v1_router.include_router(playlists.router) v1_router.include_router(playlists.router)
v1_router.include_router(version_routes.router) v1_router.include_router(version_routes.router)
v1_router.include_router(download_routes.router)
app.include_router(v1_router) app.include_router(v1_router)
mount_frontend(app) mount_frontend(app)
+19
View File
@@ -58,6 +58,25 @@ class LidarrAlbumRepository(LidarrHistoryRepository):
async def get_all_albums(self) -> list[dict[str, Any]]: async def get_all_albums(self) -> list[dict[str, Any]]:
return await self._get_all_albums_raw() 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]: async def search_for_album(self, term: str) -> list[dict]:
params = {"term": term} params = {"term": term}
return await self._get("/api/v1/album/lookup", params=params) return await self._get("/api/v1/album/lookup", params=params)
+6
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_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]]: async def get_all_albums(self) -> list[dict[str, Any]]:
... ...
+93
View File
@@ -1,7 +1,10 @@
import asyncio import asyncio
import logging import logging
import os import os
import re
import shutil import shutil
import tempfile
import zipfile
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -44,6 +47,13 @@ CONTENT_TYPE_MAP: dict[str, str] = {
".opus": "audio/opus", ".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: class LocalFilesService:
_DEFAULT_STORAGE_STATS_TTL = 300 _DEFAULT_STORAGE_STATS_TTL = 300
@@ -346,11 +356,94 @@ class LocalFilesService:
return LocalAlbumMatch( return LocalAlbumMatch(
found=bool(result_tracks), found=bool(result_tracks),
lidarr_album_id=album_id,
tracks=result_tracks, tracks=result_tracks,
total_size_bytes=total_size, total_size_bytes=total_size,
primary_format=primary_format, 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( def _library_album_to_summary(
self, item: Any, album_id: int, track_file_count: int self, item: Any, album_id: int, track_file_count: int
) -> LocalAlbumSummary: ) -> LocalAlbumSummary:
@@ -1,5 +1,5 @@
<script lang="ts"> <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 ContextMenu from './ContextMenu.svelte';
import type { MenuItem } from './ContextMenu.svelte'; import type { MenuItem } from './ContextMenu.svelte';
import { integrationStore } from '$lib/stores/integration'; import { integrationStore } from '$lib/stores/integration';
@@ -12,6 +12,8 @@
type AlbumCardMeta type AlbumCardMeta
} from '$lib/utils/albumCardPlayback'; } from '$lib/utils/albumCardPlayback';
import { openGlobalPlaylistModal } from './AddToPlaylistModal.svelte'; import { openGlobalPlaylistModal } from './AddToPlaylistModal.svelte';
import { downloadFile } from '$lib/utils/downloadHelper';
import { API } from '$lib/constants';
interface Props { interface Props {
mbid: string; mbid: string;
@@ -36,7 +38,7 @@
} }
function getMenuItems(): MenuItem[] { function getMenuItems(): MenuItem[] {
return [ const items: MenuItem[] = [
{ {
label: 'Add to Queue', label: 'Add to Queue',
icon: ListPlus, 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) { async function handlePlay(e: Event) {
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { Snippet } from 'svelte'; 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 ContextMenu from '$lib/components/ContextMenu.svelte';
import type { MenuItem } from '$lib/components/ContextMenu.svelte'; import type { MenuItem } from '$lib/components/ContextMenu.svelte';
@@ -15,6 +15,7 @@
onAddAllToQueue?: () => void; onAddAllToQueue?: () => void;
onPlayAllNext?: () => void; onPlayAllNext?: () => void;
onAddAllToPlaylist?: () => void; onAddAllToPlaylist?: () => void;
onDownload?: () => void;
icon: Snippet; icon: Snippet;
} }
@@ -29,6 +30,7 @@
onAddAllToQueue, onAddAllToQueue,
onPlayAllNext, onPlayAllNext,
onAddAllToPlaylist, onAddAllToPlaylist,
onDownload,
icon icon
}: Props = $props(); }: Props = $props();
@@ -46,6 +48,9 @@
if (onAddAllToPlaylist) { if (onAddAllToPlaylist) {
items.push({ label: 'Add All to Playlist', icon: ListMusic, onclick: 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; return items;
}); });
</script> </script>
@@ -1,7 +1,8 @@
<script lang="ts"> <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 { goto } from '$app/navigation';
import { API } from '$lib/constants'; import { API } from '$lib/constants';
import { downloadFile } from '$lib/utils/downloadHelper';
import { playerStore } from '$lib/stores/player.svelte'; import { playerStore } from '$lib/stores/player.svelte';
import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback'; import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; import { launchLocalPlayback } from '$lib/player/launchLocalPlayback';
@@ -425,17 +426,26 @@
} }
function getBulkMenuItems(): MenuItem[] { function getBulkMenuItems(): MenuItem[] {
return [ const items: MenuItem[] = [
{ label: 'Add All to Queue', icon: ListPlus, onclick: addAllToQueue }, { label: 'Add All to Queue', icon: ListPlus, onclick: addAllToQueue },
{ label: 'Play All Next', icon: ListStart, onclick: playAllNext }, { label: 'Play All Next', icon: ListStart, onclick: playAllNext },
{ label: 'Add All to Playlist', icon: ListMusic, onclick: addAllToPlaylist } { 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[] { function getTrackContextMenuItems(index: number): MenuItem[] {
const queueItem = buildTrackQueueItem(index); const queueItem = buildTrackQueueItem(index);
const hasQueueItem = queueItem !== null; const hasQueueItem = queueItem !== null;
return [ const items: MenuItem[] = [
{ {
label: 'Add to Queue', label: 'Add to Queue',
icon: ListPlus, icon: ListPlus,
@@ -459,6 +469,17 @@
disabled: !hasQueueItem 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 { function getTrackName(index: number): string {
+5
View File
@@ -270,6 +270,11 @@ export const API = {
plexStopped: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/stopped`, plexStopped: (ratingKey: string) => `/api/v1/stream/plex/${ratingKey}/stopped`,
local: (trackId: number | string) => `/api/v1/stream/local/${trackId}` 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: { jellyfinLibrary: {
albumMatch: (mbid: string) => `/api/v1/jellyfin/albums/match/${mbid}`, albumMatch: (mbid: string) => `/api/v1/jellyfin/albums/match/${mbid}`,
albums: ( albums: (
+1
View File
@@ -1180,6 +1180,7 @@ export type LocalTrackInfo = {
export type LocalAlbumMatch = { export type LocalAlbumMatch = {
found: boolean; found: boolean;
lidarr_album_id?: number | null;
tracks: LocalTrackInfo[]; tracks: LocalTrackInfo[];
total_size_bytes: number; total_size_bytes: number;
primary_format?: string | null; primary_format?: string | null;
+12
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);
}
@@ -4,7 +4,10 @@ import { toastStore } from '$lib/stores/toast';
import { playerStore } from '$lib/stores/player.svelte'; import { playerStore } from '$lib/stores/player.svelte';
import type { QueueItem } from '$lib/player/types'; import type { QueueItem } from '$lib/player/types';
import type { MenuItem } from '$lib/components/ContextMenu.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 { LocalAlbumSummary } from '$lib/types';
export const PAGE_SIZE = 48; export const PAGE_SIZE = 48;
@@ -339,7 +342,7 @@ export function createLibraryController<TAlbum>(
function getAlbumMenuItems(album: TAlbum): MenuItem[] { function getAlbumMenuItems(album: TAlbum): MenuItem[] {
const isLoading = menuLoadingAlbumId === adapter.getAlbumId(album); const isLoading = menuLoadingAlbumId === adapter.getAlbumId(album);
return [ const items: MenuItem[] = [
{ {
label: 'Add to Queue', label: 'Add to Queue',
icon: ListPlus, icon: ListPlus,
@@ -359,6 +362,15 @@ export function createLibraryController<TAlbum>(
disabled: isLoading 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 { function init(): void {
@@ -128,6 +128,7 @@
plexEnabled={$integrationStore.plex} plexEnabled={$integrationStore.plex}
jellyfinCallbacks={state.jellyfinCallbacks} jellyfinCallbacks={state.jellyfinCallbacks}
localCallbacks={state.localCallbacks} localCallbacks={state.localCallbacks}
localDownloadCallback={state.localDownloadCallback}
navidromeCallbacks={state.navidromeCallbacks} navidromeCallbacks={state.navidromeCallbacks}
plexCallbacks={state.plexCallbacks} plexCallbacks={state.plexCallbacks}
onTrackLinksUpdate={state.handleTrackLinksUpdate} onTrackLinksUpdate={state.handleTrackLinksUpdate}
@@ -39,6 +39,7 @@
plexEnabled: boolean; plexEnabled: boolean;
jellyfinCallbacks: SourceCallbacks; jellyfinCallbacks: SourceCallbacks;
localCallbacks: SourceCallbacks; localCallbacks: SourceCallbacks;
localDownloadCallback?: { callback: (() => void) | undefined };
navidromeCallbacks: SourceCallbacks; navidromeCallbacks: SourceCallbacks;
plexCallbacks: SourceCallbacks; plexCallbacks: SourceCallbacks;
onTrackLinksUpdate: (links: YouTubeTrackLink[]) => void; onTrackLinksUpdate: (links: YouTubeTrackLink[]) => void;
@@ -67,6 +68,7 @@
plexEnabled, plexEnabled,
jellyfinCallbacks, jellyfinCallbacks,
localCallbacks, localCallbacks,
localDownloadCallback,
navidromeCallbacks, navidromeCallbacks,
plexCallbacks, plexCallbacks,
onTrackLinksUpdate, onTrackLinksUpdate,
@@ -129,6 +131,7 @@
onAddAllToQueue={localCallbacks.onAddAllToQueue} onAddAllToQueue={localCallbacks.onAddAllToQueue}
onPlayAllNext={localCallbacks.onPlayAllNext} onPlayAllNext={localCallbacks.onPlayAllNext}
onAddAllToPlaylist={localCallbacks.onAddAllToPlaylist} onAddAllToPlaylist={localCallbacks.onAddAllToPlaylist}
onDownload={localDownloadCallback?.callback}
> >
{#snippet icon()} {#snippet icon()}
<LocalFilesIcon class="h-5 w-5" /> <LocalFilesIcon class="h-5 w-5" />
@@ -43,6 +43,8 @@ import { launchJellyfinPlayback } from '$lib/player/launchJellyfinPlayback';
import { launchLocalPlayback } from '$lib/player/launchLocalPlayback'; import { launchLocalPlayback } from '$lib/player/launchLocalPlayback';
import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback'; import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback';
import { launchPlexPlayback } from '$lib/player/launchPlexPlayback'; 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 type { MenuItem } from '$lib/components/ContextMenu.svelte';
import { import {
fetchAlbumBasic, 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( const jellyfinCallbacks: SourceCallbacks = buildSourceCallbacks(
() => jellyfinMatch, () => jellyfinMatch,
launchJellyfinPlayback, launchJellyfinPlayback,
@@ -890,6 +899,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
}, },
jellyfinCallbacks, jellyfinCallbacks,
localCallbacks, localCallbacks,
localDownloadCallback,
navidromeCallbacks, navidromeCallbacks,
plexCallbacks, plexCallbacks,
...eventHandlers, ...eventHandlers,
@@ -27,7 +27,9 @@ import { launchNavidromePlayback } from '$lib/player/launchNavidromePlayback';
import { launchPlexPlayback } from '$lib/player/launchPlexPlayback'; import { launchPlexPlayback } from '$lib/player/launchPlexPlayback';
import { playerStore } from '$lib/stores/player.svelte'; import { playerStore } from '$lib/stores/player.svelte';
import type { MenuItem } from '$lib/components/ContextMenu.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'; import type { SourceCallbacks } from './albumPageState.svelte';
export function getPlaybackMeta(album: AlbumBasicInfo): PlaybackMeta { export function getPlaybackMeta(album: AlbumBasicInfo): PlaybackMeta {
@@ -153,7 +155,7 @@ export function getTrackContextMenuItems(
resolvedPlex resolvedPlex
); );
const hasSource = queueItem !== null; const hasSource = queueItem !== null;
return [ const items: MenuItem[] = [
{ {
label: 'Add to Queue', label: 'Add to Queue',
icon: ListPlus, icon: ListPlus,
@@ -179,6 +181,14 @@ export function getTrackContextMenuItems(
disabled: !hasSource disabled: !hasSource
} }
]; ];
if (resolvedLocal) {
items.push({
label: 'Download',
icon: Download,
onclick: () => downloadFile(API.download.localTrack(resolvedLocal.track_file_id))
});
}
return items;
} }
function getSourceQueueItems( function getSourceQueueItems(