Download albums/tracks from local files (#56)
* download albums/tracks from local files * checks
This commit is contained in:
@@ -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):
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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]]:
|
||||||
...
|
...
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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: (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user