refactor: Prototype of tanstack-query (#34)

* move getApiUrl to api folder

* adjust imports

* tanstack-query example with homeData

* small adjustments

* fix key collision

* new MusicSource persistent mechanism example

* add error handling & set sveltekit to SPA mode

* remove unnecessary ssr test
This commit is contained in:
Arno
2026-04-11 14:46:07 +02:00
committed by GitHub
parent e74ff3b3c4
commit 63ccf03dac
36 changed files with 769 additions and 521 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ Frontend:
```bash ```bash
cd frontend cd frontend
cp env.dev.example .env cp env.development.example .env.development
pnpm install pnpm install
pnpm run dev pnpm run dev
``` ```
+29
View File
@@ -0,0 +1,29 @@
services:
musicseerr:
build:
context: .
dockerfile: Dockerfile
container_name: musicseerr-dev
environment:
- PUID=1000 # User ID — run `id` on your host to find yours
- PGID=1000 # Group ID — run `id` on your host to find yours
# Unraid: use 99/100 (nobody:users) unless you changed Unraid's defaults.
# If using Docker --user, PUID/PGID are ignored (user mapping is external).
- PORT=8688 # Internal port (must match the right side of "ports" below)
- TZ=Etc/UTC # Timezone — e.g. America/New_York, Europe/London
ports:
- "8689:8688" # <host-port>:<container-port> — change the left side to remap
volumes:
- config:/app/config
- cache:/app/cache
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8688/health"]
interval: 30s
timeout: 10s
start_period: 15s
retries: 3
volumes:
config:
cache:
+20
View File
@@ -45,5 +45,25 @@ export default defineConfig(
rules: { rules: {
'svelte/no-navigation-without-resolve': 'off' 'svelte/no-navigation-without-resolve': 'off'
} }
},
{
rules: {
'no-restricted-syntax': [
'error',
// Ensure not to call queryClient.setQueriesData or queryClient.setQueryData directly, as this will bypass the persister and lead to data loss on page refresh.
{
selector:
"CallExpression[callee.object.name='queryClient'][callee.property.name='setQueriesData']",
message:
"Direct use of 'queryClient.setQueriesData' is forbidden. Please use the 'setQueriesDataWithPersister' function instead."
},
{
selector:
"CallExpression[callee.object.name='queryClient'][callee.property.name='setQueryData']",
message:
"Direct use of 'queryClient.setQueryData' is forbidden. Please use the 'setQueryDataWithPersister' function instead."
}
]
}
} }
); );
+8 -2
View File
@@ -4,11 +4,12 @@
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev --open",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"tailwind": "tailwindcss -i ./src/app.css -o ./static/tailwind.css --watch", "tailwind": "tailwindcss -i ./src/app.css -o ./static/tailwind.css --watch",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check:ci": "pnpm check && pnpm format:check && pnpm lint",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .", "format": "prettier --write .",
@@ -47,6 +48,11 @@
"vitest-browser-svelte": "^1.1.0" "vitest-browser-svelte": "^1.1.0"
}, },
"dependencies": { "dependencies": {
"lucide-svelte": "^0.575.0" "@tanstack/svelte-query": "^6.1.13",
"@tanstack/svelte-query-devtools": "^6.1.13",
"@tanstack/svelte-query-persist-client": "^6.1.13",
"idb-keyval": "^6.2.2",
"lucide-svelte": "^0.575.0",
"runed": "^0.37.1"
} }
} }
+93
View File
@@ -8,9 +8,24 @@ importers:
.: .:
dependencies: dependencies:
'@tanstack/svelte-query':
specifier: ^6.1.13
version: 6.1.13(svelte@5.55.1)
'@tanstack/svelte-query-devtools':
specifier: ^6.1.13
version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)
'@tanstack/svelte-query-persist-client':
specifier: ^6.1.13
version: 6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)
idb-keyval:
specifier: ^6.2.2
version: 6.2.2
lucide-svelte: lucide-svelte:
specifier: ^0.575.0 specifier: ^0.575.0
version: 0.575.0(svelte@5.55.1) version: 0.575.0(svelte@5.55.1)
runed:
specifier: ^0.37.1
version: 0.37.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)
devDependencies: devDependencies:
'@eslint/compat': '@eslint/compat':
specifier: ^1.4.0 specifier: ^1.4.0
@@ -721,6 +736,32 @@ packages:
'@tailwindcss/postcss@4.2.2': '@tailwindcss/postcss@4.2.2':
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==} resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
'@tanstack/query-core@5.96.2':
resolution: {integrity: sha512-hzI6cTVh4KNRk8UtoIBS7Lv9g6BnJPXvBKsvYH1aGWvv0347jT3BnSvztOE+kD76XGvZnRC/t6qdW1CaIfwCeA==}
'@tanstack/query-devtools@5.96.2':
resolution: {integrity: sha512-vBTB1Qhbm3nHSbEUtQwks/EdcAtFfEapr1WyBW4w2ExYKuXVi3jIxUIHf5MlSltiHuL7zNyUuanqT/7sI2sb6g==}
'@tanstack/query-persist-client-core@5.96.2':
resolution: {integrity: sha512-BYsP8folbvxzZsNnWJxSenEAdepGNfv809150U78D84yt/THi33EwfUCcdKWFbma5XKwlaFQGWMJKeWnVJ6GVA==}
'@tanstack/svelte-query-devtools@6.1.13':
resolution: {integrity: sha512-55gMVoPRh7BcwQTogSt8pPrSsQ6lWnfJct3Yos1t2tAZ1YL9KbLEa6X4/iVfdPmxKO+czdDN6GSkMIB05vVKKg==}
peerDependencies:
'@tanstack/svelte-query': ^6.1.13
svelte: ^5.25.0
'@tanstack/svelte-query-persist-client@6.1.13':
resolution: {integrity: sha512-YJIx61T3857k4o206S1y8dNruzEGL1qwN63KoE+0L8juw+4rbsN2Jzq3dCADwio5JALPW4GJa12f26C4ufujAA==}
peerDependencies:
'@tanstack/svelte-query': ^6.1.13
svelte: ^5.25.0
'@tanstack/svelte-query@6.1.13':
resolution: {integrity: sha512-A/BB9BuRoPSEJZqVUU5sqcAgi13XdfldlJtd8XmbwAXr/fxEMfpSEMYmIxLmUb/s9EVYRJi3bew9OjZddkx0cg==}
peerDependencies:
svelte: ^5.25.0
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -1156,6 +1197,9 @@ packages:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
idb-keyval@6.2.2:
resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -1477,6 +1521,18 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'} engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true hasBin: true
runed@0.37.1:
resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==}
peerDependencies:
'@sveltejs/kit': ^2.21.0
svelte: ^5.7.0
zod: ^4.1.0
peerDependenciesMeta:
'@sveltejs/kit':
optional: true
zod:
optional: true
sade@1.8.1: sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -2177,6 +2233,32 @@ snapshots:
postcss: 8.5.8 postcss: 8.5.8
tailwindcss: 4.2.2 tailwindcss: 4.2.2
'@tanstack/query-core@5.96.2': {}
'@tanstack/query-devtools@5.96.2': {}
'@tanstack/query-persist-client-core@5.96.2':
dependencies:
'@tanstack/query-core': 5.96.2
'@tanstack/svelte-query-devtools@6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)':
dependencies:
'@tanstack/query-devtools': 5.96.2
'@tanstack/svelte-query': 6.1.13(svelte@5.55.1)
esm-env: 1.2.2
svelte: 5.55.1
'@tanstack/svelte-query-persist-client@6.1.13(@tanstack/svelte-query@6.1.13(svelte@5.55.1))(svelte@5.55.1)':
dependencies:
'@tanstack/query-persist-client-core': 5.96.2
'@tanstack/svelte-query': 6.1.13(svelte@5.55.1)
svelte: 5.55.1
'@tanstack/svelte-query@6.1.13(svelte@5.55.1)':
dependencies:
'@tanstack/query-core': 5.96.2
svelte: 5.55.1
'@testing-library/dom@10.4.1': '@testing-library/dom@10.4.1':
dependencies: dependencies:
'@babel/code-frame': 7.29.0 '@babel/code-frame': 7.29.0
@@ -2661,6 +2743,8 @@ snapshots:
has-flag@4.0.0: {} has-flag@4.0.0: {}
idb-keyval@6.2.2: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@@ -2927,6 +3011,15 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.60.1 '@rollup/rollup-win32-x64-msvc': 4.60.1
fsevents: 2.3.3 fsevents: 2.3.3
runed@0.37.1(@sveltejs/kit@2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1):
dependencies:
dequal: 2.0.3
esm-env: 1.2.2
lz-string: 1.5.0
svelte: 5.55.1
optionalDependencies:
'@sveltejs/kit': 2.56.1(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.55.1)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)))(svelte@5.55.1)(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0))
sade@1.8.1: sade@1.8.1:
dependencies: dependencies:
mri: 1.2.0 mri: 1.2.0
+1 -1
View File
@@ -1,5 +1,5 @@
import { pageFetch } from '$lib/utils/navigationAbort'; import { pageFetch } from '$lib/utils/navigationAbort';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
export class ApiError extends Error { export class ApiError extends Error {
readonly status: number; readonly status: number;
@@ -11,7 +11,7 @@
import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte'; import ArtistMonitoringToggle from './ArtistMonitoringToggle.svelte';
import BackButton from './BackButton.svelte'; import BackButton from './BackButton.svelte';
import HeroBackdrop from './HeroBackdrop.svelte'; import HeroBackdrop from './HeroBackdrop.svelte';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
interface Props { interface Props {
artist: ArtistInfo; artist: ArtistInfo;
+1 -1
View File
@@ -7,7 +7,7 @@
import { isValidMbid } from '$lib/utils/formatting'; import { isValidMbid } from '$lib/utils/formatting';
import { imageSettingsStore } from '$lib/stores/imageSettings'; import { imageSettingsStore } from '$lib/stores/imageSettings';
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix'; import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
interface Props { interface Props {
mbid: string; mbid: string;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import type { BecauseYouListenTo } from '$lib/types'; import type { BecauseYouListenTo } from '$lib/types';
import HomeSection from './HomeSection.svelte'; import HomeSection from './HomeSection.svelte';
import HeroBackdrop from './HeroBackdrop.svelte'; import HeroBackdrop from './HeroBackdrop.svelte';
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { imageSettingsStore } from '$lib/stores/imageSettings'; import { imageSettingsStore } from '$lib/stores/imageSettings';
import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix'; import { appendAudioDBSizeSuffix } from '$lib/utils/imageSuffix';
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { Search } from 'lucide-svelte'; import { Search } from 'lucide-svelte';
import type { SuggestResult } from '$lib/types'; import type { SuggestResult } from '$lib/types';
import { API } from '$lib/constants'; import { API } from '$lib/constants';
@@ -0,0 +1,46 @@
<script lang="ts">
import { type MusicSource } from '$lib/stores/musicSource';
import { integrationStore } from '$lib/stores/integration';
import { fromStore } from 'svelte/store';
interface Props {
currentSource: MusicSource;
onSourceChange?: (source: MusicSource) => void;
}
let { currentSource, onSourceChange }: Props = $props();
const integrationState = fromStore(integrationStore);
let switching = $state(false);
let lbEnabled = $derived(integrationState.current.listenbrainz);
let lfmEnabled = $derived(integrationState.current.lastfm);
let showSwitcher = $derived(lbEnabled && lfmEnabled);
async function handleSwitch(source: MusicSource) {
if (source === currentSource || switching) return;
switching = true;
onSourceChange?.(source);
switching = false;
}
</script>
{#if showSwitcher}
<div class="join">
<button
class="btn btn-sm join-item {currentSource === 'listenbrainz' ? 'btn-primary' : 'btn-ghost'}"
disabled={switching}
onclick={() => handleSwitch('listenbrainz')}
>
ListenBrainz
</button>
<button
class="btn btn-sm join-item {currentSource === 'lastfm' ? 'btn-lastfm' : 'btn-ghost'}"
disabled={switching}
onclick={() => handleSwitch('lastfm')}
>
Last.fm
</button>
</div>
{/if}
@@ -330,7 +330,7 @@
{/if} {/if}
<div class="grid-cards-overview lg:col-span-2"> <div class="grid-cards-overview lg:col-span-2">
{#each items.slice(0, 8) as item, idx (item.mbid)} {#each items.slice(0, 8) as item, idx (idx)}
{@const rank = idx + 2} {@const rank = idx + 2}
{@const itemHref = getItemHref(item)} {@const itemHref = getItemHref(item)}
<TimeRangeCard <TimeRangeCard
@@ -351,7 +351,7 @@
</div> </div>
{:else if expandedData} {:else if expandedData}
<div class="grid-cards"> <div class="grid-cards">
{#each expandedData.items as item, idx (item.mbid)} {#each expandedData.items as item, idx (idx)}
{@const rank = idx + 1} {@const rank = idx + 1}
{@const itemHref = getItemHref(item)} {@const itemHref = getItemHref(item)}
<TimeRangeCard <TimeRangeCard
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
interface CacheStats { interface CacheStats {
memory_entries: number; memory_entries: number;
memory_size_bytes: number; memory_size_bytes: number;
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { preferencesStore } from '$lib/stores/preferences'; import { preferencesStore } from '$lib/stores/preferences';
import { integrationStore } from '$lib/stores/integration'; import { integrationStore } from '$lib/stores/integration';
+2 -1
View File
@@ -164,7 +164,7 @@ export const API = {
suggest: (query: string, limit = 5) => suggest: (query: string, limit = 5) =>
`/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}` `/api/v1/search/suggest?q=${encodeURIComponent(query.trim())}&limit=${limit}`
}, },
home: () => '/api/v1/home', home: (source: string) => `/api/v1/home?source=${encodeURIComponent(source)}`,
homeIntegrationStatus: () => '/api/v1/home/integration-status', homeIntegrationStatus: () => '/api/v1/home/integration-status',
discover: () => '/api/v1/discover', discover: () => '/api/v1/discover',
discoverRefresh: () => '/api/v1/discover/refresh', discoverRefresh: () => '/api/v1/discover/refresh',
@@ -198,6 +198,7 @@ export const API = {
}, },
queue: () => '/api/v1/queue', queue: () => '/api/v1/queue',
settings: () => '/api/v1/settings', settings: () => '/api/v1/settings',
settingsPrimarySource: () => '/api/v1/settings/primary-source',
settingsNavidrome: () => '/api/v1/settings/navidrome', settingsNavidrome: () => '/api/v1/settings/navidrome',
settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify', settingsNavidromeVerify: () => '/api/v1/settings/navidrome/verify',
settingsLocalFiles: () => '/api/v1/settings/local-files', settingsLocalFiles: () => '/api/v1/settings/local-files',
@@ -0,0 +1,20 @@
import { api } from '$lib/api/client';
import { API, CACHE_TTL } from '$lib/constants';
import type { MusicSource } from '$lib/stores/musicSource';
import type { HomeResponse } from '$lib/types';
import type { Getter } from '$lib/utils/typeHelpers';
import { createQuery } from '@tanstack/svelte-query';
const keyFactory = {
home: (source: MusicSource) => ['home', source] as const
};
export const getHomeQuery = (getSource: Getter<MusicSource>) =>
createQuery(() => ({
staleTime: CACHE_TTL.HOME,
queryKey: keyFactory.home(getSource()),
queryFn: ({ signal }) =>
api.global.get<HomeResponse>(API.home(getSource()), {
signal
})
}));
@@ -0,0 +1,52 @@
import type {
AsyncStorage,
PersistedClient,
PersistedQuery,
Persister
} from '@tanstack/svelte-query-persist-client';
import { del, entries, get, set } from 'idb-keyval';
/**
* Creates an Indexed DB persister
* @see https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#building-a-persister
*/
export function createIDBPersister(idbValidKey: string = 'tanstackQuery') {
return {
persistClient: async (client: PersistedClient) => {
await set(idbValidKey, client);
},
restoreClient: async () => {
return await get<PersistedClient>(idbValidKey);
},
removeClient: async () => {
await del(idbValidKey);
}
} satisfies Persister;
}
export function createIDBStorage(): AsyncStorage<PersistedQuery> {
return {
getItem: async (key: string) => {
const val = await get<PersistedQuery>(key);
return val;
},
setItem: async (key: string, value: PersistedQuery) => {
// In some cases, a svelte state proxy value appears in the query state, which cannot be stored in IndexedDB.
// To work around this, we can snapshot the value before storing it.
console.debug('Setting item in IndexedDB', key, value);
try {
await set(key, $state.snapshot(value));
} catch (e) {
console.error('Failed to set item in IndexedDB', key, value, e);
throw e;
}
},
removeItem: async (key: string) => {
await del(key);
},
entries: async () => {
return await entries();
}
};
}
+72
View File
@@ -0,0 +1,72 @@
import { browser } from '$app/environment';
import {
type InferDataFromTag,
QueryClient,
type QueryFilters,
type QueryKey,
type SetDataOptions,
type Updater
} from '@tanstack/svelte-query';
import { experimental_createQueryPersister } from '@tanstack/svelte-query-persist-client';
import { createIDBStorage } from './IndexedDbPersister.svelte';
/**
* Maximum age for queries to be persisted.
* @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
*/
export const QUERY_MAX_AGE = 1000 * 60 * 60 * 24 * 7; // 7 days
export const queryPersister = experimental_createQueryPersister({
storage: createIDBStorage(),
maxAge: QUERY_MAX_AGE,
// No need to serialize/deserialize since we're using IndexedDB which can store complex objects.
serialize: (persistedQuery) => persistedQuery,
deserialize: (cached) => cached
});
export const setQueriesDataWithPersister = async <TQueryFnData>(
filters: QueryFilters,
updater: Updater<NoInfer<TQueryFnData> | undefined, NoInfer<TQueryFnData> | undefined>
) => {
// eslint-disable-next-line no-restricted-syntax
const setQueriesDataReturn = queryClient.setQueriesData<TQueryFnData>(filters, updater);
for (const modifiedQuery of setQueriesDataReturn) {
await queryPersister.persistQueryByKey(modifiedQuery[0], queryClient);
}
};
export const setQueryDataWithPersister = async <
TQueryFnData = unknown,
TTaggedQueryKey extends QueryKey = QueryKey,
TInferredQueryFnData = InferDataFromTag<TQueryFnData, TTaggedQueryKey>
>(
queryKey: TTaggedQueryKey,
updater: Updater<
NoInfer<TInferredQueryFnData> | undefined,
NoInfer<TInferredQueryFnData> | undefined
>,
options?: SetDataOptions
) => {
// eslint-disable-next-line no-restricted-syntax
await queryClient.setQueryData<TQueryFnData, TTaggedQueryKey, TInferredQueryFnData>(
queryKey,
updater,
options
);
await queryPersister.persistQueryByKey(queryKey, queryClient);
};
/**
* Global query client, used to manage all queries in the application.
*/
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
retry: false,
refetchOnWindowFocus: true,
staleTime: 1000 * 60 * 1, // 1 minute,
gcTime: 1000 * 30, // 30 seconds
persister: queryPersister.persisterFn
}
}
});
@@ -0,0 +1,20 @@
<script lang="ts">
import { type Snippet } from 'svelte';
import { queryClient } from './QueryClient';
import { QueryClientProvider } from '@tanstack/svelte-query';
import { SvelteQueryDevtools } from '@tanstack/svelte-query-devtools';
import { dev } from '$app/environment';
type Props = {
children: Snippet;
};
const { children }: Props = $props();
</script>
<QueryClientProvider client={queryClient}>
{@render children()}
{#if dev}
<SvelteQueryDevtools client={queryClient} />
{/if}
</QueryClientProvider>
+2 -2
View File
@@ -7,14 +7,14 @@ export type MusicSource = 'listenbrainz' | 'lastfm';
export type MusicSourcePage = keyof typeof PAGE_SOURCE_KEYS; export type MusicSourcePage = keyof typeof PAGE_SOURCE_KEYS;
const CACHED_SOURCE_KEY = 'musicseerr_primary_source'; const CACHED_SOURCE_KEY = 'musicseerr_primary_source';
const DEFAULT_SOURCE: MusicSource = 'listenbrainz'; export const DEFAULT_SOURCE: MusicSource = 'listenbrainz';
interface MusicSourceState { interface MusicSourceState {
source: MusicSource; source: MusicSource;
loaded: boolean; loaded: boolean;
} }
function isMusicSource(value: unknown): value is MusicSource { export function isMusicSource(value: unknown): value is MusicSource {
return value === 'listenbrainz' || value === 'lastfm'; return value === 'listenbrainz' || value === 'lastfm';
} }
-1
View File
@@ -823,7 +823,6 @@ describe('beforeunload beacon', () => {
let addEventListenerSpy: ReturnType<typeof vi.fn>; let addEventListenerSpy: ReturnType<typeof vi.fn>;
let removeEventListenerSpy: ReturnType<typeof vi.fn>; let removeEventListenerSpy: ReturnType<typeof vi.fn>;
let sendBeaconMock: ReturnType<typeof vi.fn>; let sendBeaconMock: ReturnType<typeof vi.fn>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let jellyfinApi: { let jellyfinApi: {
startSession: ReturnType<typeof vi.fn>; startSession: ReturnType<typeof vi.fn>;
reportProgress: ReturnType<typeof vi.fn>; reportProgress: ReturnType<typeof vi.fn>;
+1 -1
View File
@@ -1,7 +1,7 @@
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { api } from '$lib/api/client'; import { api } from '$lib/api/client';
import { libraryStore } from '$lib/stores/library'; import { libraryStore } from '$lib/stores/library';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
export type SyncStatus = { export type SyncStatus = {
is_syncing: boolean; is_syncing: boolean;
+1 -1
View File
@@ -1,5 +1,5 @@
import { isValidMbid } from '$lib/utils/formatting'; import { isValidMbid } from '$lib/utils/formatting';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
export function isAbortError(error: unknown): boolean { export function isAbortError(error: unknown): boolean {
return ( return (
@@ -164,17 +164,3 @@ describe('raw fetch is not affected by navigation abort', () => {
expect(callArgs[1]?.signal).toBeUndefined(); expect(callArgs[1]?.signal).toBeUndefined();
}); });
}); });
describe('SSR safety', () => {
it('falls back to raw fetch when window is undefined', async () => {
vi.stubGlobal('window', undefined);
const mockFetch = vi.fn().mockResolvedValue(new Response('ok'));
vi.stubGlobal('fetch', mockFetch);
await pageFetch('/api/v1/test');
expect(mockFetch).toHaveBeenCalledOnce();
const callArgs = mockFetch.mock.calls[0];
expect(callArgs[1]?.signal).toBeUndefined();
});
});
+3 -1
View File
@@ -12,7 +12,9 @@ export function abortAllPageRequests(): void {
} }
export async function pageFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> { export async function pageFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
if (typeof window === 'undefined') return fetch(input, init); if (typeof window === 'undefined') {
throw new Error('Can never happen, we are running in SPA mode');
}
const navSignal = getNavigationSignal(); const navSignal = getNavigationSignal();
const existingSignal = init?.signal; const existingSignal = init?.signal;
const signal = existingSignal ? AbortSignal.any([navSignal, existingSignal]) : navSignal; const signal = existingSignal ? AbortSignal.any([navSignal, existingSignal]) : navSignal;
+3
View File
@@ -0,0 +1,3 @@
export type Getter<T> = () => T;
export type MaybeGetter<T> = T | Getter<T>;
+5 -2
View File
@@ -51,6 +51,7 @@
ListMusic ListMusic
} from 'lucide-svelte'; } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import QueryProvider from '$lib/queries/QueryProvider.svelte';
let { children }: { children: Snippet } = $props(); let { children }: { children: Snippet } = $props();
@@ -234,7 +235,8 @@
const lidarrConfigured = $derived(integrations.current.lidarr || !integrations.current.loaded); const lidarrConfigured = $derived(integrations.current.lidarr || !integrations.current.loaded);
</script> </script>
<div data-theme="musicseerr"> <QueryProvider>
<div data-theme="musicseerr">
{#if showNavigationProgress} {#if showNavigationProgress}
<div class="fixed top-0 left-0 right-0 z-120 pointer-events-none"> <div class="fixed top-0 left-0 right-0 z-120 pointer-events-none">
<progress class="progress progress-primary w-full h-1"></progress> <progress class="progress progress-primary w-full h-1"></progress>
@@ -574,4 +576,5 @@
<Player /> <Player />
<CacheSyncIndicator /> <CacheSyncIndicator />
<AddToPlaylistModal bind:this={playlistModalRef} /> <AddToPlaylistModal bind:this={playlistModalRef} />
</div> </div>
</QueryProvider>
+24
View File
@@ -0,0 +1,24 @@
import { api } from '$lib/api/client';
import { API } from '$lib/constants';
import { DEFAULT_SOURCE, isMusicSource } from '$lib/stores/musicSource';
import type { LayoutLoad } from './$types';
// Disable SSR, because we currently use only CSR
export const ssr = false;
export const prerender = false;
export const load: LayoutLoad = async () => {
try {
const data = await api.global.get<{ source: unknown }>(API.settingsPrimarySource());
const primarySource = isMusicSource(data.source) ? data.source : DEFAULT_SOURCE;
return {
primarySource
};
} catch (error) {
console.error('Error fetching primary music source:', error);
return {
primarySource: DEFAULT_SOURCE
};
}
};
+26 -157
View File
@@ -1,174 +1,40 @@
<script lang="ts"> <script lang="ts">
import { Shield, Music, CircleAlert, TrendingUp, Sparkles, Library } from 'lucide-svelte'; import { Shield, Music, CircleAlert, TrendingUp, Sparkles, Library } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import HomeSection from '$lib/components/HomeSection.svelte'; import HomeSection from '$lib/components/HomeSection.svelte';
import WeeklyExploration from '$lib/components/WeeklyExploration.svelte'; import WeeklyExploration from '$lib/components/WeeklyExploration.svelte';
import ServicePromptCard from '$lib/components/ServicePromptCard.svelte'; import ServicePromptCard from '$lib/components/ServicePromptCard.svelte';
import GenreGrid from '$lib/components/GenreGrid.svelte'; import GenreGrid from '$lib/components/GenreGrid.svelte';
import SourceSwitcher from '$lib/components/SourceSwitcher.svelte';
import SectionDivider from '$lib/components/SectionDivider.svelte'; import SectionDivider from '$lib/components/SectionDivider.svelte';
import type { import type {
HomeResponse,
HomeSection as HomeSectionType, HomeSection as HomeSectionType,
WeeklyExplorationSection as WeeklyExplorationSectionType WeeklyExplorationSection as WeeklyExplorationSectionType
} from '$lib/types'; } from '$lib/types';
import { integrationStore } from '$lib/stores/integration'; import { type MusicSource } from '$lib/stores/musicSource';
import { musicSourceStore, type MusicSource } from '$lib/stores/musicSource';
import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte'; import CarouselSkeleton from '$lib/components/CarouselSkeleton.svelte';
import PageHeader from '$lib/components/PageHeader.svelte'; import PageHeader from '$lib/components/PageHeader.svelte';
import { import { getGreeting } from '$lib/utils/homeCache';
getHomeCachedData,
setHomeCachedData,
isHomeCacheStale,
getGreeting
} from '$lib/utils/homeCache';
import { isAbortError } from '$lib/utils/errorHandling';
import { api } from '$lib/api/client';
import { removeQueueCachedData } from '$lib/utils/discoverQueueCache'; import { removeQueueCachedData } from '$lib/utils/discoverQueueCache';
import { isDismissed } from '$lib/utils/dismissedPrompts'; import { isDismissed } from '$lib/utils/dismissedPrompts';
import { getHomeQuery } from '$lib/queries/HomeQuery.svelte';
import type { PageProps } from './$types';
import { PersistedState } from 'runed';
import { PAGE_SOURCE_KEYS } from '$lib/constants';
import SimpleSourceSwitcher from '$lib/components/SimpleSourceSwitcher.svelte';
let homeData = $state<HomeResponse | null>(null); const { data }: PageProps = $props();
let loading = $state(true);
let refreshing = $state(false);
let isUpdating = $state(false);
let error = $state('');
let lastUpdated = $state<Date | null>(null);
let abortController: AbortController | null = null;
let activeSource: MusicSource = 'listenbrainz';
function resolveHomeSource(source?: MusicSource): MusicSource { // svelte-ignore state_referenced_locally
return source ?? activeSource; let activeSource = new PersistedState<MusicSource>(PAGE_SOURCE_KEYS['home'], data.primarySource);
}
async function loadHomeData(forceRefresh = false, sourceOverride?: MusicSource) { const homeQuery = getHomeQuery(() => activeSource.current);
const source = resolveHomeSource(sourceOverride); const homeData = $derived(homeQuery.data);
const cached = getHomeCachedData(source); const loading = $derived(homeQuery.isLoading);
if (cached && !forceRefresh) { const isUpdating = $derived(homeQuery.isRefetching);
homeData = cached.data; const lastUpdated = $derived(homeQuery.dataUpdatedAt ? new Date(homeQuery.dataUpdatedAt) : null);
lastUpdated = new Date(cached.timestamp);
loading = false;
if (isHomeCacheStale(cached.timestamp)) {
refreshInBackground(source);
}
return;
}
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
if (!homeData) {
loading = true;
}
error = '';
try {
const data = await api.get<HomeResponse>(
`/api/v1/home?source=${encodeURIComponent(source)}`,
{
signal: abortController.signal
}
);
homeData = data;
lastUpdated = new Date();
setHomeCachedData(data, source);
if (data.integration_status) {
integrationStore.setStatus(data.integration_status);
}
} catch (e) {
if (isAbortError(e)) {
return;
}
if (!homeData) {
error = "Couldn't load the home page";
}
} finally {
loading = false;
}
}
async function refreshInBackground(sourceOverride?: MusicSource) {
if (refreshing) return;
if (abortController) {
abortController.abort();
}
abortController = new AbortController();
refreshing = true;
isUpdating = true;
const source = resolveHomeSource(sourceOverride);
try {
const data = await api.get<HomeResponse>(
`/api/v1/home?source=${encodeURIComponent(source)}`,
{
signal: abortController.signal
}
);
homeData = data;
lastUpdated = new Date();
setHomeCachedData(data, source);
if (data.integration_status) {
integrationStore.setStatus(data.integration_status);
}
} catch (e) {
if (isAbortError(e)) {
return;
}
} finally {
refreshing = false;
isUpdating = false;
}
}
async function handleRefresh() {
refreshing = true;
isUpdating = true;
const minDelay = new Promise((r) => setTimeout(r, 500));
try {
await loadHomeData(true, activeSource);
} finally {
await minDelay;
refreshing = false;
isUpdating = false;
lastUpdated = new Date();
}
}
function cleanup() {
if (abortController) {
abortController.abort();
abortController = null;
}
}
onMount(() => {
activeSource = musicSourceStore.getPageSource('home');
loadHomeData(false, activeSource);
musicSourceStore.load().then(() => {
const actualSource = musicSourceStore.getPageSource('home');
if (actualSource !== activeSource) {
const dataBefore = homeData;
loadHomeData(true, actualSource).then(() => {
if (homeData !== dataBefore) {
activeSource = actualSource;
}
});
}
});
});
onDestroy(cleanup);
beforeNavigate(cleanup);
function handleSourceChange(source: MusicSource) { function handleSourceChange(source: MusicSource) {
activeSource = source; activeSource.current = source;
removeQueueCachedData(); removeQueueCachedData();
loadHomeData(true, source);
} }
type PreGenreBlock = type PreGenreBlock =
@@ -195,7 +61,7 @@
}); });
} }
if ( if (
activeSource === 'listenbrainz' && activeSource.current === 'listenbrainz' &&
homeData.weekly_exploration && homeData.weekly_exploration &&
homeData.weekly_exploration.tracks.length > 0 homeData.weekly_exploration.tracks.length > 0
) { ) {
@@ -291,10 +157,10 @@
<PageHeader <PageHeader
subtitle="Discover music, explore your library, and find new favorites." subtitle="Discover music, explore your library, and find new favorites."
{loading} {loading}
{refreshing} refreshing={isUpdating}
{isUpdating} {isUpdating}
{lastUpdated} {lastUpdated}
onRefresh={handleRefresh} onRefresh={() => homeQuery.refetch()}
> >
{#snippet title()} {#snippet title()}
<Music class="inline h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 mr-2 align-text-bottom" /> <Music class="inline h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 mr-2 align-text-bottom" />
@@ -303,14 +169,17 @@
</PageHeader> </PageHeader>
<div class="flex justify-end px-4 -mt-4 mb-4 sm:px-6 lg:px-8"> <div class="flex justify-end px-4 -mt-4 mb-4 sm:px-6 lg:px-8">
<SourceSwitcher pageKey="home" onSourceChange={handleSourceChange} /> <SimpleSourceSwitcher
currentSource={activeSource.current}
onSourceChange={handleSourceChange}
/>
</div> </div>
{#if error && !homeData} {#if homeQuery.error && !homeData}
<div class="mt-16 flex flex-col items-center justify-center px-4"> <div class="mt-16 flex flex-col items-center justify-center px-4">
<CircleAlert class="mb-4 h-10 w-10 text-base-content/50" /> <CircleAlert class="mb-4 h-10 w-10 text-base-content/50" />
<p class="text-base-content/70">{error}</p> <p class="text-base-content/70">{homeQuery.error.message ?? 'Failed to load Home data'}</p>
<button class="btn btn-primary mt-4" onclick={() => loadHomeData(true)}>Try Again</button> <button class="btn btn-primary mt-4" onclick={() => homeQuery.refetch()}>Try Again</button>
</div> </div>
{:else} {:else}
<div class="space-y-10 px-4 sm:space-y-12 sm:px-6 lg:px-8"> <div class="space-y-10 px-4 sm:space-y-12 sm:px-6 lg:px-8">
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types'; import type { AlbumBasicInfo, AlbumTracksInfo } from '$lib/types';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { colors } from '$lib/colors'; import { colors } from '$lib/colors';
import AlbumImage from '$lib/components/AlbumImage.svelte'; import AlbumImage from '$lib/components/AlbumImage.svelte';
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte'; import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
+1 -1
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { page } from '$app/state'; import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
@@ -12,7 +12,7 @@
import { toastStore } from '$lib/stores/toast'; import { toastStore } from '$lib/stores/toast';
import { getCacheTTL } from '$lib/stores/cacheTtl'; import { getCacheTTL } from '$lib/stores/cacheTtl';
import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors'; import { extractDominantColor, DEFAULT_GRADIENT } from '$lib/utils/colors';
import { getApiUrl } from '$lib/utils/api'; import { getApiUrl } from '$lib/api/api-utils';
import { Music } from 'lucide-svelte'; import { Music } from 'lucide-svelte';
import BackButton from '$lib/components/BackButton.svelte'; import BackButton from '$lib/components/BackButton.svelte';
import HeroBackdrop from '$lib/components/HeroBackdrop.svelte'; import HeroBackdrop from '$lib/components/HeroBackdrop.svelte';
Generated
+3
View File
@@ -0,0 +1,3 @@
version = 1
revision = 3
requires-python = ">=3.14"