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:
Harvey
2026-04-06 23:08:58 +01:00
committed by GitHub
parent a3e0b2f65a
commit 343bafd7f4
44 changed files with 2360 additions and 271 deletions
@@ -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>
+3
View File
@@ -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[];
+7 -1
View File
@@ -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
};