Compare commits

...

3 commits
v1.3.2 ... main

Author SHA1 Message Date
Harvey
e70a76f489 Failed to verify fix + reactivity & polling fixes for multi downloads
Some checks failed
Backend CI / Lint (push) Has been cancelled
Backend CI / Tests (push) Has been cancelled
Frontend CI / Lint, Format & Type Check (push) Has been cancelled
Frontend CI / Tests (push) Has been cancelled
2026-04-19 03:18:50 +01:00
Harvey
a63008c298 make lint + improve makefile 2026-04-18 23:12:53 +01:00
Harvey
4f4591fb96 plex deadlock + version showing stale + "disco" screen after docker update fixes 2026-04-18 23:03:13 +01:00
16 changed files with 291 additions and 51 deletions

View file

@ -85,7 +85,7 @@ NPM ?= pnpm
frontend-test-playlist-detail \
frontend-test-queuehelpers \
rebuild \
test tests check lint format ci
fmt format lint tests test ci
help: ## Show available targets
@grep -E '^[a-zA-Z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "}; {printf "%-34s %s\n", $$1, $$2}'
@ -338,16 +338,15 @@ frontend-test-discover-page: ## Run discover page and query tests
rebuild: ## Rebuild the application
cd "$(ROOT_DIR)" && ./manage.sh --rebuild
test: backend-test frontend-test ## Run backend and frontend tests
tests: test ## Alias for 'test'
check: backend-test frontend-check ## Run backend tests and frontend type checks
lint: backend-lint frontend-lint ## Run linting targets
fmt: format ## Alias for 'format'
format: ## Auto-format backend (ruff --fix) and frontend (prettier)
cd "$(ROOT_DIR)" && $(BACKEND_VENV_DIR)/bin/ruff check --fix backend
cd "$(FRONTEND_DIR)" && $(NPM) run format
ci: backend-test backend-lint frontend-check frontend-lint frontend-format-check frontend-test-server ## Run the local CI checks
lint: backend-lint frontend-lint ## Run all linting checks
tests: backend-test frontend-test-server ## Run all tests
test: tests ## Alias for 'tests'
ci: backend-lint frontend-lint frontend-check frontend-format-check backend-test frontend-test-server ## Run the full CI pipeline (fmt-check + lint + typecheck + tests)

View file

@ -40,7 +40,7 @@ class PreferencesService:
self._settings = settings
self._config_path = settings.config_file_path
self._config_cache: Optional[dict] = None
self._cache_lock = threading.Lock()
self._cache_lock = threading.RLock()
self._migrate_musicbrainz_settings()
self._ensure_instance_id()

View file

@ -4,6 +4,9 @@ from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
_NO_CACHE_HEADERS = {"Cache-Control": "no-cache"}
def mount_frontend(app: FastAPI):
backend_static = Path(__file__).parent / "static"
frontend_root = Path(__file__).resolve().parents[1] / "frontend"
@ -83,7 +86,7 @@ def mount_frontend(app: FastAPI):
@app.get("/")
async def serve_root():
if index_html.exists():
return FileResponse(index_html)
return FileResponse(index_html, headers=_NO_CACHE_HEADERS)
raise HTTPException(status_code=404, detail="Frontend not built yet")
@app.get("/{full_path:path}")
@ -91,5 +94,5 @@ def mount_frontend(app: FastAPI):
if full_path.startswith("api"):
raise HTTPException(status_code=404, detail="API route not found")
if index_html.exists():
return FileResponse(index_html)
return FileResponse(index_html, headers=_NO_CACHE_HEADERS)
raise HTTPException(status_code=404, detail="Frontend not built yet")

View file

@ -77,6 +77,43 @@ class TestCreatePlexPin:
assert resp.status_code == 500
class TestGetOrCreateSettingNoDeadlock:
def test_get_or_create_setting_does_not_deadlock(self, tmp_path):
import threading
from core.config import Settings
from services.preferences_service import PreferencesService
config_path = tmp_path / "config.json"
settings = Settings(
root_app_dir=tmp_path,
config_file_path=config_path,
cache_dir=tmp_path / "cache",
library_db_path=tmp_path / "cache" / "library.db",
queue_db_path=tmp_path / "cache" / "queue.db",
)
result = None
exc = None
def run():
nonlocal result, exc
try:
prefs = PreferencesService(settings)
result = prefs.get_or_create_setting("plex_client_id", lambda: "test-client-id")
result = (result, prefs.get_or_create_setting("plex_client_id", lambda: "other"))
except (OSError, ValueError, RuntimeError) as e:
exc = e
t = threading.Thread(target=run)
t.start()
t.join(timeout=5)
assert not t.is_alive(), "Deadlock detected: PreferencesService hung for 5s"
assert exc is None
assert result[0] == "test-client-id"
assert result[1] == "test-client-id"
class TestPollPlexPin:
def test_poll_pending(self, auth_client):
resp = auth_client.get("/plex/auth/poll?pin_id=12345")

View file

@ -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(() => {

View file

@ -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',

View file

@ -30,7 +30,8 @@ export const getVersionQuery = () =>
staleTime: CACHE_TTL.VERSION_INFO,
queryKey: VersionQueryKeyFactory.info(),
queryFn: ({ signal }) => api.global.get<VersionInfo>(API.version.info(), { signal }),
refetchOnWindowFocus: false
refetchOnWindowFocus: false,
refetchOnMount: 'always'
}));
export const getUpdateCheckQuery = () =>

View file

@ -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');
});
});

View file

@ -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 {

View file

@ -0,0 +1,28 @@
<script lang="ts">
import { AlertTriangle, Home, RotateCw } from 'lucide-svelte';
import { page } from '$app/state';
</script>
<svelte:head>
<title>Error - MusicSeerr</title>
</svelte:head>
<div class="w-full px-2 sm:px-4 lg:px-8 py-4 sm:py-8 max-w-7xl mx-auto">
<div class="flex flex-col items-center justify-center py-20 gap-4 text-center">
<AlertTriangle class="h-16 w-16 text-error/40" />
<h1 class="text-lg font-semibold text-base-content/80">Something went wrong</h1>
<p class="text-sm text-base-content/60">
{page.error?.message ?? 'An unexpected error occurred'}
</p>
<div class="flex items-center gap-2">
<a href="/" class="btn btn-ghost btn-sm">
<Home class="h-4 w-4" />
Home
</a>
<button class="btn btn-accent btn-sm" onclick={() => location.reload()}>
<RotateCw class="h-4 w-4" />
Retry
</button>
</div>
</div>
</div>

View file

@ -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('');

View file

@ -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}

View file

@ -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;
}}

View file

@ -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>

View file

@ -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>

View file

@ -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>