mirror of
https://github.com/HabiRabbu/Musicseerr
synced 2026-04-21 13:37:27 +00:00
Download albums/tracks from local files (#56)
* download albums/tracks from local files * checks
This commit is contained in:
parent
351f31dff6
commit
89405d1c78
17 changed files with 313 additions and 10 deletions
92
backend/api/v1/routes/download.py
Normal file
92
backend/api/v1/routes/download.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]]:
|
||||
...
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
12
frontend/src/lib/utils/downloadHelper.ts
Normal file
12
frontend/src/lib/utils/downloadHelper.ts
Normal 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 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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue