feat: Requests / Add to Library Rework - Unmonitored album default + … (#25)
* feat: Requests / Add to Library Rework - Unmonitored album default + Resilience * checking for source + refresh album logic * artist monitoring + auto downloading + various request fixes * synchronous album requests * format
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
|
||||
import { imageSettingsStore } from '$lib/stores/imageSettings';
|
||||
import ArtistLinks from './ArtistLinks.svelte';
|
||||
import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte';
|
||||
import BackButton from './BackButton.svelte';
|
||||
import HeroBackdrop from './HeroBackdrop.svelte';
|
||||
import { getApiUrl } from '$lib/utils/api';
|
||||
@@ -165,6 +166,20 @@
|
||||
{#if validLinks.length > 0}
|
||||
<ArtistLinks links={validLinks} />
|
||||
{/if}
|
||||
|
||||
{#if artist.in_lidarr}
|
||||
<div class="mt-3">
|
||||
<ArtistMonitoringToggle
|
||||
artistMbid={artist.musicbrainz_id}
|
||||
monitored={artist.monitored ?? false}
|
||||
autoDownload={artist.auto_download ?? false}
|
||||
on:change={(e) => {
|
||||
artist.monitored = e.detail.monitored;
|
||||
artist.auto_download = e.detail.autoDownload;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { Eye, Download } from 'lucide-svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
export let artistMbid: string;
|
||||
export let monitored: boolean = false;
|
||||
export let autoDownload: boolean = false;
|
||||
export let disabled: boolean = false;
|
||||
|
||||
const dispatch = createEventDispatcher<{
|
||||
change: { monitored: boolean; autoDownload: boolean };
|
||||
}>();
|
||||
|
||||
let saving = false;
|
||||
|
||||
async function updateMonitoring(newMonitored: boolean, newAutoDownload: boolean) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
try {
|
||||
await api.global.put(`/api/v1/artists/${artistMbid}/monitoring`, {
|
||||
monitored: newMonitored,
|
||||
auto_download: newAutoDownload
|
||||
});
|
||||
monitored = newMonitored;
|
||||
autoDownload = newAutoDownload;
|
||||
dispatch('change', { monitored, autoDownload });
|
||||
} catch {
|
||||
// revert is implicit: we only update state on success
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMonitorToggle() {
|
||||
const newMonitored = !monitored;
|
||||
const newAutoDownload = newMonitored ? autoDownload : false;
|
||||
await updateMonitoring(newMonitored, newAutoDownload);
|
||||
}
|
||||
|
||||
async function handleAutoDownloadToggle() {
|
||||
await updateMonitoring(monitored, !autoDownload);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<label class="label cursor-pointer gap-2" aria-label="Monitor this artist">
|
||||
<Eye class="h-4 w-4 text-base-content/70" />
|
||||
<span class="text-sm text-base-content/70">Monitor</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={monitored}
|
||||
on:change={handleMonitorToggle}
|
||||
disabled={disabled || saving}
|
||||
class="toggle toggle-sm toggle-accent"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
class="label cursor-pointer gap-2 transition-opacity"
|
||||
class:opacity-40={!monitored}
|
||||
aria-label="Download new releases"
|
||||
>
|
||||
<Download class="h-4 w-4 text-base-content/70" />
|
||||
<span class="text-sm text-base-content/70">Download new releases</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoDownload}
|
||||
on:change={handleAutoDownloadToggle}
|
||||
disabled={disabled || saving || !monitored}
|
||||
class="toggle toggle-sm toggle-accent"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
@@ -147,6 +147,9 @@ export type ArtistInfo = {
|
||||
aliases: string[];
|
||||
external_links: ExternalLink[];
|
||||
in_library: boolean;
|
||||
in_lidarr?: boolean;
|
||||
monitored?: boolean;
|
||||
auto_download?: boolean;
|
||||
albums: ReleaseGroup[];
|
||||
singles: ReleaseGroup[];
|
||||
eps: ReleaseGroup[];
|
||||
|
||||
@@ -12,6 +12,9 @@ export type AlbumRequestContext = {
|
||||
artist?: string;
|
||||
album?: string;
|
||||
year?: number | null;
|
||||
artistMbid?: string;
|
||||
monitorArtist?: boolean;
|
||||
autoDownloadArtist?: boolean;
|
||||
};
|
||||
|
||||
export async function requestAlbum(
|
||||
@@ -23,7 +26,10 @@ export async function requestAlbum(
|
||||
musicbrainz_id: musicbrainzId,
|
||||
artist: context?.artist ?? undefined,
|
||||
album: context?.album ?? undefined,
|
||||
year: context?.year ?? undefined
|
||||
year: context?.year ?? undefined,
|
||||
artist_mbid: context?.artistMbid ?? undefined,
|
||||
monitor_artist: context?.monitorArtist ?? false,
|
||||
auto_download_artist: context?.autoDownloadArtist ?? false
|
||||
});
|
||||
|
||||
libraryStore.addRequested(musicbrainzId);
|
||||
|
||||
@@ -60,9 +60,13 @@
|
||||
inLibrary={state.inLibrary}
|
||||
isRequested={state.isRequested}
|
||||
requesting={state.requesting}
|
||||
refreshing={state.refreshing}
|
||||
pollingForSources={state.pollingForSources}
|
||||
lidarrConfigured={$integrationStore.lidarr}
|
||||
artistMonitored={state.artistMonitored}
|
||||
onrequest={state.handleRequest}
|
||||
ondelete={state.handleDeleteClick}
|
||||
onrefresh={state.refreshAll}
|
||||
onartistclick={state.goToArtist}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import AlbumImage from '$lib/components/AlbumImage.svelte';
|
||||
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
|
||||
import { formatTotalDuration } from '$lib/utils/formatting';
|
||||
import { Check, Trash2, Clock, Plus } from 'lucide-svelte';
|
||||
import { Check, Trash2, Clock, Plus, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
album: AlbumBasicInfo;
|
||||
@@ -14,9 +14,14 @@
|
||||
inLibrary: boolean;
|
||||
isRequested: boolean;
|
||||
requesting: boolean;
|
||||
refreshing: boolean;
|
||||
pollingForSources: boolean;
|
||||
lidarrConfigured: boolean;
|
||||
onrequest: () => void;
|
||||
|
||||
artistMonitored?: boolean;
|
||||
onrequest: (opts?: { monitorArtist?: boolean; autoDownloadArtist?: boolean }) => void;
|
||||
ondelete: () => void;
|
||||
onrefresh: () => void;
|
||||
onartistclick: () => void;
|
||||
}
|
||||
|
||||
@@ -27,12 +32,26 @@
|
||||
inLibrary,
|
||||
isRequested,
|
||||
requesting,
|
||||
refreshing,
|
||||
pollingForSources,
|
||||
lidarrConfigured,
|
||||
artistMonitored = false,
|
||||
onrequest,
|
||||
ondelete,
|
||||
onrefresh,
|
||||
onartistclick
|
||||
}: Props = $props();
|
||||
|
||||
let monitorArtist = $state(false);
|
||||
let autoDownloadArtist = $state(false);
|
||||
|
||||
// Reset checkboxes when navigating between albums
|
||||
$effect(() => {
|
||||
void album.musicbrainz_id;
|
||||
monitorArtist = false;
|
||||
autoDownloadArtist = false;
|
||||
});
|
||||
|
||||
let backdropUrl = $derived(
|
||||
album.cover_url ||
|
||||
album.album_thumb_url ||
|
||||
@@ -53,7 +72,17 @@
|
||||
/>
|
||||
|
||||
<div class="relative z-10 flex flex-col lg:flex-row gap-6 lg:gap-8 p-4 sm:p-6 lg:p-8">
|
||||
<div class="w-full lg:w-64 xl:w-80 shrink-0">
|
||||
{#if (inLibrary || isRequested) && lidarrConfigured}
|
||||
<button
|
||||
class="absolute top-3 right-3 btn btn-sm btn-ghost btn-circle z-20"
|
||||
onclick={onrefresh}
|
||||
disabled={refreshing}
|
||||
title="Refresh album status"
|
||||
>
|
||||
<RefreshCw class="h-5 w-5 {refreshing ? 'animate-spin' : ''}" />
|
||||
</button>
|
||||
{/if}
|
||||
<div class="w-full lg:w-64 xl:w-80 flex-shrink-0">
|
||||
<AlbumImage
|
||||
mbid={album.musicbrainz_id}
|
||||
customUrl={album.cover_url}
|
||||
@@ -135,6 +164,12 @@
|
||||
<Check class="h-4 w-4" />
|
||||
In Library
|
||||
</div>
|
||||
{#if pollingForSources}
|
||||
<div class="badge badge-lg badge-ghost gap-2 animate-pulse">
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Checking for sources…
|
||||
</div>
|
||||
{/if}
|
||||
<button class="btn btn-sm btn-error btn-outline gap-1" onclick={ondelete}>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Remove
|
||||
@@ -149,20 +184,42 @@
|
||||
Remove
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
class="btn btn-lg gap-2"
|
||||
style="background-color: {colors.accent}; color: {colors.secondary}; border: none;"
|
||||
onclick={onrequest}
|
||||
disabled={requesting}
|
||||
>
|
||||
{#if requesting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Requesting...
|
||||
{:else}
|
||||
<Plus class="h-5 w-5" />
|
||||
Add to Library
|
||||
<div class="flex flex-col gap-3">
|
||||
<button
|
||||
class="btn btn-lg gap-2"
|
||||
style="background-color: {colors.accent}; color: {colors.secondary}; border: none;"
|
||||
onclick={() => onrequest({ monitorArtist, autoDownloadArtist })}
|
||||
disabled={requesting}
|
||||
>
|
||||
{#if requesting}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Requesting...
|
||||
{:else}
|
||||
<Plus class="h-5 w-5" />
|
||||
Add to Library
|
||||
{/if}
|
||||
</button>
|
||||
{#if !artistMonitored}
|
||||
<label class="label cursor-pointer gap-2 justify-start">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={monitorArtist}
|
||||
class="checkbox checkbox-sm checkbox-accent"
|
||||
/>
|
||||
<span class="text-sm text-base-content/70">Monitor this artist</span>
|
||||
</label>
|
||||
{#if monitorArtist}
|
||||
<label class="label cursor-pointer gap-2 justify-start pl-6">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={autoDownloadArtist}
|
||||
class="checkbox checkbox-sm checkbox-accent"
|
||||
/>
|
||||
<span class="text-sm text-base-content/70">Download new releases</span>
|
||||
</label>
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface EventHandlerDeps {
|
||||
setRemovedArtistName: (v: string) => void;
|
||||
setToast: (msg: string, type: 'success' | 'error' | 'info' | 'warning') => void;
|
||||
setShowToast: (v: boolean) => void;
|
||||
onRequestSuccess?: () => void;
|
||||
}
|
||||
|
||||
export function createEventHandlers(deps: EventHandlerDeps) {
|
||||
@@ -44,7 +45,10 @@ export function createEventHandlers(deps: EventHandlerDeps) {
|
||||
deps.setQuota(q);
|
||||
}
|
||||
|
||||
async function handleRequest(): Promise<void> {
|
||||
async function handleRequest(opts?: {
|
||||
monitorArtist?: boolean;
|
||||
autoDownloadArtist?: boolean;
|
||||
}): Promise<void> {
|
||||
const album = deps.getAlbum();
|
||||
if (!album || deps.getRequesting()) return;
|
||||
deps.setRequesting(true);
|
||||
@@ -52,7 +56,10 @@ export function createEventHandlers(deps: EventHandlerDeps) {
|
||||
const result = await requestAlbum(album.musicbrainz_id, {
|
||||
artist: album.artist_name ?? undefined,
|
||||
album: album.title,
|
||||
year: album.year ?? undefined
|
||||
year: album.year ?? undefined,
|
||||
artistMbid: album.artist_id,
|
||||
monitorArtist: opts?.monitorArtist,
|
||||
autoDownloadArtist: opts?.autoDownloadArtist
|
||||
});
|
||||
const current = deps.getAlbum();
|
||||
if (result.success && current) {
|
||||
@@ -61,6 +68,7 @@ export function createEventHandlers(deps: EventHandlerDeps) {
|
||||
deps.albumBasicCacheSet(current, deps.getAlbumId());
|
||||
deps.setToast('Added to Library', 'success');
|
||||
deps.setShowToast(true);
|
||||
deps.onRequestSuccess?.();
|
||||
}
|
||||
} finally {
|
||||
deps.setRequesting(false);
|
||||
|
||||
@@ -106,3 +106,12 @@ export async function fetchLastFm(
|
||||
signal
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshAlbum(
|
||||
albumId: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<AlbumBasicInfo | null> {
|
||||
return api
|
||||
.post<AlbumBasicInfo>(`/api/v1/albums/${albumId}/refresh`, undefined, { signal })
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { libraryStore } from '$lib/stores/library';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
import { isAbortError } from '$lib/utils/errorHandling';
|
||||
import { extractServiceStatus } from '$lib/utils/serviceStatus';
|
||||
import { api } from '$lib/api/client';
|
||||
import {
|
||||
albumBasicCache,
|
||||
albumDiscoveryCache,
|
||||
@@ -45,7 +46,8 @@ import {
|
||||
fetchJellyfinMatch,
|
||||
fetchLocalMatch,
|
||||
fetchNavidromeMatch,
|
||||
fetchLastFm
|
||||
fetchLastFm,
|
||||
refreshAlbum
|
||||
} from './albumFetchers';
|
||||
import { buildRenderedTrackSections, buildSortedTrackMap } from './albumTrackResolvers';
|
||||
import type { RenderedTrackSection } from './albumTrackResolvers';
|
||||
@@ -95,6 +97,11 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
let renderedTrackSections = $state<RenderedTrackSection[]>([]);
|
||||
let playlistModalRef = $state<{ open: (tracks: QueueItem[]) => void } | null>(null);
|
||||
let abortController: AbortController | null = null;
|
||||
let refreshing = $state(false);
|
||||
let pollingForSources = $state(false);
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let artistInLidarr = $state(false);
|
||||
let artistMonitored = $state(false);
|
||||
|
||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- derived Map is recreated each time, reactive by nature
|
||||
const trackLinkMap = $derived(new Map(trackLinks.map((tl) => [getDiscTrackKey(tl), tl])));
|
||||
@@ -116,6 +123,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
stopPolling();
|
||||
album = null;
|
||||
tracksInfo = null;
|
||||
renderedTrackSections = [];
|
||||
@@ -137,6 +145,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
loadingNavidrome = false;
|
||||
lastfmEnrichment = null;
|
||||
loadingLastfm = true;
|
||||
refreshing = false;
|
||||
}
|
||||
|
||||
function hydrateFromCache(albumId: string) {
|
||||
@@ -316,6 +325,23 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchArtistMonitoringState(artistId: string, signal: AbortSignal) {
|
||||
try {
|
||||
const integrations = get(integrationStore);
|
||||
if (!integrations.lidarr) return;
|
||||
const info = await api.global.get<{
|
||||
in_lidarr?: boolean;
|
||||
monitored?: boolean;
|
||||
auto_download?: boolean;
|
||||
}>(`/api/v1/artists/${artistId}/monitoring`, { signal });
|
||||
if (signal.aborted) return;
|
||||
artistInLidarr = info.in_lidarr ?? false;
|
||||
artistMonitored = info.monitored ?? false;
|
||||
} catch (e) {
|
||||
console.debug('Artist monitoring fetch failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAlbum(albumId: string) {
|
||||
const { refreshBasic, refreshTracks, refreshDiscovery, refreshLastfm, refreshSourceMatch } =
|
||||
hydrateFromCache(albumId);
|
||||
@@ -323,6 +349,9 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
artistInLidarr = false;
|
||||
artistMonitored = false;
|
||||
|
||||
// Fire source matches that only need albumId immediately (before basic loads)
|
||||
if (refreshSourceMatch) {
|
||||
void (async () => {
|
||||
@@ -364,11 +393,12 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
void doFetchBasic(albumId, signal);
|
||||
}
|
||||
if (signal.aborted || !album) return;
|
||||
if (album.artist_id) void fetchArtistMonitoringState(album.artist_id, signal);
|
||||
if (refreshTracks && !refreshBasic) void doFetchTracks(albumId, signal);
|
||||
if (refreshDiscovery) void doFetchDiscovery(albumId, signal);
|
||||
if (!refreshBasic) void doFetchYouTube(albumId, signal);
|
||||
if (refreshLastfm) void doFetchLastFm(albumId, signal);
|
||||
// Navidrome match needs album title/artist — fire after basic loads
|
||||
// Navidrome match needs album title/artist - fire after basic loads
|
||||
if (refreshSourceMatch) {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -397,6 +427,71 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearInterval(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
pollingForSources = false;
|
||||
}
|
||||
|
||||
function hasAnySourceFound(): boolean {
|
||||
return !!(jellyfinMatch?.found || localMatch?.found || navidromeMatch?.found);
|
||||
}
|
||||
|
||||
async function forceLoadAlbum(albumId: string): Promise<void> {
|
||||
albumBasicCache.remove(albumId);
|
||||
albumTracksCache.remove(albumId);
|
||||
albumSourceMatchCache.remove(albumId);
|
||||
|
||||
if (abortController) abortController.abort();
|
||||
abortController = new AbortController();
|
||||
const signal = abortController.signal;
|
||||
|
||||
try {
|
||||
const freshBasic = await refreshAlbum(albumId, signal);
|
||||
if (freshBasic) {
|
||||
album = freshBasic;
|
||||
extractServiceStatus(album);
|
||||
albumBasicCache.set(album, albumId);
|
||||
}
|
||||
} catch {
|
||||
/* refresh endpoint failure is non-fatal, loadAlbum will re-fetch */
|
||||
}
|
||||
|
||||
if (signal.aborted) return;
|
||||
await loadAlbum(albumId);
|
||||
}
|
||||
|
||||
async function refreshAll(): Promise<void> {
|
||||
const albumId = albumIdGetter();
|
||||
if (!albumId || refreshing) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
await forceLoadAlbum(albumId);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startSourcePolling(): void {
|
||||
stopPolling();
|
||||
const albumId = albumIdGetter();
|
||||
if (!albumId) return;
|
||||
pollingForSources = true;
|
||||
const startTime = Date.now();
|
||||
const POLL_INTERVAL = 30_000;
|
||||
const MAX_POLL_DURATION = 5 * 60_000;
|
||||
|
||||
pollTimer = setInterval(() => {
|
||||
if (Date.now() - startTime >= MAX_POLL_DURATION || hasAnySourceFound()) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
void forceLoadAlbum(albumId);
|
||||
}, POLL_INTERVAL);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const albumId = albumIdGetter();
|
||||
if (!browser || !albumId) return;
|
||||
@@ -405,6 +500,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
void loadAlbum(albumId);
|
||||
});
|
||||
return () => {
|
||||
stopPolling();
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
@@ -412,6 +508,12 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (inLibrary && !hasAnySourceFound()) {
|
||||
untrack(() => startSourcePolling());
|
||||
}
|
||||
});
|
||||
|
||||
const eventHandlers = createEventHandlers({
|
||||
getAlbum: () => album,
|
||||
setAlbum: (a) => (album = a),
|
||||
@@ -430,7 +532,12 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
toastMessage = msg;
|
||||
toastType = type;
|
||||
},
|
||||
setShowToast: (v) => (showToast = v)
|
||||
setShowToast: (v) => (showToast = v),
|
||||
onRequestSuccess: () => {
|
||||
albumSourceMatchCache.remove(albumIdGetter());
|
||||
const aid = album?.artist_id;
|
||||
if (aid && abortController) void fetchArtistMonitoringState(aid, abortController.signal);
|
||||
}
|
||||
});
|
||||
|
||||
function retryTracks(): void {
|
||||
@@ -630,6 +737,18 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
get isRequested() {
|
||||
return isRequested;
|
||||
},
|
||||
get artistInLidarr() {
|
||||
return artistInLidarr;
|
||||
},
|
||||
get artistMonitored() {
|
||||
return artistMonitored;
|
||||
},
|
||||
get refreshing() {
|
||||
return refreshing;
|
||||
},
|
||||
get pollingForSources() {
|
||||
return pollingForSources;
|
||||
},
|
||||
get playlistModalRef() {
|
||||
return playlistModalRef;
|
||||
},
|
||||
@@ -641,6 +760,7 @@ export function createAlbumPageState(albumIdGetter: () => string) {
|
||||
navidromeCallbacks,
|
||||
...eventHandlers,
|
||||
retryTracks,
|
||||
refreshAll,
|
||||
playSourceTrack,
|
||||
getTrackContextMenuItems
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user