Failed to verify fix + reactivity & polling fixes for multi downloads
This commit is contained in:
@@ -4,12 +4,15 @@
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { playerStore } from '$lib/stores/player.svelte';
|
||||
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
let completedPerJob = $derived.by(() => {
|
||||
const { mbidSet } = $libraryStore;
|
||||
const counts: Record<string, number> = {};
|
||||
for (const job of batchDownloadStore.jobs) {
|
||||
let done = 0;
|
||||
for (const mbid of job.musicbrainzIds) {
|
||||
if (libraryStore.isInLibrary(mbid)) done++;
|
||||
if (mbidSet.has(mbid.toLowerCase())) done++;
|
||||
}
|
||||
counts[job.artistId] = done;
|
||||
}
|
||||
@@ -20,6 +23,19 @@
|
||||
batchDownloadStore.jobs.every((job) => (completedPerJob[job.artistId] ?? 0) >= job.total)
|
||||
);
|
||||
|
||||
// Poll library while batch downloads are in progress
|
||||
$effect(() => {
|
||||
if (!batchDownloadStore.hasActive || allComplete) return;
|
||||
|
||||
libraryStore.refresh();
|
||||
|
||||
const interval = setInterval(() => {
|
||||
libraryStore.refresh();
|
||||
}, POLL_INTERVAL_MS);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (allComplete && batchDownloadStore.jobs.length > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
|
||||
@@ -182,7 +182,8 @@ export const API = {
|
||||
suggest: (query: string, limit = 5) =>
|
||||
`/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}`
|
||||
},
|
||||
home: (source: string) => `/api/v1/home?source=${encodeURIComponent(source)}`,
|
||||
home: (source?: string) =>
|
||||
source ? `/api/v1/home?source=${encodeURIComponent(source)}` : '/api/v1/home',
|
||||
homeIntegrationStatus: () => '/api/v1/home/integration-status',
|
||||
discover: (source?: string) =>
|
||||
source ? `/api/v1/discover?source=${encodeURIComponent(source)}` : '/api/v1/discover',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, it, beforeEach, vi } from 'vitest';
|
||||
import { PAGE_SOURCE_KEYS, API } from '$lib/constants';
|
||||
|
||||
vi.mock('$app/environment', () => ({ browser: true }));
|
||||
|
||||
const storage = new Map<string, string>();
|
||||
const mockLocalStorage = {
|
||||
getItem: vi.fn((key: string) => storage.get(key) ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => storage.set(key, value)),
|
||||
removeItem: vi.fn((key: string) => storage.delete(key)),
|
||||
clear: vi.fn(() => storage.clear()),
|
||||
get length() {
|
||||
return storage.size;
|
||||
},
|
||||
key: vi.fn((_i: number) => null)
|
||||
};
|
||||
|
||||
vi.stubGlobal('localStorage', mockLocalStorage);
|
||||
vi.stubGlobal('window', globalThis);
|
||||
|
||||
describe('migratePageSourceKeys', () => {
|
||||
let migratePageSourceKeys: (typeof import('$lib/stores/musicSource'))['migratePageSourceKeys'];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
storage.clear();
|
||||
vi.resetModules();
|
||||
const mod = await import('$lib/stores/musicSource');
|
||||
migratePageSourceKeys = mod.migratePageSourceKeys;
|
||||
});
|
||||
|
||||
it('converts raw "listenbrainz" to JSON-encoded string', () => {
|
||||
storage.set(PAGE_SOURCE_KEYS.home, 'listenbrainz');
|
||||
migratePageSourceKeys();
|
||||
expect(storage.get(PAGE_SOURCE_KEYS.home)).toBe('"listenbrainz"');
|
||||
});
|
||||
|
||||
it('converts raw "lastfm" to JSON-encoded string', () => {
|
||||
storage.set(PAGE_SOURCE_KEYS.home, 'lastfm');
|
||||
migratePageSourceKeys();
|
||||
expect(storage.get(PAGE_SOURCE_KEYS.home)).toBe('"lastfm"');
|
||||
});
|
||||
|
||||
it('leaves already-JSON-encoded values unchanged', () => {
|
||||
storage.set(PAGE_SOURCE_KEYS.home, '"listenbrainz"');
|
||||
migratePageSourceKeys();
|
||||
expect(storage.get(PAGE_SOURCE_KEYS.home)).toBe('"listenbrainz"');
|
||||
});
|
||||
|
||||
it('leaves invalid values unchanged', () => {
|
||||
storage.set(PAGE_SOURCE_KEYS.home, 'plex');
|
||||
migratePageSourceKeys();
|
||||
expect(storage.get(PAGE_SOURCE_KEYS.home)).toBe('plex');
|
||||
});
|
||||
|
||||
it('handles absent keys without error', () => {
|
||||
expect(() => migratePageSourceKeys()).not.toThrow();
|
||||
});
|
||||
|
||||
it('migrates all page source keys', () => {
|
||||
for (const key of Object.values(PAGE_SOURCE_KEYS)) {
|
||||
storage.set(key, 'lastfm');
|
||||
}
|
||||
migratePageSourceKeys();
|
||||
for (const key of Object.values(PAGE_SOURCE_KEYS)) {
|
||||
expect(storage.get(key)).toBe('"lastfm"');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMusicSource', () => {
|
||||
let isMusicSource: (typeof import('$lib/stores/musicSource'))['isMusicSource'];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
const mod = await import('$lib/stores/musicSource');
|
||||
isMusicSource = mod.isMusicSource;
|
||||
});
|
||||
|
||||
it('returns true for listenbrainz', () => {
|
||||
expect(isMusicSource('listenbrainz')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for lastfm', () => {
|
||||
expect(isMusicSource('lastfm')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isMusicSource(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for invalid string', () => {
|
||||
expect(isMusicSource('plex')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isMusicSource(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('API.home', () => {
|
||||
it('includes source param when provided', () => {
|
||||
expect(API.home('listenbrainz')).toBe('/api/v1/home?source=listenbrainz');
|
||||
});
|
||||
|
||||
it('omits source param when undefined', () => {
|
||||
expect(API.home(undefined)).toBe('/api/v1/home');
|
||||
});
|
||||
|
||||
it('omits source param when called with no arguments', () => {
|
||||
expect(API.home()).toBe('/api/v1/home');
|
||||
});
|
||||
|
||||
it('encodes special characters in source', () => {
|
||||
expect(API.home('listen brainz')).toBe('/api/v1/home?source=listen%20brainz');
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,23 @@ export function isMusicSource(value: unknown): value is MusicSource {
|
||||
return value === 'listenbrainz' || value === 'lastfm';
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate old raw-string localStorage source values to JSON format.
|
||||
* Before v1.3.0, setPageSource() stored raw strings (e.g. `listenbrainz`).
|
||||
* PersistedState expects JSON (e.g. `"listenbrainz"`). Must run before
|
||||
* any PersistedState constructor reads these keys.
|
||||
*/
|
||||
export function migratePageSourceKeys(): void {
|
||||
if (!browser) return;
|
||||
for (const key of Object.values(PAGE_SOURCE_KEYS)) {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (raw === null) continue;
|
||||
if (isMusicSource(raw)) {
|
||||
localStorage.setItem(key, JSON.stringify(raw));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function readCachedSource(): MusicSource {
|
||||
if (!browser) return DEFAULT_SOURCE;
|
||||
const stored = localStorage.getItem(CACHED_SOURCE_KEY);
|
||||
@@ -105,13 +122,23 @@ function createMusicSourceStore() {
|
||||
function getPageSource(page: MusicSourcePage): MusicSource {
|
||||
const fallbackSource = getSource();
|
||||
if (!browser) return fallbackSource;
|
||||
const storedSource = localStorage.getItem(getPageStorageKey(page));
|
||||
return isMusicSource(storedSource) ? storedSource : fallbackSource;
|
||||
const raw = localStorage.getItem(getPageStorageKey(page));
|
||||
if (raw === null) return fallbackSource;
|
||||
// Handle JSON-encoded values (new format)
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (isMusicSource(parsed)) return parsed;
|
||||
} catch {
|
||||
// Fall through to raw check
|
||||
}
|
||||
// Handle raw string values (old format)
|
||||
if (isMusicSource(raw)) return raw;
|
||||
return fallbackSource;
|
||||
}
|
||||
|
||||
function setPageSource(page: MusicSourcePage, source: MusicSource): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(getPageStorageKey(page), source);
|
||||
localStorage.setItem(getPageStorageKey(page), JSON.stringify(source));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { goto, beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { migratePageSourceKeys } from '$lib/stores/musicSource';
|
||||
import { errorModal } from '$lib/stores/errorModal';
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
@@ -61,6 +62,8 @@
|
||||
import type { Snippet } from 'svelte';
|
||||
import QueryProvider from '$lib/queries/QueryProvider.svelte';
|
||||
|
||||
migratePageSourceKeys();
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
let query = $state('');
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
HomeSection as HomeSectionType,
|
||||
WeeklyExplorationSection as WeeklyExplorationSectionType
|
||||
} from '$lib/types';
|
||||
import { type MusicSource } from '$lib/stores/musicSource';
|
||||
import { type MusicSource, isMusicSource } from '$lib/stores/musicSource';
|
||||
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import { getGreeting } from '$lib/utils/homeCache';
|
||||
@@ -27,7 +27,11 @@
|
||||
// svelte-ignore state_referenced_locally
|
||||
let activeSource = new PersistedState<MusicSource>(PAGE_SOURCE_KEYS['home'], data.primarySource);
|
||||
|
||||
const homeQuery = getHomeQuery(() => activeSource.current);
|
||||
let validSource = $derived(
|
||||
isMusicSource(activeSource.current) ? activeSource.current : data.primarySource
|
||||
);
|
||||
|
||||
const homeQuery = getHomeQuery(() => validSource);
|
||||
const homeData = $derived(homeQuery.data);
|
||||
const loading = $derived(homeQuery.isLoading);
|
||||
const isUpdating = $derived(homeQuery.isRefetching);
|
||||
@@ -68,7 +72,7 @@
|
||||
});
|
||||
}
|
||||
if (
|
||||
activeSource.current === 'listenbrainz' &&
|
||||
validSource === 'listenbrainz' &&
|
||||
homeData.weekly_exploration &&
|
||||
homeData.weekly_exploration.tracks.length > 0
|
||||
) {
|
||||
@@ -178,10 +182,7 @@
|
||||
</PageHeader>
|
||||
|
||||
<div class="flex justify-end px-4 -mt-4 mb-4 sm:px-6 lg:px-8">
|
||||
<SimpleSourceSwitcher
|
||||
currentSource={activeSource.current}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
<SimpleSourceSwitcher currentSource={validSource} onSourceChange={handleSourceChange} />
|
||||
</div>
|
||||
|
||||
{#if homeQuery.error && !homeData}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import { requestAlbum } from '$lib/utils/albumRequest';
|
||||
import { integrationStore } from '$lib/stores/integration';
|
||||
import { libraryStore } from '$lib/stores/library';
|
||||
import { type MusicSource } from '$lib/stores/musicSource';
|
||||
import { type MusicSource, isMusicSource } from '$lib/stores/musicSource';
|
||||
import {
|
||||
getArtistLastFmEnrichmentQuery,
|
||||
getArtistReleasesInfiniteQuery,
|
||||
@@ -49,6 +49,10 @@
|
||||
data.primarySource
|
||||
);
|
||||
|
||||
let validSource = $derived(
|
||||
isMusicSource(activeSource.current) ? activeSource.current : data.primarySource
|
||||
);
|
||||
|
||||
let showToast = $state(false);
|
||||
let toastMessage = 'Added to Library';
|
||||
let showArtistRemovedModal = $state(false);
|
||||
@@ -73,21 +77,21 @@
|
||||
|
||||
const similarArtistsQuery = getSimilarArtistsQuery(() => ({
|
||||
artistId: data.artistId,
|
||||
source: activeSource.current
|
||||
source: validSource
|
||||
}));
|
||||
const similarArtists = $derived(similarArtistsQuery.data);
|
||||
const loadingSimilar = $derived(similarArtistsQuery.isLoading);
|
||||
|
||||
const topSongsQuery = getArtistTopSongsQuery(() => ({
|
||||
artistId: data.artistId,
|
||||
source: activeSource.current
|
||||
source: validSource
|
||||
}));
|
||||
const topSongs = $derived(topSongsQuery.data);
|
||||
const loadingTopSongs = $derived(topSongsQuery.isLoading);
|
||||
|
||||
const topAlbumsQuery = getArtistTopAlbumsQuery(() => ({
|
||||
artistId: data.artistId,
|
||||
source: activeSource.current
|
||||
source: validSource
|
||||
}));
|
||||
const topAlbums = $derived(topAlbumsQuery.data);
|
||||
const loadingTopAlbums = $derived(topAlbumsQuery.isLoading);
|
||||
@@ -351,7 +355,7 @@
|
||||
|
||||
<div class="flex items-center justify-end mt-8 mb-4">
|
||||
<SimpleSourceSwitcher
|
||||
currentSource={activeSource.current}
|
||||
currentSource={validSource}
|
||||
onSourceChange={(newSource) => {
|
||||
activeSource.current = newSource;
|
||||
}}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import TimeRangeView from '$lib/components/TimeRangeView.svelte';
|
||||
import { type MusicSource } from '$lib/stores/musicSource';
|
||||
import { type MusicSource, isMusicSource } from '$lib/stores/musicSource';
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
import type { PageProps } from './$types';
|
||||
import { PersistedState } from 'runed';
|
||||
@@ -15,11 +15,15 @@
|
||||
data.primarySource
|
||||
);
|
||||
|
||||
let validSource = $derived(
|
||||
isMusicSource(activeSource.current) ? activeSource.current : data.primarySource
|
||||
);
|
||||
|
||||
function handleSourceChange(nextSource: MusicSource) {
|
||||
activeSource.current = nextSource;
|
||||
}
|
||||
|
||||
let sourceLabel = $derived(activeSource.current === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
let sourceLabel = $derived(validSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -28,17 +32,14 @@
|
||||
|
||||
<div class="space-y-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-end">
|
||||
<SimpleSourceSwitcher
|
||||
currentSource={activeSource.current}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
<SimpleSourceSwitcher currentSource={validSource} onSourceChange={handleSourceChange} />
|
||||
</div>
|
||||
<TimeRangeView
|
||||
itemType="album"
|
||||
endpoint="/api/v1/home/popular/albums"
|
||||
title="Popular Right Now"
|
||||
subtitle={`Most listened albums on ${sourceLabel}`}
|
||||
source={activeSource.current}
|
||||
source={validSource}
|
||||
errorIcon={Disc3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import TimeRangeView from '$lib/components/TimeRangeView.svelte';
|
||||
import { type MusicSource } from '$lib/stores/musicSource';
|
||||
import { type MusicSource, isMusicSource } from '$lib/stores/musicSource';
|
||||
import { Mic } from 'lucide-svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import { PAGE_SOURCE_KEYS } from '$lib/constants';
|
||||
@@ -15,11 +15,15 @@
|
||||
data.primarySource
|
||||
);
|
||||
|
||||
let validSource = $derived(
|
||||
isMusicSource(activeSource.current) ? activeSource.current : data.primarySource
|
||||
);
|
||||
|
||||
function handleSourceChange(nextSource: MusicSource) {
|
||||
activeSource.current = nextSource;
|
||||
}
|
||||
|
||||
let sourceLabel = $derived(activeSource.current === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
let sourceLabel = $derived(validSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -28,17 +32,14 @@
|
||||
|
||||
<div class="space-y-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-end">
|
||||
<SimpleSourceSwitcher
|
||||
currentSource={activeSource.current}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
<SimpleSourceSwitcher currentSource={validSource} onSourceChange={handleSourceChange} />
|
||||
</div>
|
||||
<TimeRangeView
|
||||
itemType="artist"
|
||||
endpoint="/api/v1/home/trending/artists"
|
||||
title="Trending Artists"
|
||||
subtitle={`Most listened artists on ${sourceLabel}`}
|
||||
source={activeSource.current}
|
||||
source={validSource}
|
||||
errorIcon={Mic}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import TimeRangeView from '$lib/components/TimeRangeView.svelte';
|
||||
import { type MusicSource } from '$lib/stores/musicSource';
|
||||
import { type MusicSource, isMusicSource } from '$lib/stores/musicSource';
|
||||
import { Disc3 } from 'lucide-svelte';
|
||||
import { PersistedState } from 'runed';
|
||||
import { PAGE_SOURCE_KEYS } from '$lib/constants';
|
||||
@@ -15,11 +15,15 @@
|
||||
data.primarySource
|
||||
);
|
||||
|
||||
let validSource = $derived(
|
||||
isMusicSource(activeSource.current) ? activeSource.current : data.primarySource
|
||||
);
|
||||
|
||||
function handleSourceChange(nextSource: MusicSource) {
|
||||
activeSource.current = nextSource;
|
||||
}
|
||||
|
||||
let sourceLabel = $derived(activeSource.current === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
let sourceLabel = $derived(validSource === 'lastfm' ? 'Last.fm' : 'ListenBrainz');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -28,17 +32,14 @@
|
||||
|
||||
<div class="space-y-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-end">
|
||||
<SimpleSourceSwitcher
|
||||
currentSource={activeSource.current}
|
||||
onSourceChange={handleSourceChange}
|
||||
/>
|
||||
<SimpleSourceSwitcher currentSource={validSource} onSourceChange={handleSourceChange} />
|
||||
</div>
|
||||
<TimeRangeView
|
||||
itemType="album"
|
||||
endpoint="/api/v1/home/your-top/albums"
|
||||
title="Your Top Albums"
|
||||
subtitle={`Your most listened albums on ${sourceLabel}`}
|
||||
source={activeSource.current}
|
||||
source={validSource}
|
||||
errorIcon={Disc3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user