mirror of
https://github.com/HabiRabbu/Musicseerr
synced 2026-04-21 13:37:27 +00:00
Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e70a76f489 | ||
|
|
a63008c298 | ||
|
|
4f4591fb96 | ||
|
|
10c593b254 | ||
|
|
789be2c77d | ||
|
|
4736ed4668 | ||
|
|
2d98c5f17d | ||
|
|
2032f8385c | ||
|
|
3cef59f257 |
22 changed files with 412 additions and 113 deletions
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
github: HabiRabbu
|
||||
ko_fi: HabiRabbit
|
||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
|
|
@ -30,10 +30,10 @@ jobs:
|
|||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
|
|
@ -44,7 +44,7 @@ jobs:
|
|||
run: echo "timestamp=$(git log -1 --pretty=%cI)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Warm cache (${{ matrix.platform }})
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
|
|
@ -65,21 +65,27 @@ jobs:
|
|||
packages: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -94,12 +100,14 @@ jobs:
|
|||
run: echo "IMAGE=ghcr.io/$(echo "$OWNER" | tr '[:upper:]' '[:lower:]')/musicseerr" >> $GITHUB_ENV
|
||||
|
||||
- name: Build and push (multi-arch)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:${{ steps.version.outputs.tag }}
|
||||
tags: |
|
||||
${{ env.IMAGE }}:${{ steps.version.outputs.tag }}
|
||||
habirabbu/musicseerr:${{ steps.version.outputs.tag }}
|
||||
build-args: |
|
||||
COMMIT_TAG=${{ steps.version.outputs.tag }}
|
||||
BUILD_DATE=${{ steps.date.outputs.timestamp }}
|
||||
|
|
@ -115,4 +123,5 @@ jobs:
|
|||
run: |
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:latest" \
|
||||
-t "habirabbu/musicseerr:latest" \
|
||||
"${IMAGE}:${TAG}"
|
||||
|
|
|
|||
17
Makefile
17
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
[](LICENSE)
|
||||
[](https://github.com/habirabbu/musicseerr/pkgs/container/musicseerr)
|
||||
[](https://hub.docker.com/r/habirabbu/musicseerr)
|
||||
[](https://discord.gg/B5suDg7gu2)
|
||||
[](https://musicseerr.com/)
|
||||
|
||||
|
|
@ -46,6 +47,8 @@ You need Docker and a running [Lidarr](https://lidarr.audio/) instance with an A
|
|||
|
||||
### 1. Create a docker-compose.yml
|
||||
|
||||
Images are available on [GHCR](https://github.com/habirabbu/musicseerr/pkgs/container/musicseerr) and [Docker Hub](https://hub.docker.com/r/habirabbu/musicseerr). Swap the image tag if you prefer Docker Hub (`habirabbu/musicseerr:latest`).
|
||||
|
||||
```yaml
|
||||
services:
|
||||
musicseerr:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -47,16 +47,18 @@
|
|||
|
||||
let hoveredIndex = $state<number | null>(null);
|
||||
let cardEls: HTMLAnchorElement[] = [];
|
||||
let tiltStyles: string[] = $derived(Array(cards.length).fill(''));
|
||||
let specularStyles: string[] = $derived(Array(cards.length).fill(''));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let tiltStyles: string[] = $state(Array(cards.length).fill(''));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let specularStyles: string[] = $state(Array(cards.length).fill(''));
|
||||
|
||||
const tweenDuration = reducedMotion ? 0 : 1200;
|
||||
const counters: Tweened<number>[] = $derived.by(() =>
|
||||
Array.from({ length: cards.length }, () =>
|
||||
tweened(0, { duration: tweenDuration, easing: cubicOut })
|
||||
)
|
||||
// svelte-ignore state_referenced_locally
|
||||
const counters: Tweened<number>[] = Array.from({ length: cards.length }, () =>
|
||||
tweened(0, { duration: tweenDuration, easing: cubicOut })
|
||||
);
|
||||
let counterValues: number[] = $derived(Array(cards.length).fill(0));
|
||||
// svelte-ignore state_referenced_locally
|
||||
let counterValues: number[] = $state(Array(cards.length).fill(0));
|
||||
|
||||
$effect(() => {
|
||||
const unsubs = counters.map((c, i) =>
|
||||
|
|
|
|||
|
|
@ -16,11 +16,39 @@
|
|||
const currentVersion = $derived(versionQuery.data?.version ?? null);
|
||||
const buildDate = $derived(versionQuery.data?.build_date ?? null);
|
||||
const isDev = $derived(currentVersion === 'dev' || currentVersion === 'hosting-local');
|
||||
const currentRelease = $derived(
|
||||
releaseHistoryQuery.data?.find((r) => r.tag_name === currentVersion) ??
|
||||
(isDev ? releaseHistoryQuery.data?.[0] : null) ??
|
||||
null
|
||||
);
|
||||
|
||||
function getMinorPrefix(tag: string): string | null {
|
||||
const m = tag.replace(/^v/, '').match(/^(\d+\.\d+)\./);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// Collect releases sharing the same minor version, up to and including the current version
|
||||
const minorReleases = $derived.by(() => {
|
||||
const releases = releaseHistoryQuery.data;
|
||||
if (!releases || releases.length === 0) return [];
|
||||
|
||||
if (isDev) {
|
||||
const prefix = getMinorPrefix(releases[0].tag_name);
|
||||
if (!prefix) return [releases[0]];
|
||||
return releases.filter((r) => !r.prerelease && getMinorPrefix(r.tag_name) === prefix);
|
||||
}
|
||||
|
||||
if (!currentVersion) return [];
|
||||
|
||||
const prefix = getMinorPrefix(currentVersion);
|
||||
if (!prefix) {
|
||||
const exact = releases.find((r) => r.tag_name === currentVersion);
|
||||
return exact ? [exact] : [];
|
||||
}
|
||||
|
||||
// Same minor, then slice from the current version downward (releases are newest-first)
|
||||
const sameMinor = releases.filter(
|
||||
(r) => !r.prerelease && getMinorPrefix(r.tag_name) === prefix
|
||||
);
|
||||
const currentIdx = sameMinor.findIndex((r) => r.tag_name === currentVersion);
|
||||
if (currentIdx === -1) return sameMinor;
|
||||
return sameMinor.slice(currentIdx);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
updateAvailable = updateCheckQuery.data?.update_available ?? false;
|
||||
|
|
@ -31,10 +59,4 @@
|
|||
updateAvailable={updateCheckQuery.data?.update_available ?? false}
|
||||
latestVersion={updateCheckQuery.data?.latest_version ?? null}
|
||||
/>
|
||||
<WhatsNewModal
|
||||
{currentVersion}
|
||||
{buildDate}
|
||||
releaseTag={currentRelease?.tag_name ?? null}
|
||||
releaseBody={currentRelease?.body ?? null}
|
||||
releaseName={currentRelease?.name ?? null}
|
||||
/>
|
||||
<WhatsNewModal {currentVersion} {buildDate} releases={minorReleases} />
|
||||
|
|
|
|||
|
|
@ -3,43 +3,58 @@
|
|||
import { isWhatsNewDismissed, dismissWhatsNew } from '$lib/stores/version.svelte';
|
||||
import { renderMarkdown } from '$lib/utils/markdown';
|
||||
import { X, Sparkles, ExternalLink } from 'lucide-svelte';
|
||||
import type { GitHubRelease } from '$lib/queries/VersionQuery.svelte';
|
||||
|
||||
interface Props {
|
||||
currentVersion: string | null;
|
||||
buildDate: string | null;
|
||||
releaseTag: string | null;
|
||||
releaseBody: string | null;
|
||||
releaseName: string | null;
|
||||
releases: GitHubRelease[];
|
||||
}
|
||||
let { currentVersion, buildDate, releaseTag, releaseBody, releaseName }: Props = $props();
|
||||
let { currentVersion, buildDate, releases }: Props = $props();
|
||||
|
||||
let dialogEl: HTMLDialogElement | undefined = $state();
|
||||
let renderedBody = $state('');
|
||||
let renderedSections: { tag: string; name: string | null; html: string }[] = $state([]);
|
||||
|
||||
const isDev = $derived(currentVersion === 'dev' || currentVersion === 'hosting-local');
|
||||
const latestRelease = $derived(releases.length > 0 ? releases[0] : null);
|
||||
|
||||
// In dev: key dismissal to build_date so modal shows once per rebuild, not every refresh
|
||||
// In prod: key to release tag so modal shows once per new version
|
||||
const dismissKey = $derived(isDev ? (buildDate ?? 'dev') : (releaseTag ?? currentVersion));
|
||||
// In prod: key to latest release tag so modal re-shows when a new patch lands
|
||||
const dismissKey = $derived(
|
||||
isDev ? (buildDate ?? 'dev') : (latestRelease?.tag_name ?? currentVersion)
|
||||
);
|
||||
|
||||
const hasContent = $derived(releases.some((r) => r.body && r.body.trim().length > 0));
|
||||
|
||||
const shouldShow = $derived(
|
||||
currentVersion !== null &&
|
||||
dismissKey !== null &&
|
||||
releaseBody !== null &&
|
||||
releaseBody.trim().length > 0 &&
|
||||
!isWhatsNewDismissed(dismissKey)
|
||||
currentVersion !== null && dismissKey !== null && hasContent && !isWhatsNewDismissed(dismissKey)
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (releaseBody && releaseBody.trim()) {
|
||||
renderMarkdown(releaseBody)
|
||||
.then((html) => {
|
||||
renderedBody = html;
|
||||
})
|
||||
.catch(() => {
|
||||
renderedBody = '';
|
||||
});
|
||||
let aborted = false;
|
||||
const withContent = releases.filter((r) => r.body && r.body.trim());
|
||||
if (withContent.length === 0) {
|
||||
renderedSections = [];
|
||||
return;
|
||||
}
|
||||
|
||||
Promise.all(
|
||||
withContent.map(async (r) => ({
|
||||
tag: r.tag_name,
|
||||
name: r.name,
|
||||
html: await renderMarkdown(r.body!)
|
||||
}))
|
||||
)
|
||||
.then((sections) => {
|
||||
if (!aborted) renderedSections = sections;
|
||||
})
|
||||
.catch(() => {
|
||||
if (!aborted) renderedSections = [];
|
||||
});
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
|
|
@ -56,7 +71,7 @@
|
|||
}
|
||||
|
||||
function onDialogClose() {
|
||||
if (dismissKey) {
|
||||
if (dismissKey && !isWhatsNewDismissed(dismissKey)) {
|
||||
dismissWhatsNew(dismissKey);
|
||||
}
|
||||
}
|
||||
|
|
@ -102,22 +117,28 @@
|
|||
|
||||
<div class="divider my-0 opacity-10"></div>
|
||||
|
||||
{#if releaseName}
|
||||
<p class="text-base-content mt-4 mb-4 text-sm font-semibold border-l-2 border-accent/50 pl-3">
|
||||
{releaseName}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if renderedBody}
|
||||
{#if renderedSections.length > 0}
|
||||
<div
|
||||
class="whats-new-content release-notes-prose prose prose-sm max-h-[55vh] max-w-none text-base-content/75 overflow-y-auto rounded-lg border border-base-content/5 bg-base-100/50 p-4 {releaseName
|
||||
? ''
|
||||
: 'mt-4'}"
|
||||
class="whats-new-content release-notes-prose max-h-[55vh] max-w-none overflow-y-auto rounded-lg border border-base-content/5 bg-base-100/50 p-4 mt-4"
|
||||
>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify -->
|
||||
{@html renderedBody}
|
||||
{#each renderedSections as section, i (section.tag)}
|
||||
{#if i > 0}
|
||||
<div class="divider my-4 opacity-20"></div>
|
||||
{/if}
|
||||
<p
|
||||
class="text-base-content text-sm font-semibold border-l-2 border-accent/50 pl-3 {i > 0
|
||||
? ''
|
||||
: 'mt-0'} mb-3"
|
||||
>
|
||||
{section.name ?? section.tag}
|
||||
</p>
|
||||
<div class="prose prose-sm max-w-none text-base-content/75">
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via DOMPurify -->
|
||||
{@html section.html}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{:else if hasContent}
|
||||
<div class="flex justify-center py-12">
|
||||
<span class="loading loading-spinner loading-md text-accent/60"></span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
117
frontend/src/lib/stores/musicSource.spec.ts
Normal file
117
frontend/src/lib/stores/musicSource.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
28
frontend/src/routes/+error.svelte
Normal file
28
frontend/src/routes/+error.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue