From 81bdf4355fd9e751b5b2f8a20032fa2f3c31613d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 24 May 2026 17:02:46 +0300 Subject: [PATCH 01/42] refactor(webui): link stats artist chips - replace manual artist-detail navigation with declarative anchors - reuse the shared artist-detail helper for bubble and ranked views - keep the no-id bubble fallback non-interactive --- webui/src/routes/stats/-route.test.tsx | 27 +++- .../routes/stats/-ui/stats-page.module.css | 9 +- webui/src/routes/stats/-ui/stats-page.tsx | 153 +++++++----------- 3 files changed, 91 insertions(+), 98 deletions(-) diff --git a/webui/src/routes/stats/-route.test.tsx b/webui/src/routes/stats/-route.test.tsx index ec251ae9..20867072 100644 --- a/webui/src/routes/stats/-route.test.tsx +++ b/webui/src/routes/stats/-route.test.tsx @@ -117,14 +117,29 @@ describe('stats route', () => { await waitFor(() => expect(history.location.search).toContain('range=30d')); }); - it('hands artist detail navigation directly to the shell bridge', async () => { - renderStatsRoute(); + it('links artist names to the artist-detail route', async () => { + const { history } = renderStatsRoute(); - fireEvent.click(await screen.findByRole('button', { name: 'Artist A' })); + const bubbleLink = await screen.findByRole('link', { + name: 'Open artist detail for Artist A', + }); + expect(bubbleLink).toHaveAttribute('href', '/artist-detail/library/7'); - expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( - 7, - 'Artist A', + const rankedLink = screen.getByRole('link', { name: 'Artist A' }); + expect(rankedLink).toHaveAttribute('href', '/artist-detail/library/7'); + + fireEvent.click(bubbleLink); + + await waitFor(() => expect(history.location.pathname).toBe('/artist-detail/library/7')); + await waitFor(() => + expect(window.SoulSyncWebShellBridge?.navigateToArtistDetail).toHaveBeenCalledWith( + '7', + '', + null, + { + skipRouteChange: true, + }, + ), ); }); diff --git a/webui/src/routes/stats/-ui/stats-page.module.css b/webui/src/routes/stats/-ui/stats-page.module.css index 17be6814..a6260111 100644 --- a/webui/src/routes/stats/-ui/stats-page.module.css +++ b/webui/src/routes/stats/-ui/stats-page.module.css @@ -364,10 +364,13 @@ border: none; padding: 0; cursor: pointer; + color: inherit; + text-decoration: none; + font: inherit; transition: transform 0.2s ease; } -.statsArtistBubble:disabled { +.statsArtistBubbleDisabled { cursor: default; } @@ -375,6 +378,10 @@ transform: translateY(-3px); } +.statsArtistBubbleDisabled:hover { + transform: none; +} + .statsBubbleImage { border-radius: 50%; background-size: cover; diff --git a/webui/src/routes/stats/-ui/stats-page.tsx b/webui/src/routes/stats/-ui/stats-page.tsx index 63259a7d..1c93b461 100644 --- a/webui/src/routes/stats/-ui/stats-page.tsx +++ b/webui/src/routes/stats/-ui/stats-page.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; -import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { Link, useNavigate } from '@tanstack/react-router'; +import { type ComponentPropsWithoutRef, type ReactNode, useEffect, useRef, useState } from 'react'; import { Bar, BarChart, @@ -72,6 +72,8 @@ const STATS_CHART_CURSOR = { fill: 'rgba(var(--accent-rgb), 0.12)', } as const; +const ARTIST_DETAIL_SOURCE = 'library' as const; + export function StatsPage() { const bridge = useReactPageShell('stats'); @@ -136,10 +138,6 @@ export function StatsPage() { }); }; - const openArtistDetail = (artistId: string | number, artistName: string) => { - bridge.navigateToArtistDetail(artistId, artistName); - }; - return (
@@ -229,25 +227,15 @@ export function StatsPage() {
- openArtistDetail(artistId, artistName)} - /> - openArtistDetail(artistId, artistName)} - /> + + - openArtistDetail(artistId, artistName)} - /> + openArtistDetail(artistId, artistName)} onPlay={(track) => playStatsTrack(bridge, track)} /> @@ -405,13 +393,7 @@ function StatsGenreLegend({ ); } -function TopArtistsVisual({ - artists, - onArtistSelect, -}: { - artists: StatsArtistRow[]; - onArtistSelect: (artistId: string | number, artistName: string) => void; -}) { +function TopArtistsVisual({ artists }: { artists: StatsArtistRow[] }) { const topArtists = getTopArtistBubbles(artists); if (topArtists.length === 0) return null; @@ -420,18 +402,8 @@ function TopArtistsVisual({
{topArtists.map(({ artist, percent, size }) => { const isClickable = artist.id !== null && artist.id !== undefined; - return ( - + + ); + return isClickable ? ( + + {bubbleContent} + + ) : ( +
+ {bubbleContent} +
); })}
@@ -459,13 +449,30 @@ function TopArtistsVisual({ ); } -function StatsRankedArtists({ - artists, - onArtistSelect, +function ArtistDetailLink({ + artistId, + children, + ...linkProps }: { - artists: StatsArtistRow[]; - onArtistSelect: (artistId: string | number, artistName: string) => void; -}) { + artistId: string | number | null | undefined; + children: ReactNode; +} & Omit, 'children' | 'href'>) { + if (artistId == null) { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function StatsRankedArtists({ artists }: { artists: StatsArtistRow[] }) { return (
{artists.length === 0 ? : null} @@ -479,17 +486,9 @@ function StatsRankedArtists({ )}
- {artist.id ? ( - - ) : ( - artist.name - )} + + {artist.name} + {artist.soul_id && !String(artist.soul_id).startsWith('soul_unnamed_') ? ( SoulID ) : null} @@ -509,13 +508,7 @@ function StatsRankedArtists({ ); } -function StatsRankedAlbums({ - albums, - onArtistSelect, -}: { - albums: StatsAlbumRow[]; - onArtistSelect: (artistId: string | number, artistName: string) => void; -}) { +function StatsRankedAlbums({ albums }: { albums: StatsAlbumRow[] }) { return (
{albums.length === 0 ? : null} @@ -530,19 +523,9 @@ function StatsRankedAlbums({
{album.name}
- {album.artist_id ? ( - - ) : ( - album.artist || '' - )} + + {album.artist || ''} +
@@ -556,11 +539,9 @@ function StatsRankedAlbums({ function StatsRankedTracks({ tracks, - onArtistSelect, onPlay, }: { tracks: StatsTrackRow[]; - onArtistSelect: (artistId: string | number, artistName: string) => void; onPlay: (track: { title: string; artist: string; album: string }) => Promise; }) { return ( @@ -577,19 +558,9 @@ function StatsRankedTracks({
{track.name}
- {track.artist_id ? ( - - ) : ( - track.artist || '' - )} + + {track.artist || ''} + {track.album ? ` · ${track.album}` : ''}
From c9ac9aeb1a07461e49ca48224c7f71a2e91ab8d8 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 24 May 2026 18:16:18 +0300 Subject: [PATCH 02/42] refactor(issues): use Link to open/close issues --- webui/src/routes/issues/-route.test.tsx | 21 ++++++++--- .../issues/-ui/issue-detail-modal.module.css | 37 +++++++++++++++++++ .../routes/issues/-ui/issue-detail-modal.tsx | 11 +++++- .../routes/issues/-ui/issues-page.module.css | 1 + webui/src/routes/issues/-ui/issues-page.tsx | 23 +++--------- 5 files changed, 67 insertions(+), 26 deletions(-) diff --git a/webui/src/routes/issues/-route.test.tsx b/webui/src/routes/issues/-route.test.tsx index 7a0abdcd..55492773 100644 --- a/webui/src/routes/issues/-route.test.tsx +++ b/webui/src/routes/issues/-route.test.tsx @@ -136,7 +136,11 @@ describe('issues route', () => { it('renders stats and list items through the app router', async () => { renderIssuesRoute(); await waitFor(() => expect(screen.getByTestId('issue-counts')).toHaveTextContent('2')); - expect(await screen.findByTestId('issue-card-7')).toHaveTextContent('Bad tags'); + const issueCard = await screen.findByRole('link', { name: /Bad tags/i }); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('/issues?')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('status=open')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('category=all')); + expect(issueCard).toHaveAttribute('href', expect.stringContaining('issueId=7')); }); it('loads the detail modal from the route search state', async () => { @@ -157,17 +161,22 @@ describe('issues route', () => { it('opens and closes the detail modal', async () => { const { history } = renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7')); await waitFor(() => expect(history.location.search).toContain('issueId=7')); - fireEvent.click(screen.getByRole('button', { name: /close issue detail/i })); + const closeLink = screen.getByRole('link', { name: /^close$/i }); + expect(closeLink).toHaveAttribute( + 'href', + expect.stringContaining('/issues?status=open&category=all'), + ); + fireEvent.click(closeLink); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await waitFor(() => expect(history.location.search).toBe('?status=open&category=all')); }); it('closes the detail modal with Escape', async () => { const { history } = renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); await waitFor(() => expect(screen.getByRole('dialog')).toHaveTextContent('Issue #7')); await waitFor(() => expect(history.location.search).toContain('issueId=7')); @@ -179,7 +188,7 @@ describe('issues route', () => { it('focuses the detail modal close button on open', async () => { renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); const closeButton = await screen.findByRole('button', { name: /close issue detail/i, @@ -190,7 +199,7 @@ describe('issues route', () => { it('invokes the shared workflow adapter for admin downloads', async () => { renderIssuesRoute(); - fireEvent.click(await screen.findByTestId('issue-card-7')); + fireEvent.click(await screen.findByRole('link', { name: /Bad tags/i })); fireEvent.click(await screen.findByRole('button', { name: /download album/i })); await waitFor(() => expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalled()); expect(workflowActions.openDownloadMissingAlbum).toHaveBeenCalledWith( diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.module.css b/webui/src/routes/issues/-ui/issue-detail-modal.module.css index 7348fa43..48a25c54 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.module.css +++ b/webui/src/routes/issues/-ui/issue-detail-modal.module.css @@ -878,6 +878,43 @@ font-size: 13px; } +.modalLinkButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + background: rgba(255, 255, 255, 0.05); + color: #fff; + cursor: pointer; + font: inherit; + font-size: 13px; + font-weight: 600; + line-height: 1.2; + min-height: 36px; + padding: 8px 12px; + text-decoration: none; + transition: + transform 0.18s ease, + border-color 0.18s ease, + box-shadow 0.18s ease, + background 0.18s ease, + color 0.18s ease; +} + +.modalLinkButton:hover { + transform: translateY(-1px); + border-color: rgba(255, 255, 255, 0.18); + background: rgba(255, 255, 255, 0.08); +} + +.modalLinkButton:focus-visible { + outline: none; + border-color: rgba(var(--accent-light-rgb), 0.55); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + .modalButtonSecondary { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.8); diff --git a/webui/src/routes/issues/-ui/issue-detail-modal.tsx b/webui/src/routes/issues/-ui/issue-detail-modal.tsx index 1cbb21d7..c773e8e4 100644 --- a/webui/src/routes/issues/-ui/issue-detail-modal.tsx +++ b/webui/src/routes/issues/-ui/issue-detail-modal.tsx @@ -1,4 +1,5 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { Link } from '@tanstack/react-router'; import { useEffect, useMemo, useState, type ReactNode } from 'react'; import { DialogBody, DialogFooter, DialogFrame, DialogHeader } from '@/components/dialog'; @@ -22,6 +23,7 @@ import { ISSUE_CATEGORY_META, parseSnapshot, } from '../-issues.helpers'; +import { Route } from '../route'; import styles from './issue-detail-modal.module.css'; export function IssueDetailModal({ @@ -213,9 +215,14 @@ export function IssueDetailModal({ /> {renderIssueDetailContent()} - + {issue && ( <> {statusButtons} diff --git a/webui/src/routes/issues/-ui/issues-page.module.css b/webui/src/routes/issues/-ui/issues-page.module.css index 9fc1d269..95ab7faa 100644 --- a/webui/src/routes/issues/-ui/issues-page.module.css +++ b/webui/src/routes/issues/-ui/issues-page.module.css @@ -159,6 +159,7 @@ width: 100%; padding: 14px 16px; text-align: left; + text-decoration: none; border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.03); diff --git a/webui/src/routes/issues/-ui/issues-page.tsx b/webui/src/routes/issues/-ui/issues-page.tsx index 52e86f83..2354cde6 100644 --- a/webui/src/routes/issues/-ui/issues-page.tsx +++ b/webui/src/routes/issues/-ui/issues-page.tsx @@ -1,5 +1,5 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { Select } from '@/components/form'; import { Show } from '@/components/primitives'; @@ -73,13 +73,6 @@ function IssueBoard() { ...issueListQueryOptions(profileId, params), }); - const openIssue = (issueId: number) => { - void navigate({ - to: Route.fullPath, - search: (prev) => ({ ...prev, issueId }), - }); - }; - const onCategoryChange = (category: IssuesSearch['category']) => { void navigate({ to: Route.fullPath, @@ -112,7 +105,6 @@ function IssueBoard() { issuesError={issuesQuery.error} issuesLoading={issuesQuery.isLoading} showReporterName={isAdmin} - onIssueSelect={openIssue} statusFilter={params.status} />
@@ -225,7 +217,6 @@ function IssueBoardList({ issues, issuesError, issuesLoading, - onIssueSelect, showReporterName, statusFilter, }: { @@ -233,7 +224,6 @@ function IssueBoardList({ issues: IssueRecord[]; issuesError: unknown; issuesLoading: boolean; - onIssueSelect: (issueId: number) => void; showReporterName: boolean; statusFilter: IssuesSearch['status']; }) { @@ -285,7 +275,6 @@ function IssueBoardList({ key={issue.id} issue={issue} showReporterName={showReporterName} - onIssueSelect={onIssueSelect} /> )); } @@ -294,11 +283,9 @@ function IssueBoardList({ function IssueBoardCard({ issue, showReporterName, - onIssueSelect, }: { issue: IssueRecord; showReporterName: boolean; - onIssueSelect: (issueId: number) => void; }) { const snapshot = parseSnapshot(issue.snapshot_data); const artwork = getIssueArtwork(snapshot); @@ -311,11 +298,11 @@ function IssueBoardCard({ const createdDate = formatIssueDate(issue.created_at); return ( - + ); } From 7bee42468686b50e1d7fd3ec31db20ac1fd070bf Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 08:54:47 -0700 Subject: [PATCH 03/42] Escape dash-leading YouTube search queries Fix manual YouTube searches for video IDs that begin with a dash by escaping leading '-' before building yt-dlp ytsearch expressions. This preserves normal search terms and already escaped user input while preventing yt-dlp from treating the ID as search syntax. Add regression coverage for both YouTube download search and video search paths. Fixes #684. --- core/youtube_client.py | 24 ++++++- tests/test_youtube_search_dash_query.py | 87 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 tests/test_youtube_search_dash_query.py diff --git a/core/youtube_client.py b/core/youtube_client.py index df910e59..bd72e674 100644 --- a/core/youtube_client.py +++ b/core/youtube_client.py @@ -154,6 +154,7 @@ class YouTubeClient(DownloadSourcePlugin): # Initialize production matching engine for parity with Soulseek self.matching_engine = MusicMatchingEngine() + logger.info("Initialized production MusicMatchingEngine") # NOTE: deliberately don't call `_check_ffmpeg()` here. That call @@ -216,6 +217,23 @@ class YouTubeClient(DownloadSourcePlugin): # Optional progress callback for UI updates self.progress_callback = None + @staticmethod + def _escape_ytsearch_query(query: str) -> str: + """Escape yt-dlp search terms that begin with a dash. + + YouTube video IDs may start with ``-``. When passed through + ``ytsearchN:``, yt-dlp treats that leading dash as search + syntax unless it is escaped. Preserve already-escaped input so + users who worked around the issue manually keep the same result. + """ + if not isinstance(query, str): + return query + stripped = query.lstrip() + leading_ws_len = len(query) - len(stripped) + if stripped.startswith('-'): + return f"{query[:leading_ws_len]}\\{stripped}" + return query + def is_available(self) -> bool: """ Check if YouTube client is available (yt-dlp installed and ffmpeg available). @@ -698,8 +716,9 @@ class YouTubeClient(DownloadSourcePlugin): if cookies_browser: ydl_opts['cookiesfrombrowser'] = (cookies_browser,) + search_query = self._escape_ytsearch_query(query) with yt_dlp.YoutubeDL(ydl_opts) as ydl: - data = ydl.extract_info(f"ytsearch{max_results}:{query}", download=False) + data = ydl.extract_info(f"ytsearch{max_results}:{search_query}", download=False) if not data or 'entries' not in data: return [] @@ -777,9 +796,10 @@ class YouTubeClient(DownloadSourcePlugin): if cookies_browser: ydl_opts['cookiesfrombrowser'] = (cookies_browser,) + search_query = self._escape_ytsearch_query(query) with yt_dlp.YoutubeDL(ydl_opts) as ydl: # Search YouTube (max 50 results) - search_results = ydl.extract_info(f"ytsearch50:{query}", download=False) + search_results = ydl.extract_info(f"ytsearch50:{search_query}", download=False) if not search_results or 'entries' not in search_results: return [] diff --git a/tests/test_youtube_search_dash_query.py b/tests/test_youtube_search_dash_query.py new file mode 100644 index 00000000..a2e0318d --- /dev/null +++ b/tests/test_youtube_search_dash_query.py @@ -0,0 +1,87 @@ +"""Regression tests for YouTube searches whose query starts with ``-``. + +YouTube video IDs can start with a dash. yt-dlp's ``ytsearchN:`` parser +interprets a leading dash as search syntax unless escaped, so manual +searches for those IDs used to fan out into unrelated results. +""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from core import youtube_client +from core.youtube_client import YouTubeClient + + +def test_escape_ytsearch_query_handles_leading_dash(): + assert YouTubeClient._escape_ytsearch_query("-4WUHJRhvrM") == r"\-4WUHJRhvrM" + assert YouTubeClient._escape_ytsearch_query(r"\-4WUHJRhvrM") == r"\-4WUHJRhvrM" + assert YouTubeClient._escape_ytsearch_query("Yo-Yo Ma") == "Yo-Yo Ma" + + +def test_search_escapes_leading_dash_before_yt_dlp(monkeypatch): + captured = [] + + class _FakeYoutubeDL: + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, search_query, download=False): + captured.append(search_query) + return {"entries": [{"id": "-4WUHJRhvrM", "title": "Unaccompanied Cello"}]} + + monkeypatch.setattr(youtube_client.yt_dlp, "YoutubeDL", _FakeYoutubeDL) + + client = YouTubeClient.__new__(YouTubeClient) + monkeypatch.setattr(client, "_get_best_audio_format", lambda formats: None) + monkeypatch.setattr( + client, + "_youtube_to_track_result", + lambda entry, best_audio: SimpleNamespace(filename=entry["title"]), + ) + + tracks, albums = asyncio.run(client.search("-4WUHJRhvrM")) + + assert captured == [r"ytsearch50:\-4WUHJRhvrM"] + assert len(tracks) == 1 + assert albums == [] + + +def test_search_videos_escapes_leading_dash_before_yt_dlp(monkeypatch): + captured = [] + + class _FakeYoutubeDL: + def __init__(self, opts): + self.opts = opts + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def extract_info(self, search_query, download=False): + captured.append(search_query) + return { + "entries": [{ + "id": "-4WUHJRhvrM", + "title": "Unaccompanied Cello", + "duration": 152, + "uploader": "Yo-Yo Ma", + }] + } + + monkeypatch.setattr(youtube_client.yt_dlp, "YoutubeDL", _FakeYoutubeDL) + client = YouTubeClient.__new__(YouTubeClient) + + results = asyncio.run(client.search_videos("-4WUHJRhvrM", max_results=8)) + + assert captured == [r"ytsearch8:\-4WUHJRhvrM"] + assert [r.video_id for r in results] == ["-4WUHJRhvrM"] From 4ca3f70bf39341f89fbb13d5c6b3e77a9af51a1f Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 09:33:19 -0700 Subject: [PATCH 04/42] Show MusicBrainz release variants in import Expand matched MusicBrainz release groups into concrete releases for specific album searches so import users can choose the correct edition by track count, format, country, and disambiguation. Preserve distinct MusicBrainz release IDs instead of deduping same-title variants, carry release metadata through import matching, and surface those details on album result cards. Add coverage for variant preservation and release-group expansion. --- core/imports/album.py | 4 + core/imports/staging.py | 26 ++- core/metadata/album_tracks.py | 12 +- core/musicbrainz_client.py | 35 ++++ core/musicbrainz_search.py | 184 +++++++++++++++------- tests/imports/test_import_staging.py | 49 ++++++ tests/metadata/test_musicbrainz_search.py | 52 ++++++ webui/static/stats-automations.js | 23 ++- webui/static/style.css | 12 ++ 9 files changed, 337 insertions(+), 60 deletions(-) diff --git a/core/imports/album.py b/core/imports/album.py index db6c7d01..3da27119 100644 --- a/core/imports/album.py +++ b/core/imports/album.py @@ -354,6 +354,10 @@ def build_album_import_context( "images": album.get("images") or ([] if not track_album_image else [{"url": track_album_image}]), "source": source, } + for key in ("format", "country", "status", "label", "disambiguation", "release_group_id"): + value = str(album.get(key) or "").strip() + if value: + normalized_album[key] = value original_search = { "title": normalized_track["name"], diff --git a/core/imports/staging.py b/core/imports/staging.py index 5843d144..748cbb11 100644 --- a/core/imports/staging.py +++ b/core/imports/staging.py @@ -198,6 +198,12 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]: ).strip() release_date = str(_extract_value(album, "release_date", "releaseDate", default="") or "").strip() album_type = str(_extract_value(album, "album_type", "type", default="album") or "album").strip() or "album" + release_format = str(_extract_value(album, "format", "release_format", default="") or "").strip() + country = str(_extract_value(album, "country", default="") or "").strip() + status = str(_extract_value(album, "status", default="") or "").strip() + label = str(_extract_value(album, "label", default="") or "").strip() + disambiguation = str(_extract_value(album, "disambiguation", default="") or "").strip() + release_group_id = str(_extract_value(album, "release_group_id", "releaseGroupId", default="") or "").strip() total_tracks = _extract_value(album, "total_tracks", "track_count", default=0) if isinstance(total_tracks, (list, tuple, set)): @@ -225,7 +231,7 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]: else: image_url = _extract_value(first_image, "url", "image_url", "src", default="") - return { + suggestion = { "id": album_id or album_name or "unknown-album", "name": album_name or album_id or "Unknown Album", "artist": artist_name or "Unknown Artist", @@ -235,14 +241,30 @@ def _normalize_album_result(album: Any, source: str) -> Dict[str, Any]: "album_type": album_type, "source": source, } + if release_format: + suggestion["format"] = release_format + if country: + suggestion["country"] = country + if status: + suggestion["status"] = status + if label: + suggestion["label"] = label + if disambiguation: + suggestion["disambiguation"] = disambiguation + if release_group_id: + suggestion["release_group_id"] = release_group_id + return suggestion -def _album_fingerprint(album: Dict[str, Any]) -> Tuple[str, str, str, str]: +def _album_fingerprint(album: Dict[str, Any]) -> Tuple[str, ...]: + if album.get("source") == "musicbrainz" and album.get("id"): + return ("musicbrainz", str(album.get("id", "") or "").strip().casefold()) return ( str(album.get("name", "") or "").strip().casefold(), str(album.get("artist", "") or "").strip().casefold(), str(album.get("release_date", "") or "").strip()[:10].casefold(), str(album.get("album_type", "") or "").strip().casefold(), + str(album.get("total_tracks", "") or "").strip(), ) diff --git a/core/metadata/album_tracks.py b/core/metadata/album_tracks.py index 266134a8..e66be412 100644 --- a/core/metadata/album_tracks.py +++ b/core/metadata/album_tracks.py @@ -264,6 +264,11 @@ def _build_album_info_typed(album_data: Dict[str, Any], album_id: str, if isinstance(first, dict): ctx['image_url'] = first.get('url') or ctx.get('image_url') + for key in ('format', 'country', 'status', 'label', 'disambiguation', 'release_group_id'): + value = album_data.get(key) + if value: + ctx[key] = value + return ctx @@ -327,7 +332,7 @@ def _build_album_info_legacy(album_data: Any, album_id: str, if not image_url: image_url = _extract_lookup_value(album_data, 'image_url', 'thumb_url') - return { + album_info = { 'id': _extract_lookup_value(album_data, 'id', 'album_id', 'collectionId', 'release_id', default=album_id) or album_id, 'name': _extract_lookup_value(album_data, 'name', 'title', default=album_name or album_id) or album_name or album_id, 'artist': resolved_artist_name or '', @@ -345,6 +350,11 @@ def _build_album_info_legacy(album_data: Any, album_id: str, ), 'total_tracks': _extract_lookup_value(album_data, 'total_tracks', 'track_count', default=0) or 0, } + for key in ('format', 'country', 'status', 'label', 'disambiguation', 'release_group_id'): + value = _extract_lookup_value(album_data, key, default='') + if value: + album_info[key] = value + return album_info def _build_album_track_entry(track_item: Any, album_info: Dict[str, Any], source: str) -> Dict[str, Any]: diff --git a/core/musicbrainz_client.py b/core/musicbrainz_client.py index f1d59491..a802373d 100644 --- a/core/musicbrainz_client.py +++ b/core/musicbrainz_client.py @@ -298,6 +298,41 @@ class MusicBrainzClient: logger.error(f"Error browsing release-groups for artist {artist_mbid}: {e}") return [] + @rate_limited + def browse_release_group_releases(self, release_group_mbid: str, + limit: int = 100, + offset: int = 0) -> List[Dict[str, Any]]: + """Browse concrete releases that belong to a release-group. + + Release-groups identify the logical album; releases identify the + actual edition the user may own (country, format, explicit/clean + disambiguation, bonus tracks, track count). Manual import needs the + latter so users can choose the matching tracklist. + """ + try: + params = { + 'release-group': release_group_mbid, + 'fmt': 'json', + 'limit': min(limit, 100), + 'offset': offset, + 'inc': 'artist-credits+media+labels+release-groups', + } + + response = self.session.get( + f"{self.BASE_URL}/release", + params=params, + timeout=10 + ) + response.raise_for_status() + + data = response.json() + releases = data.get('releases', []) + logger.debug(f"Browsed {len(releases)} releases for release-group {release_group_mbid}") + return releases + except Exception as e: + logger.error(f"Error browsing releases for release-group {release_group_mbid}: {e}") + return [] + @rate_limited def search_recordings_by_artist_mbid(self, artist_mbid: str, limit: int = 100) -> List[Dict[str, Any]]: diff --git a/core/musicbrainz_search.py b/core/musicbrainz_search.py index f01292d4..47e95a84 100644 --- a/core/musicbrainz_search.py +++ b/core/musicbrainz_search.py @@ -57,6 +57,12 @@ class Album: album_type: str image_url: Optional[str] = None external_urls: Optional[Dict[str, str]] = None + format: Optional[str] = None + country: Optional[str] = None + status: Optional[str] = None + label: Optional[str] = None + disambiguation: Optional[str] = None + release_group_id: Optional[str] = None def _cover_art_url(mbid: str, scope: str = 'release') -> Optional[str]: @@ -316,8 +322,102 @@ class MusicBrainzSearchClient: album_type=album_type, image_url=image_url, external_urls={'musicbrainz': f'https://musicbrainz.org/release-group/{rg_mbid}'} if rg_mbid else {}, + disambiguation=rg.get('disambiguation') or None, + release_group_id=rg_mbid or None, ) + def _release_total_tracks(self, release: Dict[str, Any]) -> int: + total_tracks = 0 + for medium in release.get('media', []) or []: + try: + total_tracks += int(medium.get('track-count') or 0) + except (TypeError, ValueError): + pass + return total_tracks + + def _release_formats(self, release: Dict[str, Any]) -> str: + formats = [] + for medium in release.get('media', []) or []: + fmt = (medium.get('format') or '').strip() + if fmt and fmt not in formats: + formats.append(fmt) + return ', '.join(formats) + + def _release_label(self, release: Dict[str, Any]) -> str: + for info in release.get('label-info', []) or []: + label = (info.get('label') or {}) if isinstance(info, dict) else {} + name = (label.get('name') or '').strip() + if name: + return name + return '' + + def _release_to_album(self, release: Dict[str, Any], + fallback_artist_name: Optional[str] = None) -> Optional[Album]: + """Project a concrete MusicBrainz release into our Album dataclass.""" + mbid = release.get('id', '') + title = release.get('title', '') or '' + if not title: + return None + + artists = _extract_artist_credit(release.get('artist-credit', [])) + if not artists and fallback_artist_name: + artists = [fallback_artist_name] + + rg = release.get('release-group', {}) or {} + primary_type = rg.get('primary-type', '') or '' + secondary_types = rg.get('secondary-types', []) or [] + album_type = _map_release_type(primary_type, secondary_types) + rg_mbid = rg.get('id', '') or release.get('release-group-id', '') + image_url = self._cached_art(mbid, rg_mbid) + + return Album( + id=mbid, + name=title, + artists=artists if artists else ['Unknown Artist'], + release_date=release.get('date', '') or '', + total_tracks=self._release_total_tracks(release), + album_type=album_type, + image_url=image_url, + external_urls={'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {}, + format=self._release_formats(release) or None, + country=(release.get('country') or '').strip() or None, + status=(release.get('status') or '').strip() or None, + label=self._release_label(release) or None, + disambiguation=(release.get('disambiguation') or '').strip() or None, + release_group_id=rg_mbid or None, + ) + + def _release_variant_key(self, album: Album): + status_rank = 0 if (album.status or '').lower() == 'official' else 1 + date = (album.release_date or '9999-99-99')[:10] or '9999-99-99' + track_rank = album.total_tracks or 9999 + country_rank = 0 if (album.country or '') in ('XW', 'US', 'GB') else 1 + return ( + status_rank, + date, + country_rank, + track_rank, + album.format or '', + album.disambiguation or '', + album.id, + ) + + def _release_group_releases_to_albums(self, rg: Dict[str, Any], artist_name: str, + limit: int) -> List[Album]: + rg_mbid = rg.get('id', '') + if not rg_mbid: + return [] + + releases = self._client.browse_release_group_releases(rg_mbid, limit=max(limit, 25)) + albums = [] + for release in releases: + release.setdefault('release-group', rg) + album = self._release_to_album(release, fallback_artist_name=artist_name) + if album: + albums.append(album) + albums.sort(key=self._release_variant_key) + return albums[:limit] + def search_albums(self, query: str, limit: int = 10) -> List[Album]: """Search MusicBrainz for releases (albums). @@ -400,6 +500,13 @@ class MusicBrainzSearchClient: matched = [rg for rg in rgs if hint_lower in (rg.get('title') or '').lower()] if matched: rgs = matched + expanded = [] + for rg in rgs: + expanded.extend(self._release_group_releases_to_albums(rg, tname, limit)) + if len(expanded) >= limit: + break + if expanded: + return expanded[:limit] else: fallback = self._search_albums_text(title_hint, tname, limit) if fallback: @@ -436,63 +543,24 @@ class MusicBrainzSearchClient: albums = [] for r in results: - mbid = r.get('id', '') - title = r.get('title', '') - if not title: - continue + album = self._release_to_album(r) + if album: + albums.append(album) - artists = _extract_artist_credit(r.get('artist-credit', [])) - release_date = r.get('date', '') or '' - - # Track count from media - total_tracks = 0 - media = r.get('media', []) - for m in media: - total_tracks += m.get('track-count', 0) - - # Release type - rg = r.get('release-group', {}) - primary_type = rg.get('primary-type', '') or '' - secondary_types = rg.get('secondary-types', []) or [] - album_type = _map_release_type(primary_type, secondary_types) - - # Cover art (non-blocking — skip if slow) - rg_mbid = rg.get('id', '') - image_url = self._cached_art(mbid, rg_mbid) - - external_urls = {'musicbrainz': f'https://musicbrainz.org/release/{mbid}'} if mbid else {} - - albums.append(Album( - id=mbid, - name=title, - artists=artists if artists else ['Unknown Artist'], - release_date=release_date, - total_tracks=total_tracks, - album_type=album_type, - image_url=image_url, - external_urls=external_urls, - )) - # Deduplicate: keep best version of each title+artist combo - # (prefer ones with release dates and cover art) - seen = {} - deduped = [] + # Keep distinct MusicBrainz releases. The same title/artist/date + # can represent explicit, clean, regional, format, or bonus-track + # variants with different tracklists, which manual import must let + # the user choose. + seen_ids = set() + unique = [] for album in albums: - key = (album.name.lower().strip(), ', '.join(album.artists).lower().strip()) - if key not in seen: - seen[key] = album - deduped.append(album) - else: - existing = seen[key] - # Prefer: has date > no date, has art > no art - better = False - if not existing.release_date and album.release_date: - better = True - elif not existing.image_url and album.image_url: - better = True - if better: - deduped[deduped.index(existing)] = album - seen[key] = album - return deduped + if album.id and album.id in seen_ids: + continue + if album.id: + seen_ids.add(album.id) + unique.append(album) + unique.sort(key=self._release_variant_key) + return unique[:limit] except Exception as e: logger.warning(f"MusicBrainz album search failed: {e}") return [] @@ -1030,6 +1098,12 @@ class MusicBrainzSearchClient: 'images': images, 'tracks': tracks, 'external_urls': {'musicbrainz': f'https://musicbrainz.org/release/{release_mbid}'}, + 'format': self._release_formats(release), + 'country': release.get('country') or '', + 'status': release.get('status') or '', + 'label': self._release_label(release), + 'disambiguation': release.get('disambiguation') or '', + 'release_group_id': rg_mbid, } def get_artist_albums(self, artist_mbid: str, album_type: str = 'album,single', limit: int = 200) -> List: diff --git a/tests/imports/test_import_staging.py b/tests/imports/test_import_staging.py index 56571ede..2405a1f3 100644 --- a/tests/imports/test_import_staging.py +++ b/tests/imports/test_import_staging.py @@ -105,6 +105,55 @@ def test_search_import_albums_falls_back_when_primary_has_no_results(monkeypatch assert spotify_client.calls == [("Album Two", {"limit": 2, "allow_fallback": False})] +def test_search_import_albums_preserves_musicbrainz_release_variants(monkeypatch): + musicbrainz_client = FakeClient([ + SimpleNamespace( + id="rel-clean", + name="Shock Value", + artists=["Timbaland"], + release_date="2007-04-03", + total_tracks=17, + image_url="", + album_type="album", + format="CD", + country="US", + status="Official", + disambiguation="clean", + release_group_id="rg-shock", + ), + SimpleNamespace( + id="rel-explicit", + name="Shock Value", + artists=["Timbaland"], + release_date="2007-04-03", + total_tracks=18, + image_url="", + album_type="album", + format="CD", + country="US", + status="Official", + disambiguation="explicit", + release_group_id="rg-shock", + ), + ]) + + monkeypatch.setattr(import_staging, "get_primary_source", lambda: "musicbrainz") + monkeypatch.setattr(import_staging, "get_source_priority", lambda primary: [primary]) + monkeypatch.setattr(import_staging, "get_client_for_source", lambda source: musicbrainz_client) + monkeypatch.setattr( + import_staging, + "_search_albums_for_source", + lambda source, client, query, limit=5: client.search_albums(query, limit=limit), + ) + + results = import_staging.search_import_albums("Timbaland Shock Value", limit=12) + + assert [result["id"] for result in results] == ["rel-clean", "rel-explicit"] + assert [result["total_tracks"] for result in results] == [17, 18] + assert results[1]["disambiguation"] == "explicit" + assert results[1]["release_group_id"] == "rg-shock" + + def test_search_import_tracks_prefers_primary_source(monkeypatch): deezer_client = FakeClient([ SimpleNamespace( diff --git a/tests/metadata/test_musicbrainz_search.py b/tests/metadata/test_musicbrainz_search.py index cec77648..e5859707 100644 --- a/tests/metadata/test_musicbrainz_search.py +++ b/tests/metadata/test_musicbrainz_search.py @@ -429,6 +429,58 @@ def test_search_albums_text_path_filters_by_score(): assert 'Bad' not in titles +def test_search_albums_text_path_keeps_release_variants(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_release.return_value = [ + {'id': 'rel-clean', 'title': 'Shock Value', 'score': 100, + 'date': '2007-04-03', 'country': 'US', 'status': 'Official', + 'disambiguation': 'clean', + 'media': [{'format': 'CD', 'track-count': 17}], + 'release-group': {'id': 'rg-shock', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'Timbaland'}]}, + {'id': 'rel-explicit', 'title': 'Shock Value', 'score': 100, + 'date': '2007-04-03', 'country': 'US', 'status': 'Official', + 'disambiguation': 'explicit', + 'media': [{'format': 'CD', 'track-count': 18}], + 'release-group': {'id': 'rg-shock', 'primary-type': 'Album'}, + 'artist-credit': [{'name': 'Timbaland'}]}, + ] + + albums = client.search_albums('Timbaland - Shock Value', limit=10) + + assert [a.id for a in albums] == ['rel-clean', 'rel-explicit'] + assert [a.total_tracks for a in albums] == [17, 18] + assert albums[1].disambiguation == 'explicit' + + +def test_search_albums_title_hint_expands_release_group_to_releases(): + client = MusicBrainzSearchClient() + client._client = MagicMock() + client._client.search_artist.return_value = [_mk_artist('Spiderbait', 'artist-spiderbait', score=100)] + client._client.browse_artist_release_groups.return_value = [ + {'id': 'rg-tonight', 'title': 'Tonight Alright', 'primary-type': 'Album', + 'first-release-date': '2004-03-29', 'secondary-types': []}, + ] + client._client.browse_release_group_releases.return_value = [ + {'id': 'rel-cd', 'title': 'Tonight Alright', 'date': '2004-03-29', + 'country': 'AU', 'status': 'Official', + 'media': [{'format': 'CD', 'track-count': 12}], + 'artist-credit': [{'name': 'Spiderbait'}]}, + {'id': 'rel-vinyl', 'title': 'Tonight Alright', 'date': '2024-07-26', + 'country': 'AU', 'status': 'Official', + 'media': [{'format': '12\" Vinyl', 'track-count': 13}], + 'artist-credit': [{'name': 'Spiderbait'}]}, + ] + + albums = client.search_albums('Spiderbait Tonight Alright', limit=10) + + client._client.browse_release_group_releases.assert_called_once_with('rg-tonight', limit=25) + assert [a.id for a in albums] == ['rel-cd', 'rel-vinyl'] + assert [a.total_tracks for a in albums] == [12, 13] + assert albums[0].format == 'CD' + + # --------------------------------------------------------------------------- # Track search — routing # --------------------------------------------------------------------------- diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 74872897..89b8d028 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -650,11 +650,23 @@ function _renderSuggestionCard(a, primarySource) { const sourceBadge = (a.source && primarySource && a.source !== primarySource) ? `
via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}
` : ''; + const metaParts = [ + `${a.total_tracks || 0} tracks`, + a.release_date ? a.release_date.substring(0, 4) : '', + a.format || '', + a.country || '', + a.disambiguation || '', + ].filter(Boolean); + const details = [a.status || '', a.label || ''].filter(Boolean); + const detailsLine = details.length + ? `
${_esc(details.join(' · '))}
` + : ''; return `
${_escAttr(a.name)}
${_esc(a.name)}
${_esc(a.artist)}
-
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
+
${_esc(metaParts.join(' · '))}
+ ${detailsLine} ${sourceBadge}
`; } @@ -744,12 +756,19 @@ async function importPageSelectAlbum(albumId) { // Render hero const album = data.album; + const heroMetaParts = [ + `${album.total_tracks || 0} tracks`, + album.release_date ? album.release_date.substring(0, 4) : '', + album.format || '', + album.country || '', + album.disambiguation || '', + ].filter(Boolean); document.getElementById('import-page-album-hero').innerHTML = ` ${_escAttr(album.name)}
${_esc(album.name)}
${_esc(album.artist)}
-
${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0, 4) : ''}
+
${_esc(heroMetaParts.join(' · '))}
`; diff --git a/webui/static/style.css b/webui/static/style.css index 728d3e95..061fbbae 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -40322,6 +40322,18 @@ div.artist-hero-badge { font-size: 10px; color: rgba(255, 255, 255, 0.3); margin-top: 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.import-page-album-card-detail { + font-size: 10px; + color: rgba(255, 255, 255, 0.36); + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .import-page-album-card-source { From 9a0e3b4011cbc1ad88208976a2c37b50baaa9638 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 10:02:00 -0700 Subject: [PATCH 05/42] Persist completed downloads in downloads view Include a capped recent tail of database-backed download history in the unified Downloads page so completed Deezer and other streaming downloads remain visible after runtime tasks are cleaned up or the container restarts. Use persistent download history for the dashboard finished count, keep live tasks authoritative for active rows, avoid showing the local clear-completed action for persisted history rows, and cover history hydration/deduping/capping in status tests. --- core/downloads/status.py | 84 +++++++++++++++++++++++ tests/downloads/test_downloads_status.py | 87 ++++++++++++++++++++++++ web_server.py | 18 ++++- webui/static/api-monitor.js | 2 +- webui/static/core.js | 2 +- webui/static/pages-extra.js | 4 +- 6 files changed, 191 insertions(+), 6 deletions(-) diff --git a/core/downloads/status.py b/core/downloads/status.py index 6e11a763..37ae441d 100644 --- a/core/downloads/status.py +++ b/core/downloads/status.py @@ -24,6 +24,7 @@ import logging import threading import time from dataclasses import dataclass +from datetime import datetime from typing import Any, Callable, Optional from core.runtime_state import ( @@ -82,6 +83,7 @@ class StatusDeps: download_orchestrator: Any = None run_async: Optional[Callable] = None on_download_completed: Optional[Callable[[str, str, bool], None]] = None + get_persistent_download_history: Optional[Callable[[int], list[dict]]] = None # Streaming sources the engine fallback applies to. Soulseek goes through @@ -536,6 +538,69 @@ _STATUS_PRIORITY = { 'not_found': 6, 'failed': 7, 'cancelled': 8, } +_PERSISTENT_HISTORY_TAIL_LIMIT = 50 + + +def _normalize_identity_part(value: Any) -> str: + return str(value or '').strip().casefold() + + +def _download_identity(title: Any, artist: Any, album: Any) -> tuple[str, str, str]: + return ( + _normalize_identity_part(title), + _normalize_identity_part(artist), + _normalize_identity_part(album), + ) + + +def _history_timestamp(value: Any) -> float: + if not value: + return 0.0 + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip() + if not text: + return 0.0 + try: + # SQLite CURRENT_TIMESTAMP uses "YYYY-MM-DD HH:MM:SS". + return datetime.fromisoformat(text.replace('Z', '+00:00')).timestamp() + except ValueError: + try: + return datetime.strptime(text[:19], '%Y-%m-%d %H:%M:%S').timestamp() + except ValueError: + return 0.0 + + +def _build_history_download_item(entry: dict) -> dict: + history_id = entry.get('id') or entry.get('history_id') or '' + title = entry.get('title') or entry.get('source_track_title') or '' + artist = entry.get('artist_name') or entry.get('source_artist') or '' + album = entry.get('album_name') or '' + created_at = entry.get('created_at') or entry.get('completed_at') or '' + source = entry.get('download_source') or '' + return { + 'task_id': f'history-{history_id}' if history_id else f'history-{title}-{created_at}', + 'title': title, + 'artist': artist, + 'album': album, + 'artwork': entry.get('thumb_url') or '', + 'status': 'completed', + 'progress': 100, + 'error': None, + 'batch_id': '', + 'batch_name': source, + 'batch_source': source, + 'playlist_id': '', + 'track_index': 0, + 'batch_total': 1, + 'timestamp': _history_timestamp(created_at), + 'created_at': created_at, + 'priority': _STATUS_PRIORITY['completed'], + 'quality': entry.get('quality') or '', + 'file_path': entry.get('file_path') or '', + 'is_persistent_history': True, + } + def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: """Flat list of every task across batches, sorted active-first then by recency. @@ -543,6 +608,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: Powers /api/downloads/all for the centralized Downloads page. """ items = [] + live_identities = set() with tasks_lock: for task_id, task in download_tasks.items(): track_info = task.get('track_info') or {} @@ -589,6 +655,7 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: artwork = images[0].get('url', '') if isinstance(images[0], dict) else str(images[0]) status = task.get('status', 'queued') + live_identities.add(_download_identity(title, artist, album)) # Determine download progress percentage progress = 0 if status == 'completed': @@ -625,8 +692,25 @@ def build_unified_downloads_response(limit: int, deps: StatusDeps) -> dict: 'batch_total': len(batch.get('queue', [])), 'timestamp': task.get('status_change_time', 0), 'priority': _STATUS_PRIORITY.get(status, 9), + 'is_persistent_history': False, }) + if deps.get_persistent_download_history is not None and len(items) < limit: + history_limit = min(limit - len(items), _PERSISTENT_HISTORY_TAIL_LIMIT) + try: + history_entries = deps.get_persistent_download_history(history_limit) or [] + except Exception as exc: + logger.debug("[Downloads] persistent history lookup failed: %s", exc) + history_entries = [] + + for entry in history_entries: + item = _build_history_download_item(entry) + identity = _download_identity(item.get('title'), item.get('artist'), item.get('album')) + if identity in live_identities: + continue + items.append(item) + live_identities.add(identity) + # Sort: active first (by priority), then by timestamp desc within each group items.sort(key=lambda x: (x['priority'], -x['timestamp'])) diff --git a/tests/downloads/test_downloads_status.py b/tests/downloads/test_downloads_status.py index 706f9131..64d41b3d 100644 --- a/tests/downloads/test_downloads_status.py +++ b/tests/downloads/test_downloads_status.py @@ -42,6 +42,7 @@ def _build_deps( cached_transfers=None, download_orchestrator=None, run_async=None, + persistent_history=None, ): submitted = [] @@ -57,6 +58,7 @@ def _build_deps( get_cached_transfer_data=cached_transfers or (lambda: {}), download_orchestrator=download_orchestrator, run_async=run_async, + get_persistent_download_history=persistent_history, ) return deps, submitted @@ -655,3 +657,88 @@ def test_unified_response_respects_limit(): out = st.build_unified_downloads_response(5, deps) assert len(out['downloads']) == 5 assert out['total'] == 20 # total still reflects all + + +def test_unified_response_includes_persistent_download_history(): + deps, _ = _build_deps( + persistent_history=lambda limit: [ + { + 'id': 42, + 'title': 'Persisted Track', + 'artist_name': 'Deezer Artist', + 'album_name': 'Persistent Album', + 'thumb_url': 'http://cover.jpg', + 'download_source': 'Deezer', + 'quality': 'FLAC', + 'created_at': '2026-05-24 12:34:56', + } + ] + ) + + out = st.build_unified_downloads_response(100, deps) + + assert out['total'] == 1 + item = out['downloads'][0] + assert item['task_id'] == 'history-42' + assert item['title'] == 'Persisted Track' + assert item['artist'] == 'Deezer Artist' + assert item['album'] == 'Persistent Album' + assert item['artwork'] == 'http://cover.jpg' + assert item['status'] == 'completed' + assert item['progress'] == 100 + assert item['batch_name'] == 'Deezer' + assert item['is_persistent_history'] is True + + +def test_unified_response_dedupes_history_against_live_task(): + download_tasks['live'] = { + 'track_index': 0, + 'status': 'completed', + 'track_info': { + 'name': 'Same Track', + 'artist': 'Same Artist', + 'album': 'Same Album', + }, + } + deps, _ = _build_deps( + persistent_history=lambda limit: [ + { + 'id': 7, + 'title': 'Same Track', + 'artist_name': 'Same Artist', + 'album_name': 'Same Album', + 'created_at': '2026-05-24 12:34:56', + } + ] + ) + + out = st.build_unified_downloads_response(100, deps) + + assert len(out['downloads']) == 1 + assert out['downloads'][0]['task_id'] == 'live' + assert out['downloads'][0]['is_persistent_history'] is False + + +def test_unified_response_caps_persistent_history_tail(): + requested_limits = [] + + def _history(limit): + requested_limits.append(limit) + return [ + { + 'id': i, + 'title': f'Track {i}', + 'artist_name': 'Artist', + 'album_name': 'Album', + 'created_at': '2026-05-24 12:34:56', + } + for i in range(limit + 10) + ] + + deps, _ = _build_deps(persistent_history=_history) + + out = st.build_unified_downloads_response(300, deps) + + assert requested_limits == [50] + assert len(out['downloads']) == 50 + assert out['total'] == 50 diff --git a/web_server.py b/web_server.py index f3cad76f..993cda4a 100644 --- a/web_server.py +++ b/web_server.py @@ -2557,9 +2557,16 @@ def _build_system_stats(): active_downloads = len([batch_id for batch_id, batch_data in download_batches.items() if batch_data.get('phase') == 'downloading']) - # Count finished downloads (completed this session) - use session counter like dashboard.py - with session_stats_lock: - finished_downloads = session_completed_downloads + # Count finished downloads from persistent history so the dashboard + # survives Docker/container restarts and streaming downloads that leave + # the in-memory task tracker after post-processing. + try: + persistent_finished = int(get_database().get_library_history_stats().get('downloads', 0) or 0) + with session_stats_lock: + finished_downloads = max(persistent_finished, session_completed_downloads) + except Exception: + with session_stats_lock: + finished_downloads = session_completed_downloads # Calculate total download speed from active soulseek transfers # Skip the slskd API call entirely when Soulseek is not the active download @@ -16928,6 +16935,11 @@ def _build_status_deps(): download_orchestrator=download_orchestrator, run_async=run_async, on_download_completed=_on_download_completed, + get_persistent_download_history=lambda limit: get_database().get_library_history( + event_type='download', + page=1, + limit=limit, + )[0], ) diff --git a/webui/static/api-monitor.js b/webui/static/api-monitor.js index d53d0667..75c3f491 100644 --- a/webui/static/api-monitor.js +++ b/webui/static/api-monitor.js @@ -568,7 +568,7 @@ async function fetchAndUpdateSystemStats() { // Update all stat cards updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); - updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); + updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); diff --git a/webui/static/core.js b/webui/static/core.js index 9a1bd1e4..2ba0d6b2 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -628,7 +628,7 @@ function unsubscribeFromDownloadBatch(batchId) { function handleDashboardStats(data) { // Same logic as fetchAndUpdateSystemStats response handler updateStatCard('active-downloads-card', data.active_downloads, 'Currently downloading'); - updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed this session'); + updateStatCard('finished-downloads-card', data.finished_downloads, 'Completed downloads'); updateStatCard('download-speed-card', data.download_speed, 'Combined speed'); updateStatCard('active-syncs-card', data.active_syncs, 'Playlists syncing'); updateStatCard('uptime-card', data.uptime, 'Application runtime'); diff --git a/webui/static/pages-extra.js b/webui/static/pages-extra.js index 47a65710..a10a7df5 100644 --- a/webui/static/pages-extra.js +++ b/webui/static/pages-extra.js @@ -2278,7 +2278,9 @@ function _adlRender() { else if (_adlFilter === 'completed') filtered = filtered.filter(d => completedStatuses.includes(d.status)); else if (_adlFilter === 'failed') filtered = filtered.filter(d => failedStatuses.includes(d.status)); - const completedN = _adlData.filter(d => [...completedStatuses, ...failedStatuses].includes(d.status)).length; + const completedN = _adlData.filter(d => + [...completedStatuses, ...failedStatuses].includes(d.status) && !d.is_persistent_history + ).length; if (countEl) { const activeN = _adlData.filter(d => activeStatuses.includes(d.status)).length; From ccbe91880870a42d1fdced2d80688b0506da2fff Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 10:46:21 -0700 Subject: [PATCH 06/42] Unify artist detail action buttons Move the artist watchlist and discography actions into the main artist hero action row so they sit with Artist Radio and Enhance Quality. Apply a shared compact pill treatment for the hero actions while preserving the existing button IDs and click behavior. --- webui/index.html | 22 ++++----- webui/static/style.css | 110 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/webui/index.html b/webui/index.html index d590d158..2486ed4b 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2435,10 +2435,6 @@ ← Back -
@@ -2466,6 +2462,17 @@ 📻 Artist Radio + +
-
diff --git a/webui/static/style.css b/webui/static/style.css index 061fbbae..7d7a71a4 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -45706,6 +45706,10 @@ textarea.enhanced-meta-field-input { .discog-download-wrap { margin: 12px 0 4px; } +.artist-hero-actions .discog-download-wrap { margin: 0; } + +.artist-hero-actions .discog-btn-compact { margin-top: 0; } + .discog-download-btn { position: relative; display: flex; @@ -45753,6 +45757,112 @@ textarea.enhanced-meta-field-input { animation: discogShimmer 3s ease-in-out infinite; } +.artist-hero-actions { + gap: 10px; + align-items: center; +} + +.artist-hero-actions .library-artist-radio-btn, +.artist-hero-actions .library-artist-watchlist-btn, +.artist-hero-actions .discog-download-btn, +.artist-hero-actions .library-artist-enhance-btn { + --artist-action-rgb: var(--accent-rgb); + min-height: 38px; + width: auto; + max-width: none; + padding: 8px 14px 8px 12px; + gap: 8px; + border-radius: 999px; + color: rgba(255, 255, 255, 0.82); + background: + linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.13), rgba(var(--artist-action-rgb), 0.03)), + rgba(255, 255, 255, 0.035); + border: 1px solid rgba(var(--artist-action-rgb), 0.26); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.08), + 0 8px 22px rgba(0, 0, 0, 0.22); + font-size: 12px; + font-weight: 650; + letter-spacing: 0; + line-height: 1; + backdrop-filter: blur(14px) saturate(1.25); + transform: none; +} + +.artist-hero-actions .library-artist-radio-btn { --artist-action-rgb: 79, 195, 247; } +.artist-hero-actions .library-artist-watchlist-btn { --artist-action-rgb: 255, 193, 7; } +.artist-hero-actions .discog-download-btn { --artist-action-rgb: var(--accent-rgb); } +.artist-hero-actions .library-artist-enhance-btn { --artist-action-rgb: 180, 132, 255; } + +.artist-hero-actions .library-artist-radio-btn::before, +.artist-hero-actions .library-artist-watchlist-btn::before, +.artist-hero-actions .library-artist-enhance-btn::before, +.artist-hero-actions .discog-download-btn::before { + content: ''; + width: 7px; + height: 7px; + border-radius: 999px; + position: relative; + inset: auto; + flex: 0 0 auto; + opacity: 1; + background: rgb(var(--artist-action-rgb)); + box-shadow: 0 0 14px rgba(var(--artist-action-rgb), 0.85); + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.artist-hero-actions .library-artist-radio-btn:hover, +.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled), +.artist-hero-actions .discog-download-btn:hover, +.artist-hero-actions .library-artist-enhance-btn:hover { + color: #ffffff; + border-color: rgba(var(--artist-action-rgb), 0.5); + background: + linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.22), rgba(var(--artist-action-rgb), 0.06)), + rgba(255, 255, 255, 0.055); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.12), + 0 12px 28px rgba(0, 0, 0, 0.28), + 0 0 0 1px rgba(var(--artist-action-rgb), 0.12); + transform: translateY(-1px); +} + +.artist-hero-actions .library-artist-radio-btn:hover::before, +.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled)::before, +.artist-hero-actions .discog-download-btn:hover::before, +.artist-hero-actions .library-artist-enhance-btn:hover::before { + transform: scale(1.25); + box-shadow: 0 0 18px rgba(var(--artist-action-rgb), 1); +} + +.artist-hero-actions .library-artist-watchlist-btn.watching { + color: #ffd761; + border-color: rgba(255, 193, 7, 0.52); + background: + linear-gradient(135deg, rgba(255, 193, 7, 0.24), rgba(255, 193, 7, 0.06)), + rgba(255, 255, 255, 0.04); +} + +.artist-hero-actions .library-artist-radio-btn .radio-icon, +.artist-hero-actions .library-artist-watchlist-btn .watchlist-icon, +.artist-hero-actions .discog-download-btn .discog-btn-icon, +.artist-hero-actions .library-artist-enhance-btn .enhance-icon { + font-size: 13px; + position: relative; + z-index: 1; + transform: none; +} + +.artist-hero-actions .library-artist-radio-btn:hover .radio-icon, +.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon, +.artist-hero-actions .library-artist-enhance-btn:hover .enhance-icon { + transform: none; +} + +.artist-hero-actions .discog-btn-shimmer { + display: none; +} + @keyframes discogShimmer { 0%, 100% { left: -100%; } 50% { left: 150%; } From a3ba79a9ce245974f25410a22ff0c7bded8ae6f4 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Sun, 24 May 2026 11:02:19 -0700 Subject: [PATCH 07/42] Improve radio mode UI and behavior Refactor and enhance the player radio feature: add npSetRadioMode, npQueueHasNext, and npEnsureCurrentTrackInQueue helpers to centralize radio-state changes and conditional radio fetch logic; replace direct npRadioMode toggles with npSetRadioMode in the expanded player and artist-radio flow (now awaits playLibraryTrack and triggers fetchIfNeeded). Add accessibility (aria-pressed) and label/pulse elements to the radio button, and update CSS for improved visuals and active-state animation. Also adjust toasts/messages and ensure the current library track is seeded into the queue when needed. --- webui/index.html | 4 +- webui/static/media-player.js | 81 +++++++++++++++++++------------ webui/static/stats-automations.js | 16 ++---- webui/static/style.css | 54 ++++++++++++++++----- 4 files changed, 99 insertions(+), 56 deletions(-) diff --git a/webui/index.html b/webui/index.html index 2486ed4b..43b1e265 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7174,8 +7174,10 @@
-
diff --git a/webui/static/media-player.js b/webui/static/media-player.js index fd7eb526..cec97aba 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -1383,6 +1383,54 @@ let npMediaSource = null; let npVizAnimFrame = null; let npVizInitialized = false; +function npQueueHasNext() { + if (npQueue.length === 0) return false; + return npShuffleOn + ? npQueue.length > 1 + : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'); +} + +function npEnsureCurrentTrackInQueue() { + if (!currentTrack || !currentTrack.is_library || npQueue.length > 0) return; + npQueue.push({ + title: currentTrack.title, + artist: currentTrack.artist, + album: currentTrack.album, + file_path: currentTrack.filename || currentTrack.file_path, + filename: currentTrack.filename || currentTrack.file_path, + is_library: true, + image_url: currentTrack.image_url, + id: currentTrack.id, + artist_id: currentTrack.artist_id, + album_id: currentTrack.album_id, + bitrate: currentTrack.bitrate, + sample_rate: currentTrack.sample_rate + }); + npQueueIndex = 0; + renderNpQueue(); + updateNpPrevNextButtons(); +} + +function npSetRadioMode(enabled, options = {}) { + const { toast = true, fetchIfNeeded = false } = options; + npRadioMode = Boolean(enabled); + const radioBtn = document.getElementById('np-radio-btn'); + if (radioBtn) { + radioBtn.classList.toggle('active', npRadioMode); + radioBtn.setAttribute('aria-pressed', npRadioMode ? 'true' : 'false'); + radioBtn.title = npRadioMode + ? 'Radio mode on - similar tracks will auto-queue' + : 'Radio mode - auto-add similar tracks'; + } + if (toast) { + showToast(npRadioMode ? 'Radio mode on - similar tracks will auto-queue' : 'Radio mode off', 'success'); + } + if (npRadioMode && fetchIfNeeded && currentTrack && currentTrack.id && !npLoadingQueueItem && !npQueueHasNext()) { + npEnsureCurrentTrackInQueue(); + npFetchRadioTracks(); + } +} + function initExpandedPlayer() { const closeBtn = document.getElementById('np-close-btn'); const overlay = document.getElementById('np-modal-overlay'); @@ -1462,40 +1510,9 @@ function initExpandedPlayer() { const radioBtn = document.getElementById('np-radio-btn'); if (radioBtn) { radioBtn.addEventListener('click', () => { - npRadioMode = !npRadioMode; - radioBtn.classList.toggle('active', npRadioMode); - showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success'); - // Immediately fetch radio tracks if turned on while playing with empty/exhausted queue - if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) { - const hasNext = npQueue.length > 0 && (npShuffleOn - ? npQueue.length > 1 - : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all')); - if (!hasNext) { - // Add current track to queue first so it appears as "now playing" in context - if (npQueue.length === 0 && currentTrack.is_library) { - npQueue.push({ - title: currentTrack.title, - artist: currentTrack.artist, - album: currentTrack.album, - file_path: currentTrack.filename || currentTrack.file_path, - filename: currentTrack.filename || currentTrack.file_path, - is_library: true, - image_url: currentTrack.image_url, - id: currentTrack.id, - artist_id: currentTrack.artist_id, - album_id: currentTrack.album_id, - bitrate: currentTrack.bitrate - }); - npQueueIndex = 0; - renderNpQueue(); - updateNpPrevNextButtons(); - } - npFetchRadioTracks(); - } - } + npSetRadioMode(!npRadioMode, { fetchIfNeeded: true }); }); } - // Action link (Go to Artist) const gotoArtistBtn = document.getElementById('np-goto-artist'); if (gotoArtistBtn) { diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 89b8d028..68772b35 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -6056,15 +6056,14 @@ async function playArtistRadio() { const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; // Clear existing queue and disable radio before starting fresh - npRadioMode = false; + npSetRadioMode(false, { toast: false }); clearQueue(); if (audioPlayer && !audioPlayer.paused) { audioPlayer.pause(); } - // Play the track first, then enable radio mode after a short delay - // so currentTrack is set and the radio queue fill triggers - playLibraryTrack({ + // Play the track first so currentTrack is populated before radio seeds the queue. + await playLibraryTrack({ id: random.track.id, title: random.track.title, file_path: random.track.file_path, @@ -6073,14 +6072,9 @@ async function playArtistRadio() { album_id: random.album.id, }, random.album.title || '', artistName); - // Enable radio mode after track starts loading - setTimeout(() => { - npRadioMode = true; - const radioBtn = document.querySelector('.np-radio-btn'); - if (radioBtn) radioBtn.classList.add('active'); - }, 1000); + npSetRadioMode(true, { toast: false, fetchIfNeeded: true }); - showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); + showToast(`Playing ${artistName} radio - similar tracks will auto-queue`, 'success'); } catch (e) { showToast(`Failed to start artist radio: ${e.message}`, 'error'); } diff --git a/webui/static/style.css b/webui/static/style.css index 7d7a71a4..ea65ca7f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -47281,26 +47281,56 @@ textarea.enhanced-meta-field-input { /* Radio mode button */ .np-radio-btn { - background: none; - border: 1px solid rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.35); + --np-radio-rgb: 79, 195, 247; + position: relative; + overflow: hidden; + background: + linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.1), rgba(var(--np-radio-rgb), 0.025)), + rgba(255, 255, 255, 0.035); + border: 1px solid rgba(var(--np-radio-rgb), 0.22); + color: rgba(255, 255, 255, 0.62); font-size: 11px; - padding: 3px 8px; - border-radius: 6px; + font-weight: 650; + line-height: 1; + padding: 7px 10px; + border-radius: 999px; cursor: pointer; display: flex; align-items: center; - gap: 4px; - transition: all 0.15s ease; + gap: 6px; + min-height: 30px; + transition: all 0.18s ease; } .np-radio-btn:hover { - color: rgba(255, 255, 255, 0.7); - border-color: rgba(255, 255, 255, 0.2); + color: rgba(255, 255, 255, 0.9); + border-color: rgba(var(--np-radio-rgb), 0.42); + background: + linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.16), rgba(var(--np-radio-rgb), 0.04)), + rgba(255, 255, 255, 0.055); } .np-radio-btn.active { - color: rgb(var(--accent-rgb)); - border-color: rgba(var(--accent-rgb), 0.3); - background: rgba(var(--accent-rgb), 0.08); + color: #ffffff; + border-color: rgba(var(--np-radio-rgb), 0.55); + background: + linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.28), rgba(var(--np-radio-rgb), 0.07)), + rgba(255, 255, 255, 0.06); + box-shadow: 0 0 0 1px rgba(var(--np-radio-rgb), 0.08), 0 0 18px rgba(var(--np-radio-rgb), 0.12); +} +.np-radio-label { + position: relative; + z-index: 1; +} +.np-radio-pulse { + width: 6px; + height: 6px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.24); + box-shadow: none; + transition: all 0.18s ease; +} +.np-radio-btn.active .np-radio-pulse { + background: rgb(var(--np-radio-rgb)); + box-shadow: 0 0 12px rgba(var(--np-radio-rgb), 0.95); } .np-queue-header-actions { display: flex; From ff4c5562572b3a7ce801ce86d69d10c3e9fbd5b2 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 16:45:19 +0300 Subject: [PATCH 08/42] feat(webui): migrate stats page to react - move the stats route onto the React shell with Recharts-based visualizations - remove the global Chart.js include and add a local stats seed script for easier testing - keep parity coverage with route, API, and helper tests while preserving the legacy page layout --- webui/src/platform/shell/globals.d.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 8b043665..954686b8 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -62,6 +62,28 @@ declare global { showLoadingOverlay: (message?: string) => void; hideLoadingOverlay: () => void; }; + navigateToArtistDetail?: ( + artistId: string | number, + artistName: string, + sourceOverride?: string | null, + options?: Record, + ) => void; + playLibraryTrack?: ( + track: { + id: string | number; + title: string; + file_path: string; + bitrate?: string | number | null; + artist_id?: string | number | null; + album_id?: string | number | null; + _stats_image?: string | null; + }, + albumTitle: string, + artistName: string, + ) => void | Promise; + startStream?: (searchResult: Record) => void | Promise; + showLoadingOverlay?: (message?: string) => void; + hideLoadingOverlay?: () => void; } } From 5680e52ceb929f9cb31c149adcd69ddebeb9de20 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Thu, 14 May 2026 17:15:25 +0300 Subject: [PATCH 09/42] refactor(webui): route stats interop through shell bridge - move stats route legacy handoffs onto explicit SoulSyncWebShellBridge methods\n- stop relying on ad hoc window globals from React code for artist navigation and playback\n- update shell bridge tests and route test doubles to enforce the expanded bridge contract --- webui/src/platform/shell/globals.d.ts | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 954686b8..8b043665 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -62,28 +62,6 @@ declare global { showLoadingOverlay: (message?: string) => void; hideLoadingOverlay: () => void; }; - navigateToArtistDetail?: ( - artistId: string | number, - artistName: string, - sourceOverride?: string | null, - options?: Record, - ) => void; - playLibraryTrack?: ( - track: { - id: string | number; - title: string; - file_path: string; - bitrate?: string | number | null; - artist_id?: string | number | null; - album_id?: string | number | null; - _stats_image?: string | null; - }, - albumTitle: string, - artistName: string, - ) => void | Promise; - startStream?: (searchResult: Record) => void | Promise; - showLoadingOverlay?: (message?: string) => void; - hideLoadingOverlay?: () => void; } } From 27fbc80e7a703db9bed52254d23dab803064232d Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 11:13:12 +0300 Subject: [PATCH 10/42] feat(webui): migrate import route to React - Move import page, tabs, workflow state, and route tests into React-owned route slices - Preserve shell gating, staging queries, album matching, singles matching, auto-import, and queue behavior - Add migration plan snapshot so cleanup/refinement can build on a stable baseline --- webui/docs/migration/README.md | 3 + webui/docs/migration/import-migration-plan.md | 315 ++++ .../docs/migration/page-migration-overview.md | 20 +- webui/index.html | 153 -- webui/package-lock.json | 32 +- webui/package.json | 3 +- webui/src/platform/shell/globals.d.ts | 7 + .../src/platform/shell/route-manifest.test.ts | 10 +- webui/src/platform/shell/route-manifest.ts | 8 +- webui/src/routeTree.gen.ts | 131 +- webui/src/routes/import/-import.api.ts | 293 +++ webui/src/routes/import/-import.helpers.ts | 286 +++ webui/src/routes/import/-import.store.ts | 236 +++ webui/src/routes/import/-import.types.ts | 226 +++ webui/src/routes/import/-route.test.tsx | 272 +++ .../routes/import/-ui/album-import-tab.tsx | 581 ++++++ .../src/routes/import/-ui/auto-import-tab.tsx | 580 ++++++ .../routes/import/-ui/import-page.module.css | 1645 +++++++++++++++++ webui/src/routes/import/-ui/import-page.tsx | 184 ++ webui/src/routes/import/-ui/import-shared.tsx | 106 ++ .../routes/import/-ui/singles-import-tab.tsx | 330 ++++ webui/src/routes/import/album.tsx | 15 + webui/src/routes/import/auto.tsx | 27 + webui/src/routes/import/index.tsx | 7 + webui/src/routes/import/route.tsx | 20 + webui/src/routes/import/singles.tsx | 7 + 26 files changed, 5326 insertions(+), 171 deletions(-) create mode 100644 webui/docs/migration/import-migration-plan.md create mode 100644 webui/src/routes/import/-import.api.ts create mode 100644 webui/src/routes/import/-import.helpers.ts create mode 100644 webui/src/routes/import/-import.store.ts create mode 100644 webui/src/routes/import/-import.types.ts create mode 100644 webui/src/routes/import/-route.test.tsx create mode 100644 webui/src/routes/import/-ui/album-import-tab.tsx create mode 100644 webui/src/routes/import/-ui/auto-import-tab.tsx create mode 100644 webui/src/routes/import/-ui/import-page.module.css create mode 100644 webui/src/routes/import/-ui/import-page.tsx create mode 100644 webui/src/routes/import/-ui/import-shared.tsx create mode 100644 webui/src/routes/import/-ui/singles-import-tab.tsx create mode 100644 webui/src/routes/import/album.tsx create mode 100644 webui/src/routes/import/auto.tsx create mode 100644 webui/src/routes/import/index.tsx create mode 100644 webui/src/routes/import/route.tsx create mode 100644 webui/src/routes/import/singles.tsx diff --git a/webui/docs/migration/README.md b/webui/docs/migration/README.md index a7a9d5e3..f54442a1 100644 --- a/webui/docs/migration/README.md +++ b/webui/docs/migration/README.md @@ -16,6 +16,9 @@ This folder is the home for React migration planning work inside `webui`. - cross-route risk assessment - [stats-migration-plan.md](./stats-migration-plan.md) - route-specific migration plan for `stats` +- [import-migration-plan.md](./import-migration-plan.md) + - route-specific migration plan for `import` + - implementation status and follow-up cleanup notes ## Naming Guidance diff --git a/webui/docs/migration/import-migration-plan.md b/webui/docs/migration/import-migration-plan.md new file mode 100644 index 00000000..9a5e4013 --- /dev/null +++ b/webui/docs/migration/import-migration-plan.md @@ -0,0 +1,315 @@ +# WebUI Import Migration Plan + +Snapshot date: 2026-05-15 + +## Status + +- Initial implementation completed on 2026-05-15. +- `import` is now React-owned in the shell route manifest. +- The legacy import page DOM has been removed from `webui/index.html`. +- Legacy import activation has been removed from `webui/static/init.js`. +- A React route slice now owns import rendering, tab state, album matching, singles matching, auto-import controls, and the client-side processing queue. +- The old import-specific functions still exist at the top of `webui/static/stats-automations.js` as dead code and can be removed in a focused cleanup pass. +- Backend routes are already grouped around `/api/import/*` and `/api/auto-import/*`. + +## Goal + +- Migrate `import` into a React-owned route without changing the user workflow. +- Preserve manual album matching, singles matching, auto-import review, and the processing queue. +- Keep staging-folder and import-processing behavior backed by the existing API routes. +- Use the completed `issues` and `stats` routes as the structural reference for route slices, API helpers, shell gating, and tests. + +## Why `import` Was The Right Next Route + +- It is the safest remaining route after excluding `help` and `hydrabase`. +- It has real workflows, so it gives the migration program more signal than another mostly-static page. +- The backend API boundary is already clearer than the broad dashboard, library, discover, sync, or settings surfaces. +- The page is important enough to validate mutation, polling, and route-local reducer patterns before larger operational pages. +- It does not need a visual redesign or a new shell abstraction to migrate cleanly. + +## Current Legacy Shape + +Page surface in `webui/index.html`: + +- Header + - import folder path + - file count and total size + - refresh action +- Processing queue + - per-job progress + - partial error display + - clear-finished action +- Tabs + - auto-import + - albums + - singles +- Auto-import tab + - enable toggle + - status text + - confidence and interval settings + - scan-now action + - live scan progress + - result filters + - approve, reject, approve-all, and clear-completed actions +- Albums tab + - auto-group suggestions from staging + - album search + - album result cards + - track matching view + - drag/drop and tap-to-assign overrides + - unmatched file pool + - process album action +- Singles tab + - staging file list + - select all / per-file selection + - per-file track search + - manual match selection + - process selected action + +Legacy JS responsibilities in `webui/static/stats-automations.js`: + +- `initializeImportPage` +- staging fetch and refresh +- tab switching +- auto-import polling +- auto-import mutations +- staging group and suggestion rendering +- album search and match POSTs +- drag/drop assignment state +- singles search and selection state +- client-side processing queue +- sequential album/singles processing requests + +Backend endpoints already available: + +- `GET /api/import/staging/files` +- `GET /api/import/staging/groups` +- `GET /api/import/staging/hints` +- `GET /api/import/staging/suggestions` +- `GET /api/import/search/albums` +- `POST /api/import/album/match` +- `POST /api/import/album/process` +- `GET /api/import/search/tracks` +- `POST /api/import/singles/process` +- `GET /api/auto-import/status` +- `POST /api/auto-import/toggle` +- `GET /api/auto-import/settings` +- `POST /api/auto-import/settings` +- `GET /api/auto-import/results` +- `POST /api/auto-import/approve/:id` +- `POST /api/auto-import/reject/:id` +- `POST /api/auto-import/scan-now` +- `POST /api/auto-import/approve-all` +- `POST /api/auto-import/clear-completed` + +## Proposed Route Slice + +```text +webui/src/routes/import/ + route.tsx + -import.types.ts + -import.api.ts + -import.helpers.ts + -route.test.tsx + -ui/ + import-page.tsx +``` + +## Proposed Route Responsibilities + +`route.tsx` + +- declare `/import` +- validate search params +- gate route through `bridge.isPageAllowed('import')` +- preload shell context +- ensure the staging-files query +- prefetch album staging groups and suggestions +- leave auto-import status/results as tab-specific client queries + +`-import.types.ts` + +- search param schema +- API response types +- staging file, staging group, album result, track result, match, auto-import result, and queue item types + +`-import.api.ts` + +- query options for staging, groups, suggestions, auto-import status, auto-import settings, auto-import results, album search, and track search +- mutation helpers for album match, album process, singles process, auto-import actions, and settings writes +- a single `invalidateImportQueries(queryClient)` helper for broad route refreshes after processing + +`-import.helpers.ts` + +- byte-size formatting +- default query param coercion +- album and track display labels +- confidence class/label mapping +- staging match normalization +- auto-import result filtering and counters + +`-ui/import-page.tsx` + +- route-local match state +- tap-selected file chip state +- singles selection and manual matches +- auto-import polling UI +- client-side processing queue state + +## Search Params + +Use URL state only for durable, shareable route state: + +- `tab` + - values: `album`, `singles`, `auto` + - default: `album` +- `autoFilter` + - values: `all`, `pending`, `imported`, `failed` + - default: `all` + +Keep these local to React state: + +- album search text +- track search text +- selected album +- match overrides +- selected singles +- processing queue jobs + +Reasoning: + +- The tab and auto-import filter are useful after reloads. +- The matching workflow is ephemeral and should not create fragile URLs with file indexes or local staging paths. + +## Query Model + +Critical route-loader data: + +- `importStagingFilesQueryOptions()` + +Useful prefetch data: + +- `importStagingGroupsQueryOptions()` +- `importStagingSuggestionsQueryOptions()` + +Tab-specific data: + +- `autoImportStatusQueryOptions()` +- `autoImportSettingsQueryOptions()` +- `autoImportResultsQueryOptions(autoFilter)` + +Lazy search data: + +- `importAlbumSearchQueryOptions(query)` +- `importTrackSearchQueryOptions(query)` + +Mutation-style actions: + +- album match draft +- process one album track +- process one single file +- toggle auto-import +- save auto-import settings +- scan now +- approve/reject auto-import result +- approve all +- clear completed + +Invalidation rules: + +- Processing album or singles files invalidates staging files, staging groups, staging suggestions, auto-import results, and any route-local queue completion summary. +- Auto-import actions invalidate auto-import status and results. +- Auto-import settings writes invalidate settings and status. +- Refresh invalidates staging files, groups, and suggestions. + +## Incremental Migration Order + +Recommended order: + +1. Add route slice, types, API helpers, reducer, and helper tests. +2. Build the React route shell with header, tabs, and staging summary. +3. Port the Albums tab search and suggestions, but keep processing disabled until match rendering is covered. +4. Port the album matching view, including drag/drop and tap assignment. +5. Port the processing queue and album/singles process mutations. +6. Port the Singles tab. +7. Port the Auto tab and polling behavior. +8. Flip `import` from `legacy` to `react` in the shell route manifest. +9. Remove the legacy `import-page` DOM from `webui/index.html`. +10. Remove import-specific legacy functions from `webui/static/stats-automations.js`. + +This order gives us a visible React page early while delaying the highest-risk file-processing actions until the state model is tested. + +## Testing Sketch + +Unit tests: + +- tab and filter search-param defaults +- staging summary formatting +- auto-import counters +- confidence labels +- reducer assignment behavior +- reducer queue transitions + +API tests: + +- staging files success and error +- staging groups success and error +- album search success and empty result +- track search success and empty result +- album match success and failure +- album process success and partial error +- singles process success and partial error +- auto-import status/results/settings/actions + +Route / component tests: + +- unauthorized users redirect to profile home +- default route renders the Albums tab +- `?tab=singles` renders the Singles tab +- `?tab=auto&autoFilter=pending` renders pending auto-import results +- refresh invalidates staging queries +- album selection opens the match view +- drag/drop and tap assignment update track matches +- processing queue advances and refreshes staging on completion + +Playwright can wait until after route ownership flips. + +## Risks + +- The processing queue is client-side and long-running. +- Auto-import polling must stop when leaving the tab or route. +- File indexes can become stale after staging refreshes. +- Album matching depends on preserving source, album name, and album artist from search results. +- The page currently shares a large legacy module with stats and automations code, so cleanup should be careful and incremental. + +## Decisions To Keep Simple + +- Keep the current visual language. +- Keep the existing backend endpoints. +- Keep the processing queue client-side for the first migration. +- Keep file matching state local to the route. +- Do not extract shared workflow primitives until a second migrated route needs them. + +## Outcome + +- The route now serves as the first React-owned workflow migration. +- The implementation uses validated search params for `tab` and `autoFilter`. +- The route uses TanStack Query for staging data, suggestions, auto-import polling, mutations, and invalidation. +- Tests cover shell ownership, URL tab state, album match payload preservation, and auto-import rendering. + +## Recommendation + +Treat remaining work as cleanup and hardening rather than route selection. + +Follow-up work should optimize for: + +- shrinking `stats-automations.js` after cutover +- adding E2E coverage around full album and singles processing +- considering route-level code splitting once more large React routes land + +It should not optimize for: + +- redesign +- backend reshaping +- shared queue abstractions +- migrating `automations` at the same time diff --git a/webui/docs/migration/page-migration-overview.md b/webui/docs/migration/page-migration-overview.md index 01c96a57..6016df4b 100644 --- a/webui/docs/migration/page-migration-overview.md +++ b/webui/docs/migration/page-migration-overview.md @@ -1,10 +1,10 @@ # WebUI Page Migration Overview -Snapshot date: 2026-05-14 +Snapshot date: 2026-05-15 ## Summary - The shell route manifest now has 18 page ids. -- `issues` and `stats` are now React-owned routes. +- `issues`, `stats`, and `import` are now React-owned routes. - Since the last snapshot, the biggest changes are: - `downloads` was renamed into `search`. - The live queue became `active-downloads`. @@ -78,9 +78,9 @@ Rollups: | --- | --- | --- | --- | --- | --- | | `issues` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | | `stats` | React | 2 / 2 / 2 / 2 / 2 | Low | Low | Completed | +| `import` | React | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Completed | | `help` | Legacy | 3 / 2 / 1 / 1 / 2 | Low | Low | Wave 1 | | `hydrabase` | Legacy | 2 / 2 / 2 / 2 / 2 | Low | Low | Wave 1 | -| `import` | Legacy | 3 / 3 / 3 / 2 / 3 | Medium | Medium | Wave 1 | | `search` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 2 | | `watchlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | | `wishlist` | Legacy | 4 / 4 / 4 / 3 / 4 | High | High | Wave 3 | @@ -130,11 +130,12 @@ Rollups: - Recommendation: low-risk route with a narrow surface. #### `import` -- Current owner: Legacy. -- Primary files: `webui/index.html`, `webui/static/stats-automations.js`, `webui/static/helper.js`. +- Current owner: React. +- Primary files: `webui/src/routes/import/*`, `webui/src/platform/shell/route-manifest.ts`. - Main surface: staging files, album and singles matching, suggestion cards, processing queue. - Key coupling: settings-derived staging path assumptions and downstream library state. -- Recommendation: still bounded enough for an early wave, though more workflow-heavy than `help` or `hydrabase`. +- Recommendation: completed as the next migration after `stats`. Follow-up cleanup should remove the dead legacy import functions from `webui/static/stats-automations.js`. +- Route plan: `webui/docs/migration/import-migration-plan.md`. ### Wave 2: Search split @@ -264,9 +265,10 @@ Rollups: - Waves 6-10 defer the broadest, most coupled, or most orchestration-heavy surfaces until the team has the most leverage. ## Final Recommendation -- Keep `issues` and `stats` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. +- Keep `issues`, `stats`, and `import` as the current React reference implementations, and preserve the explicit bridge contract between React routes and legacy shell behavior. - Treat `search`, `watchlist`, `wishlist`, `active-downloads`, and `tools` as the current route ids, and keep `downloads` and `artists` only as compatibility history. -- Migrate the remaining safe legacy routes first: `help`, `hydrabase`, and `import`. +- Migrate the remaining safe legacy routes first: `help` and `hydrabase`. +- `import` has already been migrated and should be treated as the first React-owned workflow route. - During each migration, actively look for small reuse opportunities across route slices and shared UI primitives, but only extract once the overlap is clearly real. -- Use `search` as the next meaningful proving ground now that the download queue has been split out. +- Use `search` as the next larger proving ground after `import`, now that the download queue has been split out. - Avoid pulling `settings`, `sync`, `library`, `artist-detail`, or `automations` forward unless there is a separate product priority strong enough to justify the added regression risk. diff --git a/webui/index.html b/webui/index.html index 43b1e265..709c9b9e 100644 --- a/webui/index.html +++ b/webui/index.html @@ -6199,159 +6199,6 @@
- -
-
- -
-
-

Import Music

- -
-
- Import folder: loading... - -
-
- - - - - -
- - - -
- - -
-
-
- - Disabled - -
- - - -
- - - - -
-
-

Enable auto-import to watch your import folder for new music.

-

Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.

-
-
-
- - -
- -
-
- -
-
- -
-
- - - - -
- - -
-
-
- - -
-
-
-
Navigate to this page to scan your import folder for audio files.
-
- -
-
-
-
diff --git a/webui/package-lock.json b/webui/package-lock.json index 23b81ab1..3e6bf5a6 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -15,7 +15,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", @@ -5391,6 +5392,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/webui/package.json b/webui/package.json index 56123f51..ad4cb775 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,7 +21,8 @@ "react": "^19.2.5", "react-dom": "^19.2.5", "recharts": "^3.8.1", - "zod": "^4.4.2" + "zod": "^4.4.2", + "zustand": "^5.0.13" }, "devDependencies": { "@playwright/test": "^1.59.1", diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts index 8b043665..dbc51571 100644 --- a/webui/src/platform/shell/globals.d.ts +++ b/webui/src/platform/shell/globals.d.ts @@ -9,6 +9,13 @@ import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './b declare global { interface Window { showToast?: (message: string, type?: string, durationOrContext?: number | string) => void; + showConfirmDialog?: (options?: { + title?: string; + message?: string; + confirmText?: string; + cancelText?: string; + destructive?: boolean; + }) => Promise; SoulSyncIssueDomain?: IssueDomainBridge; SoulSyncWorkflowActions?: { openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise; diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts index 2727c255..e4f8c978 100644 --- a/webui/src/platform/shell/route-manifest.test.ts +++ b/webui/src/platform/shell/route-manifest.test.ts @@ -31,6 +31,13 @@ describe('shellRouteManifest', () => { expect(resolveShellPageFromPath('/issues/')).toBe('issues'); }); + it('resolves nested React route paths to their shell page', () => { + expect(resolveShellPageFromPath('/import/album')).toBe('import'); + expect(resolveShellPageFromPath('/import/auto')).toBe('import'); + expect(resolveShellPageFromPath('/import/singles')).toBe('import'); + expect(resolveLegacyShellPageFromPath('/import/album')).toBeNull(); + }); + it('keeps a route entry for every manifest page id', () => { expect(shellRouteManifest).not.toHaveLength(0); expect(getShellRouteByPageId('dashboard')?.path).toBe('/dashboard'); @@ -43,8 +50,9 @@ describe('shellRouteManifest', () => { it('tracks whether a route is rendered by React or the legacy shell', () => { expect(getShellRouteByPageId('issues')?.kind).toBe('react'); expect(getShellRouteByPageId('stats')?.kind).toBe('react'); + expect(getShellRouteByPageId('import')?.kind).toBe('react'); expect(getShellRouteByPageId('discover')?.kind).toBe('legacy'); - expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']); + expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']); expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true); }); diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts index bbcd7d09..c09a18b6 100644 --- a/webui/src/platform/shell/route-manifest.ts +++ b/webui/src/platform/shell/route-manifest.ts @@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [ { pageId: 'wishlist', path: '/wishlist', kind: 'legacy' }, { pageId: 'automations', path: '/automations', kind: 'legacy' }, { pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' }, - { pageId: 'import', path: '/import', kind: 'legacy' }, + { pageId: 'import', path: '/import', kind: 'react' }, { pageId: 'library', path: '/library', kind: 'legacy' }, { pageId: 'tools', path: '/tools', kind: 'legacy' }, { pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' }, @@ -67,7 +67,11 @@ export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition } export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined { - return routeByPath.get(normalizeShellPath(pathname) as `/${string}`); + const normalized = normalizeShellPath(pathname); + const exactRoute = routeByPath.get(normalized as `/${string}`); + if (exactRoute) return exactRoute; + + return reactShellRoutes.find((route) => normalized.startsWith(`${route.path}/`)); } export function resolveShellPageFromPath(pathname: string): ShellPageId | null { diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts index addc76fb..7f935df1 100644 --- a/webui/src/routeTree.gen.ts +++ b/webui/src/routeTree.gen.ts @@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SplatRouteImport } from './routes/$' import { Route as StatsRouteRouteImport } from './routes/stats/route' import { Route as IssuesRouteRouteImport } from './routes/issues/route' +import { Route as ImportRouteRouteImport } from './routes/import/route' import { Route as IndexRouteImport } from './routes/index' +import { Route as ImportIndexRouteImport } from './routes/import/index' +import { Route as ImportSinglesRouteImport } from './routes/import/singles' +import { Route as ImportAutoRouteImport } from './routes/import/auto' +import { Route as ImportAlbumRouteImport } from './routes/import/album' import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id' const SplatRoute = SplatRouteImport.update({ @@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({ path: '/issues', getParentRoute: () => rootRouteImport, } as any) +const ImportRouteRoute = ImportRouteRouteImport.update({ + id: '/import', + path: '/import', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => rootRouteImport, } as any) +const ImportIndexRoute = ImportIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportSinglesRoute = ImportSinglesRouteImport.update({ + id: '/singles', + path: '/singles', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportAutoRoute = ImportAutoRouteImport.update({ + id: '/auto', + path: '/auto', + getParentRoute: () => ImportRouteRoute, +} as any) +const ImportAlbumRoute = ImportAlbumRouteImport.update({ + id: '/album', + path: '/album', + getParentRoute: () => ImportRouteRoute, +} as any) const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ id: '/artist-detail/$source/$id', path: '/artist-detail/$source/$id', @@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import/': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesByTo { @@ -53,32 +88,66 @@ export interface FileRoutesByTo { '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/import': typeof ImportRouteRouteWithChildren '/issues': typeof IssuesRouteRoute '/stats': typeof StatsRouteRoute '/$': typeof SplatRoute + '/import/album': typeof ImportAlbumRoute + '/import/auto': typeof ImportAutoRoute + '/import/singles': typeof ImportSinglesRoute + '/import/': typeof ImportIndexRoute '/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' + fullPaths: + | '/' + | '/import' + | '/issues' + | '/stats' + | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import/' + | '/artist-detail/$source/$id' fileRoutesByTo: FileRoutesByTo - to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id' - id: - | '__root__' + to: | '/' | '/issues' | '/stats' | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import' + | '/artist-detail/$source/$id' + id: + | '__root__' + | '/' + | '/import' + | '/issues' + | '/stats' + | '/$' + | '/import/album' + | '/import/auto' + | '/import/singles' + | '/import/' | '/artist-detail/$source/$id' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + ImportRouteRoute: typeof ImportRouteRouteWithChildren IssuesRouteRoute: typeof IssuesRouteRoute StatsRouteRoute: typeof StatsRouteRoute SplatRoute: typeof SplatRoute @@ -108,6 +177,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IssuesRouteRouteImport parentRoute: typeof rootRouteImport } + '/import': { + id: '/import' + path: '/import' + fullPath: '/import' + preLoaderRoute: typeof ImportRouteRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -115,6 +191,34 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof IndexRouteImport parentRoute: typeof rootRouteImport } + '/import/': { + id: '/import/' + path: '/' + fullPath: '/import/' + preLoaderRoute: typeof ImportIndexRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/singles': { + id: '/import/singles' + path: '/singles' + fullPath: '/import/singles' + preLoaderRoute: typeof ImportSinglesRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/auto': { + id: '/import/auto' + path: '/auto' + fullPath: '/import/auto' + preLoaderRoute: typeof ImportAutoRouteImport + parentRoute: typeof ImportRouteRoute + } + '/import/album': { + id: '/import/album' + path: '/album' + fullPath: '/import/album' + preLoaderRoute: typeof ImportAlbumRouteImport + parentRoute: typeof ImportRouteRoute + } '/artist-detail/$source/$id': { id: '/artist-detail/$source/$id' path: '/artist-detail/$source/$id' @@ -125,8 +229,27 @@ declare module '@tanstack/react-router' { } } +interface ImportRouteRouteChildren { + ImportAlbumRoute: typeof ImportAlbumRoute + ImportAutoRoute: typeof ImportAutoRoute + ImportSinglesRoute: typeof ImportSinglesRoute + ImportIndexRoute: typeof ImportIndexRoute +} + +const ImportRouteRouteChildren: ImportRouteRouteChildren = { + ImportAlbumRoute: ImportAlbumRoute, + ImportAutoRoute: ImportAutoRoute, + ImportSinglesRoute: ImportSinglesRoute, + ImportIndexRoute: ImportIndexRoute, +} + +const ImportRouteRouteWithChildren = ImportRouteRoute._addFileChildren( + ImportRouteRouteChildren, +) + const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + ImportRouteRoute: ImportRouteRouteWithChildren, IssuesRouteRoute: IssuesRouteRoute, StatsRouteRoute: StatsRouteRoute, SplatRoute: SplatRoute, diff --git a/webui/src/routes/import/-import.api.ts b/webui/src/routes/import/-import.api.ts new file mode 100644 index 00000000..624d8a36 --- /dev/null +++ b/webui/src/routes/import/-import.api.ts @@ -0,0 +1,293 @@ +import { queryOptions, type QueryClient } from '@tanstack/react-query'; + +import { apiClient, readJson } from '@/app/api-client'; + +import type { + ImportAlbum, + ImportAlbumMatch, + ImportAlbumMatchPayload, + ImportAlbumSearchPayload, + ImportAutoImportResultsPayload, + ImportAutoImportSettingsPayload, + ImportAutoImportStatusPayload, + ImportProcessPayload, + ImportStagingFilesPayload, + ImportStagingGroupsPayload, + ImportTrackSearchPayload, +} from './-import.types'; + +export const IMPORT_QUERY_KEY = ['import'] as const; + +function assertSuccess( + payload: T, + fallback: string, +): T { + if (!payload.success) { + throw new Error(payload.error || fallback); + } + return payload; +} + +export async function fetchImportStagingFiles(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/files')), + 'Failed to load import folder', + ); +} + +export async function fetchImportStagingGroups(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/groups')), + 'Failed to load auto-detected albums', + ); +} + +export async function fetchImportStagingSuggestions(): Promise { + return assertSuccess( + await readJson(apiClient.get('import/staging/suggestions')), + 'Failed to load import suggestions', + ); +} + +export async function searchImportAlbums(query: string): Promise { + return assertSuccess( + await readJson( + apiClient.get('import/search/albums', { + searchParams: { + q: query, + limit: '12', + }, + }), + ), + 'Album search failed', + ); +} + +export async function matchImportAlbum(input: { + albumId: string; + source?: string | null; + albumName?: string | null; + albumArtist?: string | null; + filePaths?: string[] | null; +}): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/album/match', { + json: { + album_id: input.albumId, + source: input.source || '', + album_name: input.albumName || '', + album_artist: input.albumArtist || '', + ...(input.filePaths?.length ? { file_paths: input.filePaths } : {}), + }, + }), + ), + 'Failed to match album', + ); +} + +export async function processImportAlbumTrack(input: { + album: ImportAlbum; + match: ImportAlbumMatch; +}): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/album/process', { + json: { + album: input.album, + matches: [input.match], + }, + }), + ), + 'Failed to process album track', + ); +} + +export async function searchImportTracks(query: string): Promise { + return assertSuccess( + await readJson( + apiClient.get('import/search/tracks', { + searchParams: { + q: query, + limit: '6', + }, + }), + ), + 'Track search failed', + ); +} + +export async function processImportSingleFile(file: unknown): Promise { + return assertSuccess( + await readJson( + apiClient.post('import/singles/process', { + json: { + files: [file], + }, + }), + ), + 'Failed to process single', + ); +} + +export async function fetchAutoImportStatus(): Promise { + return assertSuccess( + await readJson(apiClient.get('auto-import/status')), + 'Failed to load auto-import status', + ); +} + +export async function fetchAutoImportSettings(): Promise { + return assertSuccess( + await readJson(apiClient.get('auto-import/settings')), + 'Failed to load auto-import settings', + ); +} + +export async function saveAutoImportSettings(input: { + confidenceThreshold: number; + scanInterval: number; +}): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post('auto-import/settings', { + json: { + confidence_threshold: input.confidenceThreshold, + scan_interval: input.scanInterval, + }, + }), + ), + 'Failed to save auto-import settings', + ); +} + +export async function fetchAutoImportResults(): Promise { + return assertSuccess( + await readJson( + apiClient.get('auto-import/results', { + searchParams: { + limit: '100', + }, + }), + ), + 'Failed to load auto-import results', + ); +} + +export async function toggleAutoImport(enabled: boolean): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post('auto-import/toggle', { + json: { enabled }, + }), + ), + 'Failed to toggle auto-import', + ); +} + +export async function triggerAutoImportScan(): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now')), + 'Failed to trigger scan', + ); +} + +export async function approveAutoImportResult(id: number): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post(`auto-import/approve/${id}`), + ), + 'Failed to approve import', + ); +} + +export async function rejectAutoImportResult(id: number): Promise { + assertSuccess( + await readJson<{ success: boolean; error?: string }>( + apiClient.post(`auto-import/reject/${id}`), + ), + 'Failed to dismiss import', + ); +} + +export async function approveAllAutoImportResults(): Promise { + const payload = assertSuccess( + await readJson<{ success: boolean; count?: number; error?: string }>( + apiClient.post('auto-import/approve-all'), + ), + 'Failed to approve imports', + ); + return payload.count ?? 0; +} + +export async function clearCompletedAutoImportResults(): Promise { + const payload = assertSuccess( + await readJson<{ success: boolean; count?: number; error?: string }>( + apiClient.post('auto-import/clear-completed'), + ), + 'Failed to clear import history', + ); + return payload.count ?? 0; +} + +export function importStagingFilesQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-files'], + queryFn: fetchImportStagingFiles, + }); +} + +export function importStagingGroupsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'], + queryFn: fetchImportStagingGroups, + }); +} + +export function importStagingSuggestionsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'], + queryFn: fetchImportStagingSuggestions, + }); +} + +export function autoImportStatusQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'], + queryFn: fetchAutoImportStatus, + }); +} + +export function autoImportSettingsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'], + queryFn: fetchAutoImportSettings, + }); +} + +export function autoImportResultsQueryOptions() { + return queryOptions({ + queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'], + queryFn: fetchAutoImportResults, + }); +} + +export function invalidateImportQueries(queryClient: QueryClient) { + return queryClient.invalidateQueries({ queryKey: IMPORT_QUERY_KEY }); +} + +export function invalidateImportStagingQueries(queryClient: QueryClient) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-files'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'] }), + ]); +} + +export function invalidateAutoImportQueries(queryClient: QueryClient) { + return Promise.all([ + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'] }), + queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'] }), + ]); +} diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts new file mode 100644 index 00000000..0805b5d9 --- /dev/null +++ b/webui/src/routes/import/-import.helpers.ts @@ -0,0 +1,286 @@ +import type { + ImportAlbumMatch, + ImportAutoFilter, + ImportAutoImportActiveItem, + ImportAutoImportMatchData, + ImportAutoImportResult, + ImportAutoImportStatusPayload, + ImportQueueEntry, + ImportStagingFile, +} from './-import.types'; + +export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png'; + +export function formatImportBytes(bytes: number): string { + if (bytes > 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + if (bytes > 1_048_576) return `${(bytes / 1_048_576).toFixed(0)} MB`; + return `${(bytes / 1024).toFixed(0)} KB`; +} + +export function getStagingStatsText(files: ImportStagingFile[]): string { + const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0); + const fileLabel = `${files.length} file${files.length === 1 ? '' : 's'}`; + return totalSize ? `${fileLabel} - ${formatImportBytes(totalSize)}` : fileLabel; +} + +export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) { + const track = match.track || match.spotify_track || {}; + const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; + const trackNumber = + rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === '' + ? null + : String(rawTrackNumber).split('/')[0]?.trim() || null; + + return { + track, + name: track.name || track.title || `Track ${index + 1}`, + trackNumber, + displayTrackNumber: trackNumber || String(index + 1), + }; +} + +export function getEffectiveAlbumMatches( + matches: ImportAlbumMatch[], + stagingFiles: ImportStagingFile[], + overrides: Record, +): ImportAlbumMatch[] { + return matches.flatMap((match, index) => { + if (Object.hasOwn(overrides, index)) { + const override = overrides[index]; + if (override === -1) return []; + const stagingFile = stagingFiles[override]; + return stagingFile ? [{ ...match, staging_file: stagingFile, confidence: 1 }] : []; + } + return match.staging_file ? [match] : []; + }); +} + +export function getDisplayedMatchFile( + match: ImportAlbumMatch, + index: number, + stagingFiles: ImportStagingFile[], + overrides: Record, +): { file: ImportStagingFile | null; confidence: number; isOverride: boolean } { + if (Object.hasOwn(overrides, index)) { + const override = overrides[index]; + if (override === -1) return { file: null, confidence: match.confidence, isOverride: false }; + return { + file: stagingFiles[override] ?? null, + confidence: 1, + isOverride: true, + }; + } + + if (!match.staging_file) { + return { file: null, confidence: match.confidence, isOverride: false }; + } + + const autoFileName = match.staging_file.filename; + const reassigned = Object.entries(overrides).some(([trackIndex, stagingFileIndex]) => { + const file = stagingFiles[stagingFileIndex]; + return file && file.filename === autoFileName && Number(trackIndex) !== index; + }); + + return { + file: reassigned ? null : match.staging_file, + confidence: match.confidence, + isOverride: false, + }; +} + +export function getUnmatchedStagingFiles( + matches: ImportAlbumMatch[], + stagingFiles: ImportStagingFile[], + overrides: Record, +): Array<{ file: ImportStagingFile; index: number }> { + return stagingFiles.flatMap((file, index) => { + if (Object.values(overrides).includes(index)) return []; + + const autoUsed = matches.some((match, matchIndex) => { + if (Object.hasOwn(overrides, matchIndex)) return false; + return match.staging_file?.filename === file.filename; + }); + + return autoUsed ? [] : [{ file, index }]; + }); +} + +export function getAutoImportCounts(results: ImportAutoImportResult[]) { + return { + imported: results.filter( + (result) => result.status === 'completed' || result.status === 'approved', + ).length, + review: results.filter((result) => result.status === 'pending_review').length, + failed: results.filter( + (result) => result.status === 'failed' || result.status === 'needs_identification', + ).length, + }; +} + +export function filterAutoImportResults( + results: ImportAutoImportResult[], + filter: ImportAutoFilter, +): ImportAutoImportResult[] { + if (filter === 'pending') return results.filter((result) => result.status === 'pending_review'); + if (filter === 'imported') { + return results.filter( + (result) => result.status === 'completed' || result.status === 'approved', + ); + } + if (filter === 'failed') { + return results.filter( + (result) => result.status === 'failed' || result.status === 'needs_identification', + ); + } + return results; +} + +export function getAutoImportStatusText(status: ImportAutoImportStatusPayload | undefined): string { + if (!status) return 'Loading...'; + if (status.paused) return 'Paused'; + if (status.current_status === 'processing') return 'Processing...'; + if (status.current_status === 'scanning') return 'Scanning...'; + if (!status.running) return 'Disabled'; + + if (status.last_scan_time) { + const lastScan = new Date(status.last_scan_time); + const diffSeconds = Math.floor((Date.now() - lastScan.getTime()) / 1000); + if (Number.isFinite(diffSeconds) && diffSeconds >= 0 && diffSeconds < 60) { + return `Watching (scanned ${diffSeconds}s ago)`; + } + if (Number.isFinite(diffSeconds) && diffSeconds < 3600) { + return `Watching (scanned ${Math.floor(diffSeconds / 60)}m ago)`; + } + } + + return 'Watching'; +} + +export function getAutoImportStatusClass( + status: ImportAutoImportStatusPayload | undefined, +): string { + if (!status?.running) return 'disabled'; + if (status.current_status === 'scanning') return 'scanning'; + if (status.current_status === 'processing') return 'processing'; + return 'active'; +} + +export function getActiveImportLines(status: ImportAutoImportStatusPayload | undefined): string[] { + const active = Array.isArray(status?.active_imports) ? status.active_imports : []; + if (active.length > 0) return active.map(getActiveImportLine); + if (status?.current_status === 'scanning') { + return [`Scanning... (${status.stats?.scanned || 0} processed)`]; + } + return []; +} + +function getActiveImportLine(item: ImportAutoImportActiveItem): string { + const folder = item.folder_name || '...'; + const trackIndex = item.track_index || 0; + const trackTotal = item.track_total || 0; + const trackName = item.track_name || ''; + if (item.status === 'processing' && trackTotal > 0) { + return `${folder} - track ${trackIndex}/${trackTotal}: ${trackName}`; + } + if (item.status === 'matching') return `${folder} - matching tracks...`; + if (item.status === 'identifying') return `${folder} - identifying...`; + return `${folder} - queued`; +} + +export function parseAutoImportMatchData( + matchData: ImportAutoImportResult['match_data'], +): ImportAutoImportMatchData { + if (!matchData) return {}; + if (typeof matchData === 'object') return matchData; + try { + const parsed = JSON.parse(matchData) as ImportAutoImportMatchData; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +export function getAutoImportTimeAgo(createdAt: string | null | undefined): string { + if (!createdAt) return ''; + const created = new Date(createdAt); + const diffMinutes = Math.floor((Date.now() - created.getTime()) / 60_000); + if (!Number.isFinite(diffMinutes) || diffMinutes < 0) return ''; + if (diffMinutes < 1) return 'just now'; + if (diffMinutes < 60) return `${diffMinutes}m ago`; + if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}h ago`; + return `${Math.floor(diffMinutes / 1440)}d ago`; +} + +export function getConfidenceClass(confidencePercent: number): 'high' | 'medium' | 'low' { + if (confidencePercent >= 90) return 'high'; + if (confidencePercent >= 70) return 'medium'; + return 'low'; +} + +export function getAutoImportStatusMeta(status: string): { + label: string; + icon: string; + className: 'completed' | 'review' | 'failed' | 'processing' | 'neutral'; +} { + const labels: Record = { + completed: 'Imported', + pending_review: 'Needs Review', + needs_identification: 'Unidentified', + failed: 'Failed', + scanning: 'Scanning...', + matched: 'Matched', + rejected: 'Dismissed', + approved: 'Approved', + processing: 'Processing', + }; + + const icons: Record = { + completed: 'OK', + pending_review: '!', + needs_identification: 'x', + failed: 'x', + scanning: '~', + matched: 'OK', + rejected: 'x', + approved: 'OK', + processing: '~', + }; + + return { + label: labels[status] || status, + icon: icons[status] || '', + className: + status === 'completed' + ? 'completed' + : status === 'pending_review' + ? 'review' + : status === 'failed' || status === 'needs_identification' + ? 'failed' + : status === 'processing' + ? 'processing' + : 'neutral', + }; +} + +export function getQueueProgressPercent(entry: ImportQueueEntry): number { + if (entry.status === 'done' || entry.status === 'error') return 100; + if (entry.total <= 0) return 0; + return Math.round((entry.processed / entry.total) * 100); +} + +export function getQueueStatusText(entry: ImportQueueEntry): string { + if (entry.status === 'running') return `${entry.processed}/${entry.total}`; + if (entry.status === 'done') { + return entry.errors.length > 0 + ? `${entry.processed}/${entry.total} (${entry.errors.length} err)` + : 'Done'; + } + return 'Failed'; +} + +export function formatDuration(durationMs: number | null | undefined): string { + if (!durationMs) return ''; + const minutes = Math.floor(durationMs / 60_000); + const seconds = String(Math.floor((durationMs % 60_000) / 1000)).padStart(2, '0'); + return `${minutes}:${seconds}`; +} diff --git a/webui/src/routes/import/-import.store.ts b/webui/src/routes/import/-import.store.ts new file mode 100644 index 00000000..88a09e2f --- /dev/null +++ b/webui/src/routes/import/-import.store.ts @@ -0,0 +1,236 @@ +import { create } from 'zustand'; +import { combine } from 'zustand/middleware'; +import { useShallow } from 'zustand/react/shallow'; + +import type { + ImportAlbumMatchPayload, + ImportAlbumResult, + ImportQueueEntry, + ImportQueueJob, + ImportTrackResult, +} from './-import.types'; + +export type SingleSearchState = { + query: string; + loading: boolean; + error: string | null; + results: ImportTrackResult[]; +}; + +type StateUpdater = T | ((current: T) => T); + +function resolveState(current: T, updater: StateUpdater) { + return typeof updater === 'function' ? (updater as (value: T) => T)(current) : updater; +} + +function createInitialWorkflowState() { + return { + queue: [] as ImportQueueEntry[], + nextQueueId: 0, + albumQuery: '', + albumResults: null as ImportAlbumResult[] | null, + albumSearchError: null as string | null, + albumSearchLoading: false, + autoGroupFilePaths: null as string[] | null, + selectedAlbum: null as ImportAlbumResult | null, + albumMatch: null as ImportAlbumMatchPayload | null, + albumMatchError: null as string | null, + albumMatchLoading: false, + matchOverrides: {} as Record, + selectedSingles: new Set(), + singlesManualMatches: {} as Record, + openSingleSearch: null as number | null, + singleSearches: {} as Record, + }; +} + +export const useImportWorkflowStore = create( + combine(createInitialWorkflowState(), (set, get) => ({ + clearFinishedJobs: () => { + set((state) => ({ queue: state.queue.filter((entry) => entry.status === 'running') })); + }, + enqueueQueueJob: (job: ImportQueueJob) => { + const id = get().nextQueueId + 1; + const entry: ImportQueueEntry = { + id, + type: job.type, + label: job.label, + sublabel: job.sublabel, + imageUrl: job.imageUrl, + status: 'running', + processed: 0, + total: job.items.length, + errors: [], + }; + + set((state) => ({ + nextQueueId: id, + queue: [...state.queue, entry], + })); + return id; + }, + updateQueueEntry: (entryId: number, patch: Partial) => { + set((state) => ({ + queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)), + })); + }, + resetAlbumSearch: () => { + set({ + albumQuery: '', + albumResults: null, + albumSearchError: null, + albumSearchLoading: false, + autoGroupFilePaths: null, + selectedAlbum: null, + albumMatch: null, + albumMatchError: null, + albumMatchLoading: false, + matchOverrides: {}, + }); + }, + setAlbumQuery: (albumQuery: string) => set({ albumQuery }), + setAlbumResults: (albumResults: ImportAlbumResult[] | null) => set({ albumResults }), + setAlbumSearchError: (albumSearchError: string | null) => set({ albumSearchError }), + setAlbumSearchLoading: (albumSearchLoading: boolean) => set({ albumSearchLoading }), + setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => { + set({ + albumQuery, + albumSearchLoading: true, + albumSearchError: null, + albumResults: null, + selectedAlbum: null, + albumMatch: null, + autoGroupFilePaths, + }); + }, + setSelectedAlbum: (selectedAlbum: ImportAlbumResult | null) => set({ selectedAlbum }), + setAlbumMatch: (albumMatch: ImportAlbumMatchPayload | null) => set({ albumMatch }), + setAlbumMatchError: (albumMatchError: string | null) => set({ albumMatchError }), + setAlbumMatchLoading: (albumMatchLoading: boolean) => set({ albumMatchLoading }), + clearAutoGroupFilePaths: () => set({ autoGroupFilePaths: null }), + setMatchOverrides: (updater: StateUpdater>) => { + set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) })); + }, + toggleSingle: (index: number) => { + set((state) => { + const selectedSingles = new Set(state.selectedSingles); + if (selectedSingles.has(index)) selectedSingles.delete(index); + else selectedSingles.add(index); + return { selectedSingles }; + }); + }, + toggleAllSingles: (fileCount: number) => { + set((state) => ({ + selectedSingles: + state.selectedSingles.size === fileCount + ? new Set() + : new Set(Array.from({ length: fileCount }, (_, index) => index)), + })); + }, + clearSinglesSelection: () => { + set({ + selectedSingles: new Set(), + singlesManualMatches: {}, + }); + }, + setOpenSingleSearch: (openSingleSearch: number | null) => set({ openSingleSearch }), + ensureSingleSearch: (index: number, query: string) => { + set((state) => ({ + singleSearches: { + ...state.singleSearches, + [index]: state.singleSearches[index] ?? { + query, + loading: false, + error: null, + results: [], + }, + }, + })); + }, + setSingleSearch: (index: number, updater: StateUpdater) => { + set((state) => { + const current = state.singleSearches[index] ?? { + query: '', + loading: false, + error: null, + results: [], + }; + return { + singleSearches: { + ...state.singleSearches, + [index]: resolveState(current, updater), + }, + }; + }); + }, + selectSingleMatch: (fileIndex: number, track: ImportTrackResult) => { + set((state) => ({ + singlesManualMatches: { ...state.singlesManualMatches, [fileIndex]: track }, + selectedSingles: new Set(state.selectedSingles).add(fileIndex), + openSingleSearch: null, + })); + }, + })), +); + +export function resetImportWorkflowStore() { + useImportWorkflowStore.setState(createInitialWorkflowState()); +} + +export function useImportQueueWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + clearFinishedJobs: state.clearFinishedJobs, + enqueueQueueJob: state.enqueueQueueJob, + queue: state.queue, + updateQueueEntry: state.updateQueueEntry, + })), + ); +} + +export function useAlbumImportWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + albumMatch: state.albumMatch, + albumMatchError: state.albumMatchError, + albumMatchLoading: state.albumMatchLoading, + albumQuery: state.albumQuery, + albumResults: state.albumResults, + albumSearchError: state.albumSearchError, + albumSearchLoading: state.albumSearchLoading, + autoGroupFilePaths: state.autoGroupFilePaths, + clearAutoGroupFilePaths: state.clearAutoGroupFilePaths, + matchOverrides: state.matchOverrides, + resetAlbumWorkflow: state.resetAlbumSearch, + selectedAlbum: state.selectedAlbum, + setAlbumMatch: state.setAlbumMatch, + setAlbumMatchError: state.setAlbumMatchError, + setAlbumMatchLoading: state.setAlbumMatchLoading, + setAlbumQuery: state.setAlbumQuery, + setAlbumResults: state.setAlbumResults, + setAlbumSearchContext: state.setAlbumSearchContext, + setAlbumSearchError: state.setAlbumSearchError, + setAlbumSearchLoading: state.setAlbumSearchLoading, + setMatchOverrides: state.setMatchOverrides, + setSelectedAlbum: state.setSelectedAlbum, + })), + ); +} + +export function useSinglesImportWorkflow() { + return useImportWorkflowStore( + useShallow((state) => ({ + clearSinglesSelection: state.clearSinglesSelection, + ensureSingleSearch: state.ensureSingleSearch, + openSingleSearch: state.openSingleSearch, + selectedSingles: state.selectedSingles, + selectSingleMatchInStore: state.selectSingleMatch, + setOpenSingleSearch: state.setOpenSingleSearch, + setSingleSearch: state.setSingleSearch, + singleSearches: state.singleSearches, + singlesManualMatches: state.singlesManualMatches, + toggleAllSingles: state.toggleAllSingles, + toggleSingleInStore: state.toggleSingle, + })), + ); +} diff --git a/webui/src/routes/import/-import.types.ts b/webui/src/routes/import/-import.types.ts new file mode 100644 index 00000000..9767ff24 --- /dev/null +++ b/webui/src/routes/import/-import.types.ts @@ -0,0 +1,226 @@ +import { z } from 'zod'; + +export const IMPORT_AUTO_FILTER_VALUES = ['all', 'pending', 'imported', 'failed'] as const; +export type ImportAutoFilter = (typeof IMPORT_AUTO_FILTER_VALUES)[number]; + +export const importAutoSearchSchema = z.object({ + autoFilter: z.enum(IMPORT_AUTO_FILTER_VALUES).default('all').catch('all'), +}); + +export type ImportAutoSearch = z.infer; + +export interface ImportStagingFile { + filename: string; + rel_path?: string; + full_path: string; + title?: string | null; + artist?: string | null; + album?: string | null; + track_number?: string | number | null; + disc_number?: string | number | null; + extension?: string | null; + size?: number | null; + manual_match?: ImportTrackResult; +} + +export interface ImportStagingFilesPayload { + success: boolean; + files?: ImportStagingFile[]; + staging_path?: string; + error?: string; +} + +export interface ImportStagingGroup { + album: string; + artist: string; + file_count: number; + files?: Array<{ + filename: string; + full_path: string; + title?: string | null; + track_number?: string | number | null; + }>; + file_paths: string[]; +} + +export interface ImportStagingGroupsPayload { + success: boolean; + groups?: ImportStagingGroup[]; + error?: string; +} + +export interface ImportAlbumResult { + id: string; + name: string; + artist: string; + source?: string | null; + image_url?: string | null; + total_tracks?: number | null; + release_date?: string | null; +} + +export interface ImportAlbumSearchPayload { + success: boolean; + albums?: ImportAlbumResult[]; + suggestions?: ImportAlbumResult[]; + ready?: boolean; + error?: string; +} + +export interface ImportTrackResult { + id: string; + name: string; + artist: string; + album?: string | null; + source?: string | null; + image_url?: string | null; + duration_ms?: number | null; +} + +export interface ImportTrackSearchPayload { + success: boolean; + tracks?: ImportTrackResult[]; + error?: string; +} + +export interface ImportAlbum { + id?: string | number | null; + name: string; + artist: string; + source?: string | null; + image_url?: string | null; + total_tracks?: number | null; + release_date?: string | null; +} + +export interface ImportAlbumTrack { + id?: string | number | null; + name?: string | null; + title?: string | null; + track_number?: string | number | null; + trackNumber?: string | number | null; + disc_number?: number | null; +} + +export interface ImportAlbumMatch { + track?: ImportAlbumTrack | null; + spotify_track?: ImportAlbumTrack | null; + staging_file?: ImportStagingFile | null; + confidence: number; +} + +export interface ImportAlbumMatchPayload { + success: boolean; + album?: ImportAlbum; + matches?: ImportAlbumMatch[]; + error?: string; +} + +export interface ImportProcessPayload { + success: boolean; + processed?: number; + total?: number; + errors?: string[]; + error?: string; +} + +export interface ImportAutoImportActiveItem { + folder_hash?: string | null; + folder_name?: string | null; + status?: string | null; + track_index?: number | null; + track_total?: number | null; + track_name?: string | null; +} + +export interface ImportAutoImportStatusPayload { + success: boolean; + running?: boolean; + paused?: boolean; + current_status?: string | null; + last_scan_time?: string | null; + active_imports?: ImportAutoImportActiveItem[]; + stats?: { + scanned?: number; + auto_processed?: number; + pending_review?: number; + failed?: number; + }; + error?: string; +} + +export interface ImportAutoImportSettingsPayload { + success: boolean; + enabled?: boolean; + scan_interval?: number; + confidence_threshold?: number; + auto_process?: boolean; + error?: string; +} + +export interface ImportAutoImportMatchData { + matched_count?: number; + total_tracks?: number; + matches?: Array<{ + track_name?: string | null; + track?: { name?: string | null }; + file?: string | null; + confidence?: number | null; + }>; +} + +export interface ImportAutoImportResult { + id: number; + status: string; + folder_hash?: string | null; + folder_name: string; + album_name?: string | null; + artist_name?: string | null; + image_url?: string | null; + confidence?: number | null; + total_files?: number | null; + identification_method?: string | null; + match_data?: string | ImportAutoImportMatchData | null; + error_message?: string | null; + created_at?: string | null; +} + +export interface ImportAutoImportResultsPayload { + success: boolean; + results?: ImportAutoImportResult[]; + error?: string; +} + +export type ImportQueueStatus = 'running' | 'done' | 'error'; +export type ImportQueueJobType = 'album' | 'singles'; + +export interface ImportQueueEntry { + id: number; + type: ImportQueueJobType; + label: string; + sublabel: string; + imageUrl?: string | null; + status: ImportQueueStatus; + processed: number; + total: number; + errors: string[]; +} + +export interface ImportAlbumQueueJob { + type: 'album'; + label: string; + sublabel: string; + imageUrl?: string | null; + items: ImportAlbumMatch[]; + albumData: ImportAlbum; +} + +export interface ImportSinglesQueueJob { + type: 'singles'; + label: string; + sublabel: string; + imageUrl?: string | null; + items: ImportStagingFile[]; +} + +export type ImportQueueJob = ImportAlbumQueueJob | ImportSinglesQueueJob; diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx new file mode 100644 index 00000000..2750a08b --- /dev/null +++ b/webui/src/routes/import/-route.test.tsx @@ -0,0 +1,272 @@ +import { createMemoryHistory } from '@tanstack/react-router'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge'; + +import { createAppQueryClient } from '@/app/query-client'; +import { AppRouterProvider, createAppRouter } from '@/app/router'; + +import { resetImportWorkflowStore } from './-import.store'; + +function createResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function createShellBridge(overrides: Partial = {}): ShellBridge { + return { + getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })), + isPageAllowed: vi.fn(() => true), + getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'), + resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'), + setActivePageChrome: vi.fn(), + activateLegacyPath: vi.fn(), + showReactHost: vi.fn(), + navigateToArtistDetail: vi.fn(), + playLibraryTrack: vi.fn(), + startStream: vi.fn(), + showLoadingOverlay: vi.fn(), + hideLoadingOverlay: vi.fn(), + ...overrides, + }; +} + +function renderImportRoute(initialEntries = ['/import']) { + const queryClient = createAppQueryClient(); + const history = createMemoryHistory({ initialEntries }); + const router = createAppRouter({ history, queryClient }); + + return { + history, + router, + ...render(), + }; +} + +function getFetchUrls() { + return vi + .mocked(fetch) + .mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input))); +} + +describe('import route', () => { + let albumMatchBodies: Record[]; + + beforeEach(() => { + albumMatchBodies = []; + resetImportWorkflowStore(); + window.SoulSyncWebShellBridge = createShellBridge(); + window.showToast = vi.fn(); + window.showConfirmDialog = vi.fn(async () => true); + + vi.stubGlobal( + 'fetch', + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + + if (url.includes('/api/import/staging/files')) { + return createResponse({ + success: true, + staging_path: '/music/Staging', + files: [ + { + filename: '01-track.flac', + rel_path: 'Album/01-track.flac', + full_path: '/music/Staging/Album/01-track.flac', + title: 'Track One', + artist: 'Artist A', + album: 'Album A', + extension: '.flac', + }, + ], + }); + } + + if (url.includes('/api/import/staging/groups')) { + return createResponse({ + success: true, + groups: [ + { + album: 'Album A', + artist: 'Artist A', + file_count: 2, + file_paths: ['/music/Staging/Album/01-track.flac'], + }, + ], + }); + } + + if (url.includes('/api/import/staging/suggestions')) { + return createResponse({ + success: true, + ready: true, + suggestions: [ + { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + ], + }); + } + + if (url.includes('/api/import/search/albums')) { + return createResponse({ + success: true, + albums: [ + { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + ], + }); + } + + if (url.includes('/api/import/album/match')) { + const body = + input instanceof Request + ? ((await input.clone().json()) as Record) + : (JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as Record< + string, + unknown + >); + albumMatchBodies.push(body); + return createResponse({ + success: true, + received: body, + album: { + id: 'album-1', + name: 'Album A', + artist: 'Artist A', + source: 'deezer', + total_tracks: 1, + release_date: '2026-01-01', + }, + matches: [ + { + track: { name: 'Track One', track_number: 1 }, + staging_file: { + filename: '01-track.flac', + full_path: '/music/Staging/Album/01-track.flac', + }, + confidence: 0.95, + }, + ], + }); + } + + if (url.includes('/api/auto-import/status')) { + return createResponse({ + success: true, + running: true, + current_status: 'idle', + active_imports: [], + }); + } + + if (url.includes('/api/auto-import/settings')) { + return createResponse({ + success: true, + scan_interval: 60, + confidence_threshold: 0.9, + }); + } + + if (url.includes('/api/auto-import/results')) { + return createResponse({ + success: true, + results: [ + { + id: 4, + status: 'pending_review', + folder_hash: 'hash-1', + folder_name: 'Album A', + album_name: 'Album A', + artist_name: 'Artist A', + confidence: 0.82, + total_files: 2, + }, + ], + }); + } + + return createResponse({ success: true }); + }) as unknown as typeof fetch, + ); + }); + + it('renders the import page through the app router', async () => { + const { history } = renderImportRoute(); + + await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument()); + expect(await screen.findByText('Import Music')).toBeInTheDocument(); + expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument(); + await waitFor(() => expect(history.location.pathname).toBe('/import/album')); + await waitFor(() => + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true), + ); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe( + true, + ); + expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import'); + expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import'); + }); + + it('stores the active tab in nested route paths', async () => { + const { history } = renderImportRoute(); + + fireEvent.click(await screen.findByRole('link', { name: 'Singles' })); + + await waitFor(() => expect(history.location.pathname).toBe('/import/singles')); + expect(screen.getByText('Process Selected (0)')).toBeInTheDocument(); + }); + + it('keeps client workflow drafts across page remounts', async () => { + const view = renderImportRoute(); + + const searchInput = await screen.findByPlaceholderText('Search for an album...'); + fireEvent.change(searchInput, { target: { value: 'half matched album' } }); + view.unmount(); + + renderImportRoute(); + + expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument(); + }); + + it('preserves album source details when matching an album', async () => { + renderImportRoute(); + + const albumButtons = await screen.findAllByRole('button', { name: /Album A/ }); + fireEvent.click(albumButtons[albumButtons.length - 1]); + + await waitFor(() => expect(screen.getByText('Track Matching')).toBeInTheDocument()); + + expect(albumMatchBodies.at(-1)).toMatchObject({ + source: 'deezer', + album_name: 'Album A', + album_artist: 'Artist A', + }); + }); + + it('renders auto-import results from route search state', async () => { + renderImportRoute(['/import/auto?autoFilter=pending']); + + expect(await screen.findByText('1 review')).toBeInTheDocument(); + expect(screen.getAllByText('Album A').length).toBeGreaterThan(0); + expect(screen.getByText('Needs Review')).toBeInTheDocument(); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false); + expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe( + false, + ); + }); +}); diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx new file mode 100644 index 00000000..aa07185b --- /dev/null +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -0,0 +1,581 @@ +import { useQuery } from '@tanstack/react-query'; +import { type DragEvent, type KeyboardEvent, useState } from 'react'; + +import type { ImportAlbumResult } from '../-import.types'; +import styles from './import-page.module.css'; + +import { + importStagingGroupsQueryOptions, + importStagingSuggestionsQueryOptions, + matchImportAlbum, + searchImportAlbums, +} from '../-import.api'; +import { + getDisplayedMatchFile, + getEffectiveAlbumMatches, + getTrackDisplayInfo, + getUnmatchedStagingFiles, + IMPORT_PLACEHOLDER_IMAGE, +} from '../-import.helpers'; +import { useAlbumImportWorkflow } from '../-import.store'; +import { + fallbackImage, + getErrorMessage, + useImportQueueActions, + useImportStaging, +} from './import-shared'; + +function useAlbumImportViewModel() { + const { refreshStaging, stagingFiles } = useImportStaging(); + const [dragOverTrack, setDragOverTrack] = useState(null); + const [tapSelectedChip, setTapSelectedChip] = useState(null); + const groupsQuery = useQuery({ + ...importStagingGroupsQueryOptions(), + }); + const suggestionsQuery = useQuery({ + ...importStagingSuggestionsQueryOptions(), + }); + const { addQueueJob } = useImportQueueActions(); + const { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + autoGroupFilePaths, + clearAutoGroupFilePaths, + matchOverrides, + resetAlbumWorkflow, + selectedAlbum, + setAlbumMatch, + setAlbumMatchError, + setAlbumMatchLoading, + setAlbumQuery, + setAlbumResults, + setAlbumSearchContext, + setAlbumSearchError, + setAlbumSearchLoading, + setMatchOverrides, + setSelectedAlbum, + } = useAlbumImportWorkflow(); + + const resetAlbumSearch = () => { + setDragOverTrack(null); + setTapSelectedChip(null); + resetAlbumWorkflow(); + refreshStaging(); + }; + + const runAlbumSearch = async (query: string, filePaths: string[] | null = null) => { + const trimmed = query.trim(); + if (!trimmed) return; + + setAlbumSearchContext(trimmed, filePaths); + + try { + const payload = await searchImportAlbums(trimmed); + setAlbumResults(payload.albums ?? []); + } catch (error) { + setAlbumSearchError(getErrorMessage(error)); + } finally { + setAlbumSearchLoading(false); + } + }; + + const selectAlbum = async (album: ImportAlbumResult) => { + setSelectedAlbum(album); + setAlbumMatch(null); + setAlbumMatchError(null); + setAlbumMatchLoading(true); + + try { + const payload = await matchImportAlbum({ + albumId: album.id, + source: album.source, + albumName: album.name, + albumArtist: album.artist, + filePaths: autoGroupFilePaths, + }); + setAlbumMatch(payload); + setMatchOverrides({}); + setTapSelectedChip(null); + setDragOverTrack(null); + } catch (error) { + setAlbumMatchError(getErrorMessage(error)); + } finally { + clearAutoGroupFilePaths(); + setAlbumMatchLoading(false); + } + }; + + const assignMatchFile = (trackIndex: number, stagingFileIndex: number) => { + setMatchOverrides((current) => { + const next = { ...current }; + for (const [key, value] of Object.entries(next)) { + if (value === stagingFileIndex) { + delete next[Number(key)]; + } + } + next[trackIndex] = stagingFileIndex; + return next; + }); + setTapSelectedChip(null); + }; + + const unmatchTrack = (trackIndex: number) => { + setMatchOverrides((current) => { + const next = { ...current }; + delete next[trackIndex]; + if (albumMatch?.matches?.[trackIndex]?.staging_file) { + next[trackIndex] = -1; + } + return next; + }); + }; + + const processAlbum = () => { + const album = albumMatch?.album; + const matches = albumMatch?.matches ?? []; + if (!album || matches.length === 0) return; + + const effectiveMatches = getEffectiveAlbumMatches(matches, stagingFiles, matchOverrides); + if (effectiveMatches.length === 0) return; + + addQueueJob({ + type: 'album', + label: album.name, + sublabel: `${album.artist} - ${effectiveMatches.length} tracks`, + imageUrl: album.image_url, + items: effectiveMatches, + albumData: album, + }); + resetAlbumSearch(); + }; + + return { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + dragOverTrack, + groups: groupsQuery.data?.groups ?? [], + matchOverrides, + onAlbumQueryChange: setAlbumQuery, + onAutoRematch: () => { + setMatchOverrides({}); + setTapSelectedChip(null); + setDragOverTrack(null); + }, + onBackToSearch: resetAlbumSearch, + onDragOverTrack: setDragOverTrack, + onProcessAlbum: processAlbum, + onRunGroupSearch: (group: { + album: string; + artist: string; + file_count: number; + file_paths: string[]; + }) => { + void runAlbumSearch(`${group.artist} ${group.album}`, group.file_paths); + }, + onRunSearch: () => { + void runAlbumSearch(albumQuery); + }, + onSelectAlbum: (album: ImportAlbumResult) => { + void selectAlbum(album); + }, + onTapAssign: assignMatchFile, + onTapSelectChip: (index: number) => { + setTapSelectedChip((current) => (current === index ? null : index)); + }, + onUnmatchTrack: unmatchTrack, + selectedAlbum, + stagingFiles, + suggestions: suggestionsQuery.data?.suggestions ?? [], + suggestionsReady: suggestionsQuery.data?.ready ?? true, + tapSelectedChip, + }; +} + +type AlbumImportViewModel = ReturnType; + +export function AlbumImportTab() { + const viewModel = useAlbumImportViewModel(); + + return ; +} + +function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) { + const { + albumMatch, + albumMatchError, + albumMatchLoading, + albumQuery, + albumResults, + albumSearchError, + albumSearchLoading, + groups, + onAlbumQueryChange, + onBackToSearch, + onRunGroupSearch, + onRunSearch, + onSelectAlbum, + selectedAlbum, + suggestions, + suggestionsReady, + } = viewModel; + + const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch; + + return ( + <> +
+ {albumResults === null && ( + <> + {groups.length > 0 && ( +
+
Auto-Detected Albums
+
+ {groups.map((group, index) => ( + + ))} +
+
+ )} + +
+
Suggested from your import folder
+
+ {suggestions.length > 0 ? ( + suggestions.map((album) => ( + + )) + ) : suggestionsReady ? null : ( +
Loading suggestions...
+ )} +
+
+ + )} + +
+ onAlbumQueryChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onRunSearch(); + }} + /> + + +
+ +
+ {albumSearchLoading ? ( +
Searching...
+ ) : albumSearchError ? ( +
Error: {albumSearchError}
+ ) : albumResults?.length === 0 ? ( +
No albums found
+ ) : ( + albumResults?.map((album) => ( + + )) + )} +
+
+ +
+ {albumMatchLoading ? ( +
Matching files to tracklist...
+ ) : albumMatchError ? ( +
Error: {albumMatchError}
+ ) : albumMatch?.album ? ( + + ) : ( +
Select an album to start matching files.
+ )} +
+ + ); +} + +function AlbumCard({ + album, + onSelect, +}: { + album: ImportAlbumResult; + onSelect: (album: ImportAlbumResult) => void; +}) { + return ( + + ); +} + +function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { + const { + albumMatch, + albumMatchError, + albumMatchLoading, + dragOverTrack, + matchOverrides, + onAutoRematch, + onBackToSearch, + onDragOverTrack, + onProcessAlbum, + onTapAssign, + onTapSelectChip, + onUnmatchTrack, + stagingFiles, + tapSelectedChip, + } = viewModel; + + const effectiveMatches = getEffectiveAlbumMatches( + albumMatch?.matches ?? [], + stagingFiles, + matchOverrides, + ); + const unmatchedFiles = getUnmatchedStagingFiles( + albumMatch?.matches ?? [], + stagingFiles, + matchOverrides, + ); + const matchedCount = effectiveMatches.length; + + return albumMatchLoading ? ( +
Matching files to tracklist...
+ ) : albumMatchError ? ( +
Error: {albumMatchError}
+ ) : albumMatch?.album ? ( + <> +
+ {albumMatch.album.name} +
+
{albumMatch.album.name}
+
{albumMatch.album.artist}
+
+ {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '} + {albumMatch.album.release_date?.substring(0, 4) || ''} +
+
+
+ +
+

Track Matching

+
+ + +
+
+ +
+ {(albumMatch.matches ?? []).map((match, index) => { + const trackInfo = getTrackDisplayInfo(match, index); + const { confidence, file } = getDisplayedMatchFile( + match, + index, + stagingFiles, + matchOverrides, + ); + const confidencePercent = Math.round(confidence * 100); + return ( +
{ + if (tapSelectedChip !== null) onTapAssign(index, tapSelectedChip); + }} + onDragOver={(event) => { + event.preventDefault(); + event.dataTransfer.dropEffect = 'move'; + onDragOverTrack(index); + }} + onDragLeave={() => onDragOverTrack(null)} + onDrop={(event) => { + event.preventDefault(); + onDragOverTrack(null); + const stagingFileIndex = Number(event.dataTransfer.getData('text/plain')); + if (Number.isFinite(stagingFileIndex)) onTapAssign(index, stagingFileIndex); + }} + > + {trackInfo.displayTrackNumber} + {trackInfo.name} + + {file ? ( + <> + {file.filename} + = 0.7 ? '' : styles.low + }`} + > + {confidencePercent}% + + + ) : ( + Drop a file here + )} + + + {file ? ( + + ) : null} + +
+ ); + })} +
+ +
+
+ Unmatched Files ({unmatchedFiles.length}) +
+
+ {unmatchedFiles.length === 0 ? ( + All files matched + ) : ( + unmatchedFiles.map(({ file, index }) => ( + { + event.stopPropagation(); + onTapSelectChip(index); + }} + onDragStart={(event: DragEvent) => { + event.dataTransfer.setData('text/plain', String(index)); + event.dataTransfer.effectAllowed = 'move'; + }} + onKeyDown={(event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onTapSelectChip(index); + } + }} + > + {file.filename} + + )) + )} +
+
+ +
+
+ {matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched +
+ +
+ + ) : ( +
Select an album to start matching files.
+ ); +} diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx new file mode 100644 index 00000000..c8ac1075 --- /dev/null +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -0,0 +1,580 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; + +import type { + ImportAutoFilter, + ImportAutoImportResult, + ImportAutoImportStatusPayload, +} from '../-import.types'; + +import { + approveAllAutoImportResults, + approveAutoImportResult, + autoImportResultsQueryOptions, + autoImportSettingsQueryOptions, + autoImportStatusQueryOptions, + clearCompletedAutoImportResults, + invalidateAutoImportQueries, + rejectAutoImportResult, + saveAutoImportSettings, + toggleAutoImport, + triggerAutoImportScan, +} from '../-import.api'; +import { + filterAutoImportResults, + getActiveImportLines, + getAutoImportCounts, + getAutoImportStatusClass, + getAutoImportStatusMeta, + getAutoImportStatusText, + getAutoImportTimeAgo, + getConfidenceClass, + parseAutoImportMatchData, +} from '../-import.helpers'; +import styles from './import-page.module.css'; +import { fallbackImage, getErrorMessage, RefreshIcon } from './import-shared'; + +export function AutoImportPanel({ + autoFilter, + onFilterChange, +}: { + autoFilter: ImportAutoFilter; + onFilterChange: (filter: ImportAutoFilter) => void; +}) { + const queryClient = useQueryClient(); + const [confidence, setConfidence] = useState(90); + const [interval, setInterval] = useState(60); + const [expandedRows, setExpandedRows] = useState>(() => new Set()); + + const statusQuery = useQuery({ + ...autoImportStatusQueryOptions(), + refetchInterval: 5000, + }); + const settingsQuery = useQuery({ + ...autoImportSettingsQueryOptions(), + }); + const resultsQuery = useQuery({ + ...autoImportResultsQueryOptions(), + refetchInterval: 5000, + }); + + useEffect(() => { + const settings = settingsQuery.data; + if (!settings) return; + setConfidence(Math.round((settings.confidence_threshold ?? 0.9) * 100)); + setInterval(settings.scan_interval ?? 60); + }, [settingsQuery.data]); + + const invalidateAutoImport = () => { + void invalidateAutoImportQueries(queryClient); + }; + + const toggleMutation = useMutation({ + mutationFn: toggleAutoImport, + onSuccess: (_, enabled) => { + window.showToast?.(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const saveSettingsMutation = useMutation({ + mutationFn: saveAutoImportSettings, + onSuccess: () => { + window.showToast?.('Settings saved', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const scanMutation = useMutation({ + mutationFn: triggerAutoImportScan, + onSuccess: () => { + window.showToast?.('Scan triggered', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const approveMutation = useMutation({ + mutationFn: approveAutoImportResult, + onSuccess: () => { + window.showToast?.('Approved', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const rejectMutation = useMutation({ + mutationFn: rejectAutoImportResult, + onSuccess: () => { + window.showToast?.('Dismissed', 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const approveAllMutation = useMutation({ + mutationFn: async () => { + const confirmed = await confirmAction({ + title: 'Approve All', + message: 'Approve and import all pending review items?', + confirmText: 'Approve All', + }); + if (!confirmed) return null; + return await approveAllAutoImportResults(); + }, + onSuccess: (count) => { + if (count === null) return; + window.showToast?.(`Approved ${count} items`, 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + const clearMutation = useMutation({ + mutationFn: clearCompletedAutoImportResults, + onSuccess: (count) => { + window.showToast?.(`Cleared ${count} imported items`, 'success'); + invalidateAutoImport(); + }, + onError: showMutationError, + }); + + const allResults = resultsQuery.data?.results ?? []; + const results = filterAutoImportResults(allResults, autoFilter); + const counts = getAutoImportCounts(allResults); + const activeLines = getActiveImportLines(statusQuery.data); + const statusClassName = getAutoImportStatusClass(statusQuery.data); + + if (statusQuery.error) { + return ( +
+ Auto-import is unavailable: {getErrorMessage(statusQuery.error)} +
+ ); + } + + return ( + <> +
+
+ + + {getAutoImportStatusText(statusQuery.data)} + + {statusQuery.data?.running ? ( + + ) : null} +
+ {statusQuery.data?.running ? ( +
+ + + +
+ ) : null} + {activeLines.length > 0 ? ( +
+
+ {activeLines.length === 1 + ? `Processing ${activeLines[0]}` + : `Processing ${activeLines.length} imports:`} + {activeLines.length > 1 + ? activeLines.map((line) =>
{line}
) + : null} +
+
+
+
+
+ ) : null} +
+ + {allResults.length > 0 ? ( + <> +
+ + {counts.imported} imported + + + {counts.review} review + + + {counts.failed} failed + +
+
+ {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( + + ))} +
+ {counts.review > 0 ? ( + + ) : null} + {counts.imported + counts.failed > 0 ? ( + + ) : null} +
+ + ) : null} + +
+ {resultsQuery.error ? ( +
+ Failed to load imports: {getErrorMessage(resultsQuery.error)} +
+ ) : allResults.length === 0 ? ( +
+

No imports yet. Drop album folders or single tracks into your import folder.

+
+ ) : results.length === 0 ? ( +
+

No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.

+
+ ) : ( + results.map((result, index) => ( + approveMutation.mutate(result.id)} + onReject={() => rejectMutation.mutate(result.id)} + onToggle={() => { + setExpandedRows((current) => { + const next = new Set(current); + if (next.has(result.id)) next.delete(result.id); + else next.add(result.id); + return next; + }); + }} + /> + )) + )} +
+ + ); +} + +function AutoImportResultCard({ + approvePending, + expanded, + index, + rejectPending, + result, + status, + onApprove, + onReject, + onToggle, +}: { + approvePending: boolean; + expanded: boolean; + index: number; + rejectPending: boolean; + result: ImportAutoImportResult; + status: ImportAutoImportStatusPayload | undefined; + onApprove: () => void; + onReject: () => void; + onToggle: () => void; + }) { + const confidencePercent = Math.round((result.confidence || 0) * 100); + const confidenceClass = getConfidenceClass(confidencePercent); + const statusMeta = getAutoImportStatusMeta(result.status); + const liveActive = status?.active_imports?.find( + (item) => item.folder_hash === result.folder_hash, + ); + const isLiveProcessing = result.status === 'processing' && liveActive?.status === 'processing'; + const liveTrackIndex = isLiveProcessing ? liveActive?.track_index || 0 : 0; + const liveTrackTotal = isLiveProcessing ? liveActive?.track_total || 0 : 0; + const liveTrackName = isLiveProcessing ? liveActive?.track_name || '' : ''; + const matchData = parseAutoImportMatchData(result.match_data); + const trackDetails = + matchData.matches?.map((match) => ({ + name: match.track_name || match.track?.name || 'Unknown', + file: match.file ? match.file.split(/[/\\]/).pop() || '?' : '?', + confidence: Math.round((match.confidence || 0) * 100), + })) ?? []; + const matchSummary = + isLiveProcessing && liveTrackTotal > 0 + ? `track ${liveTrackIndex}/${liveTrackTotal}: ${liveTrackName}` + : matchData.total_tracks && matchData.total_tracks > 0 + ? `${matchData.matched_count || 0}/${matchData.total_tracks} tracks` + : `${result.total_files || 0} files`; + const methodLabel = getMethodLabel(result.identification_method); + const timeAgo = getAutoImportTimeAgo(result.created_at); + const statusCardClass = getAutoImportCardClass(statusMeta.className); + const statusBadgeClass = getAutoImportBadgeClass(statusMeta.className); + const confidenceFillClass = getAutoImportConfidenceClass(confidenceClass); + + return ( +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onToggle(); + } + }} + > +
+
+ {result.image_url ? ( + + ) : ( +
Album
+ )} +
+
+
{result.album_name || result.folder_name}
+
{result.artist_name || 'Unknown Artist'}
+
+ {matchSummary} + {methodLabel ? {methodLabel} : null} + {timeAgo ? {timeAgo} : null} +
+ {result.error_message ? ( +
{result.error_message}
+ ) : null} +
+
+
+ {statusMeta.icon} {statusMeta.label} +
+
+
+
+
{confidencePercent}% confidence
+ {result.status === 'pending_review' ? ( +
+ + +
+ ) : null} +
+
+
{result.folder_name}
+ {trackDetails.length > 0 ? ( +
+
+ Track + Matched File + Conf +
+ {trackDetails.map((track, trackIndex) => { + const rowClassName = [ + styles.autoImportTrackRow, + isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 === liveTrackIndex + ? styles.autoImportTrackRowActive + : '', + isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 < liveTrackIndex + ? styles.autoImportTrackRowDone + : '', + ] + .filter(Boolean) + .join(' '); + return ( +
+ {track.name} + {track.file} + + {track.confidence}% + +
+ ); + })} +
+ ) : null} +
+ ); +} + +function showMutationError(error: unknown) { + window.showToast?.(getErrorMessage(error), 'error'); +} + +function getAutoImportCardClass(status: string): string { + const classes: Record = { + completed: styles.autoImportCompleted, + review: styles.autoImportReview, + failed: styles.autoImportFailed, + processing: styles.autoImportProcessing, + }; + + return classes[status] ?? ''; +} + +function getAutoImportBadgeClass(status: string): string { + const classes: Record = { + completed: styles.autoImportBadgeCompleted, + review: styles.autoImportBadgeReview, + failed: styles.autoImportBadgeFailed, + neutral: styles.autoImportBadgeNeutral, + processing: styles.autoImportBadgeProcessing, + }; + + return classes[status] ?? ''; +} + +function getAutoImportConfidenceClass(status: string): string { + const classes: Record = { + high: styles.autoImportConfHigh, + medium: styles.autoImportConfMedium, + low: styles.autoImportConfLow, + }; + + return classes[status] ?? ''; +} + +function getMethodLabel(method: string | null | undefined): string { + const labels: Record = { + tags: 'Tags', + folder_name: 'Folder Name', + acoustid: 'AcoustID', + filename: 'Filename', + }; + return method ? labels[method] || method : ''; +} + +function titleCase(value: string): string { + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +async function confirmAction({ + title, + message, + confirmText, +}: { + title: string; + message: string; + confirmText: string; +}): Promise { + if (window.showConfirmDialog) { + return await window.showConfirmDialog({ title, message, confirmText }); + } + return window.confirm(message); +} diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css new file mode 100644 index 00000000..2f7e71b9 --- /dev/null +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -0,0 +1,1645 @@ +.importPageContainer { + max-width: 1200px; + margin: 0 auto; + padding: 24px 32px; +} + +.importPageHeader { + margin-bottom: 20px; +} + +.importPageTitleRow { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; +} + +.importPageTitle { + font-size: 28px; + font-weight: 700; + margin: 0; + letter-spacing: -0.3px; + font-family: + 'SF Pro Display', + -apple-system, + BlinkMacSystemFont, + sans-serif; + display: flex; + align-items: center; + gap: 14px; +} + +.importPageTitle > span { + background: linear-gradient( + 90deg, + #ffffff 0%, + rgb(var(--accent-light-rgb)) 50%, + rgb(var(--accent-rgb)) 100% + ); + background-size: 200% 100%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + animation: page-title-shimmer 6s ease-in-out infinite; +} + +.importPageRefreshBtn { + display: flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #ccc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageRefreshBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.importPageStagingBar { + display: flex; + align-items: center; + gap: 16px; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +.importStagingPath { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.importStagingStats { + color: rgb(var(--accent-light-rgb)); + font-weight: 500; + white-space: nowrap; +} + +/* Tab Bar */ +.importPageTabBar { + display: flex; + gap: 4px; + margin-bottom: 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + padding-bottom: 0; +} + +.importPageTab { + padding: 10px 24px; + background: none; + border: none; + border-bottom: 2px solid transparent; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + margin-bottom: -1px; +} + +.importPageTab:hover { + color: rgba(255, 255, 255, 0.8); +} + +.importPageTab.active { + color: rgb(var(--accent-light-rgb)); + border-bottom-color: rgb(var(--accent-light-rgb)); +} + +.importPageTabContent { + display: none; +} + +.importPageTabContent.active { + display: block; +} + +.hidden { + display: none; +} + +/* Section labels */ +.importPageSectionLabel { + font-size: 13px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 500; +} + +/* Search bar */ +.importPageSearchBar { + display: flex; + gap: 8px; + margin-bottom: 20px; + position: relative; +} + +.importPageSearchInput { + flex: 1; + padding: 10px 16px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + color: #fff; + font-size: 14px; + outline: none; + transition: border-color 0.2s; +} + +.importPageSearchInput:focus { + border-color: rgba(var(--accent-light-rgb), 0.5); +} + +.importPageSearchBtn { + padding: 10px 20px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 10px; + color: #000; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.importPageSearchBtn:hover { + background: #1fdf64; +} + +.importPageClearBtn { + position: absolute; + right: 100px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + font-size: 16px; + cursor: pointer; + padding: 4px 8px; +} + +.importPageClearBtn:hover { + color: #fff; +} + +/* Album grid */ +.importPageAlbumGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 16px; + margin-bottom: 24px; +} + +.importPageAlbumCard { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 12px; + cursor: pointer; + transition: all 0.2s; + text-align: center; + color: inherit; + font: inherit; +} + +.importPageAutoGroups .importPageAlbumGrid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 10px; +} + +.importPageAutoGroupCard { + display: flex; + align-items: center; + gap: 12px; + text-align: left; +} + +.importPageAutoGroupCount { + width: 48px; + height: 48px; + border-radius: 8px; + background: rgba(var(--accent-rgb, 29, 185, 84), 0.15); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 1.2em; +} + +.importPageAutoGroupInfo { + min-width: 0; +} + +.importPageAlbumCard:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(var(--accent-light-rgb), 0.3); + transform: translateY(-2px); +} + +.importPageAlbumCard img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 8px; + margin-bottom: 8px; +} + +.importPageAlbumCardTitle { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-bottom: 2px; +} + +.importPageAlbumCardArtist { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageAlbumCardMeta { + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + margin-top: 4px; +} + +/* Album hero (selected album) */ +.importPageAlbumHero { + display: flex; + gap: 20px; + padding: 20px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + margin-bottom: 20px; + align-items: center; +} + +.importPageAlbumHero img { + width: 120px; + height: 120px; + border-radius: 10px; + object-fit: cover; + flex-shrink: 0; +} + +.importPageAlbumHeroInfo { + flex: 1; + min-width: 0; +} + +.importPageAlbumHeroTitle { + font-size: 22px; + font-weight: 700; + color: #fff; + margin-bottom: 4px; +} + +.importPageAlbumHeroArtist { + font-size: 15px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; +} + +.importPageAlbumHeroMeta { + font-size: 12px; + color: rgba(255, 255, 255, 0.35); +} + +/* Match header */ +.importPageMatchHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.importPageMatchHeader h3 { + font-size: 16px; + font-weight: 600; + color: #fff; + margin: 0; +} + +.importPageMatchActions { + display: flex; + gap: 8px; +} + +/* Match list */ +.importPageMatchList { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 16px; +} + +.importPageMatchRow { + display: grid; + grid-template-columns: 36px 1fr 1fr 36px; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + min-height: 44px; + transition: all 0.2s; +} + +.importPageMatchRow.dragOver { + background: rgba(var(--accent-light-rgb), 0.08); + border-color: rgba(var(--accent-light-rgb), 0.4); + box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15); +} + +.importPageMatchRow.matched { + border-color: rgba(var(--accent-light-rgb), 0.2); +} + +.importPageMatchNum { + font-size: 13px; + color: rgba(255, 255, 255, 0.3); + text-align: center; + font-weight: 500; +} + +.importPageMatchTrack { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageMatchFile { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + display: flex; + align-items: center; + gap: 6px; + overflow: hidden; +} + +.importPageMatchFile.hasFile { + color: rgb(var(--accent-light-rgb)); +} + +.importPageMatchFileName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.importPageMatchConfidence { + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + background: rgba(var(--accent-light-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + font-weight: 500; + white-space: nowrap; +} + +.importPageMatchConfidence.low { + background: rgba(255, 165, 0, 0.15); + color: #ffa500; +} + +.importPageMatchDropZone { + font-size: 11px; + color: rgba(255, 255, 255, 0.25); + font-style: italic; +} + +.importPageMatchUnmatch { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + font-size: 14px; + cursor: pointer; + padding: 4px; + border-radius: 4px; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.importPageMatchUnmatch:hover { + color: #ff4444; + background: rgba(255, 68, 68, 0.1); +} + +/* Unmatched file pool */ +.importPageUnmatchedPool { + padding: 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 12px; + margin-bottom: 16px; +} + +.importPagePoolLabel { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 10px; + font-weight: 500; +} + +.importPagePoolChips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.importPageFileChip { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 20px; + font-size: 12px; + color: #ccc; + cursor: grab; + transition: all 0.2s; + user-select: none; +} + +.importPageFileChip:active { + cursor: grabbing; +} + +.importPageFileChip:hover { + background: rgba(255, 255, 255, 0.14); + border-color: rgba(var(--accent-light-rgb), 0.3); + color: #fff; +} + +.importPageFileChip.selected { + background: rgba(var(--accent-light-rgb), 0.15); + border-color: rgba(var(--accent-light-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +.importPagePoolEmpty { + font-size: 12px; + color: rgba(255, 255, 255, 0.2); + font-style: italic; +} + +/* Match footer */ +.importPageMatchFooter { + display: flex; + align-items: center; + justify-content: space-between; + padding-top: 16px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.importPageMatchStats { + font-size: 13px; + color: rgba(255, 255, 255, 0.5); +} + +/* Buttons */ +.importPageProcessBtn { + padding: 10px 24px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 10px; + color: #000; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.importPageProcessBtn:hover { + background: #1fdf64; +} + +.importPageProcessBtn:disabled { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.3); + cursor: not-allowed; +} + +.importPageSecondaryBtn { + padding: 8px 16px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #ccc; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageSecondaryBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.importPageBackBtn { + padding: 8px 16px; + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + transition: all 0.2s; +} + +.importPageBackBtn:hover { + color: #fff; + border-color: rgba(255, 255, 255, 0.2); +} + +/* Singles */ +.importPageSinglesHeader { + display: flex; + align-items: center; + justify-content: flex-end; + margin-bottom: 16px; +} + +.importPageSinglesActions { + display: flex; + gap: 8px; + align-items: center; +} + +.importPageSinglesList { + display: flex; + flex-direction: column; + gap: 4px; +} + +.importPageSingleItem { + display: grid; + grid-template-columns: 32px 1fr auto; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + transition: all 0.2s; +} + +.importPageSingleItem.matched { + border-color: rgba(var(--accent-light-rgb), 0.2); +} + +.importPageSingleCheckbox { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.2); + background: transparent; + padding: 0; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + flex-shrink: 0; +} + +.importPageSingleCheckbox.checked { + background: rgb(var(--accent-light-rgb)); + border-color: rgb(var(--accent-light-rgb)); +} + +.importPageSingleCheckbox.checked::after { + content: '✓'; + color: #000; + font-size: 12px; + font-weight: 700; +} + +.importPageSingleInfo { + min-width: 0; +} + +.importPageSingleFilename { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageSingleMeta { + font-size: 11px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.importPageSingleMatchedInfo { + font-size: 12px; + color: rgb(var(--accent-light-rgb)); + margin-top: 4px; + display: flex; + align-items: center; + gap: 6px; +} + +.importPageSingleMatchedChange { + background: transparent; + border: 0; + padding: 0; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + font-size: 11px; + text-decoration: underline; +} + +.importPageSingleMatchedChange:hover { + color: #fff; +} + +.importPageSingleActions { + display: flex; + gap: 6px; + align-items: center; + flex-shrink: 0; +} + +.importPageIdentifyBtn { + padding: 6px 12px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + color: #ccc; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; + display: flex; + align-items: center; + gap: 4px; +} + +.importPageIdentifyBtn:hover { + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +/* Inline search panel for singles */ +.importPageSingleSearchPanel { + grid-column: 1 / -1; + padding: 12px 0 4px 44px; +} + +.importPageSingleSearchBar { + display: flex; + gap: 8px; + margin-bottom: 10px; +} + +.importPageSingleSearchInput { + flex: 1; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + color: #fff; + font-size: 13px; + outline: none; +} + +.importPageSingleSearchInput:focus { + border-color: rgba(var(--accent-light-rgb), 0.4); +} + +.importPageSingleSearchGo { + padding: 8px 14px; + background: rgb(var(--accent-light-rgb)); + border: none; + border-radius: 8px; + color: #000; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.importPageSingleSearchResults { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 200px; + overflow-y: auto; +} + +.importPageSingleResultItem { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s; + color: inherit; + font: inherit; + text-align: left; +} + +.importPageSingleResultItem:hover { + background: rgba(var(--accent-light-rgb), 0.08); + border-color: rgba(var(--accent-light-rgb), 0.25); +} + +.importPageSingleResultImg { + width: 36px; + height: 36px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; +} + +.importPageSingleResultInfo { + flex: 1; + min-width: 0; +} + +.importPageSingleResultName { + font-size: 13px; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageSingleResultDetail { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); +} + +.importPageSingleResultSelect { + padding: 4px 12px; + background: rgba(var(--accent-light-rgb), 0.15); + border: 1px solid rgba(var(--accent-light-rgb), 0.3); + border-radius: 6px; + color: rgb(var(--accent-light-rgb)); + font-size: 11px; + font-weight: 600; + cursor: pointer; + flex-shrink: 0; +} + +.importPageSingleResultSelect:hover { + background: rgba(var(--accent-light-rgb), 0.25); +} + +/* Empty state */ +.importPageEmptyState { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.3); + font-size: 14px; +} + +/* Suggestions */ +.importPageSuggestions { + margin-bottom: 24px; +} + +/* Processing Queue */ +.importPageQueue { + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.importPageQueueHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.importPageQueueTitle { + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.6); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.importPageQueueClear { + background: none; + border: none; + color: rgba(255, 255, 255, 0.3); + font-size: 12px; + cursor: pointer; + padding: 2px 8px; +} + +.importPageQueueClear:hover { + color: #fff; +} + +.importPageQueueList { + display: flex; + flex-direction: column; +} + +.importPageQueueItem { + display: grid; + grid-template-columns: 44px 1fr auto; + gap: 12px; + align-items: center; + padding: 10px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); +} + +.importPageQueueItem:last-child { + border-bottom: none; +} + +.importPageQueueArt { + width: 44px; + height: 44px; + border-radius: 6px; + object-fit: cover; +} + +.importPageQueueArtEmpty { + background: rgba(255, 255, 255, 0.06); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: rgba(255, 255, 255, 0.3); +} + +.importPageFlexSpacer { + flex: 1; +} + +.importPageQueueInfo { + min-width: 0; +} + +.importPageQueueName { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.importPageQueueDetail { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.importPageQueueProgress { + width: 120px; + flex-shrink: 0; +} + +.importPageQueueBar { + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; + margin-bottom: 4px; +} + +.importPageQueueFill { + height: 100%; + background: rgb(var(--accent-light-rgb)); + border-radius: 2px; + width: 0%; + transition: width 0.3s ease; +} + +.importPageQueueFill.error { + background: #ef4444; +} + +.importPageQueueStatus { + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + text-align: right; +} + +.importPageQueueStatus.done { + color: rgb(var(--accent-light-rgb)); +} + +.importPageQueueStatus.error { + color: #ef4444; +} + +/* ======================================== + END IMPORT PAGE + .importPageFileChip { + cursor: pointer; + } + + .importPageMatchRow { + cursor: pointer; + } +} + +/* Import Page — small screen */ +@media (max-width: 768px) { + .importPageContainer { + padding: 16px; + } + + .importPageTitle { + font-size: 22px; + } + + .importPageAlbumGrid { + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 10px; + } + + .importPageAlbumHero { + flex-direction: column; + text-align: center; + } + + .importPageAlbumHero img { + width: 100px; + height: 100px; + } + + .importPageMatchRow { + grid-template-columns: 28px 1fr; + gap: 8px; + } + + .importPageMatchFile { + grid-column: 1 / -1; + padding-left: 28px; + } + + .importPageMatchUnmatch { + grid-column: 1 / -1; + justify-self: end; + } + + .importPageSinglesHeader { + flex-direction: column; + align-items: flex-start; + } + + .importPageSinglesActions { + width: 100%; + flex-wrap: wrap; + } + + .importPageSingleItem { + grid-template-columns: 28px 1fr; + } + + .importPageSingleActions { + grid-column: 1 / -1; + justify-self: end; + } + + .importPageSingleSearchPanel { + padding-left: 0; + } + + .importPageStagingBar { + flex-direction: column; + gap: 4px; + align-items: flex-start; + } +} +.autoImportControls { + padding: 12px 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + margin-bottom: 16px; +} + +.autoImportToggleRow { + display: flex; + align-items: center; + gap: 12px; +} + +.autoImportToggleLabel { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + font-weight: 600; + color: rgba(255, 255, 255, 0.7); +} + +.autoImportToggleLabel input { + display: none; +} + +.autoImportToggleSlider { + position: relative; + width: 44px; + height: 24px; + background: rgba(255, 255, 255, 0.12); + border-radius: 12px; + transition: background 0.2s ease; + display: inline-block; +} + +.autoImportToggleSlider::after { + content: ''; + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s ease; +} + +.autoImportToggleLabel input:checked + .autoImportToggleSlider { + background: var(--accent-color, #6366f1); +} + +.autoImportToggleLabel input:checked + .autoImportToggleSlider::after { + transform: translateX(20px); +} + +.autoImportStatus { + font-size: 12px; + font-weight: 500; + padding: 2px 10px; + border-radius: 6px; +} +.autoImportStatus.active { + color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} +.autoImportStatus.scanning { + color: rgb(var(--accent-light-rgb)); + background: rgba(var(--accent-rgb), 0.1); +} +.autoImportStatus.disabled { + color: rgba(255, 255, 255, 0.3); +} + +.autoImportSettingsRow { + display: flex; + align-items: center; + gap: 16px; + margin-top: 10px; + flex-wrap: wrap; + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.autoImportSettingsRow label { + display: flex; + align-items: center; + gap: 6px; +} +.autoImportSettingsRow input[type='range'] { + width: 100px; +} +.autoImportSettingsRow select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + color: #fff; + border-radius: 6px; + padding: 3px 8px; + font-size: 12px; +} + +.autoImportEmpty { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.3); + font-size: 13px; +} + +/* Result cards */ +.autoImportCard { + display: flex; + flex-direction: column; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 12px; + margin-bottom: 8px; + transition: all 0.2s; +} + +.autoImportCardTop { + display: flex; + gap: 14px; + align-items: center; +} + +.autoImportCard:hover { + background: rgba(255, 255, 255, 0.04); + border-color: rgba(255, 255, 255, 0.1); +} + +.autoImportCompleted { + border-left: 3px solid #4ade80; +} +.autoImportReview { + border-left: 3px solid #fbbf24; +} +.autoImportFailed { + border-left: 3px solid #f87171; +} +.autoImportProcessing { + border-left: 3px solid #60a5fa; +} + +.autoImportCardArt { + width: 56px; + height: 56px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; +} + +.autoImportCardArtFallback { + width: 56px; + height: 56px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + display: flex; + align-items: center; + justify-content: center; + font-size: 22px; + opacity: 0.3; + flex-shrink: 0; +} + +.autoImportCardCenter { + flex: 1; + min-width: 0; +} + +.autoImportCardAlbum { + font-size: 14px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardArtist { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardFolder { + font-size: 10px; + color: rgba(255, 255, 255, 0.25); + margin-top: 2px; +} + +.autoImportMatchInfo { + font-size: 10px; + color: rgba(255, 255, 255, 0.35); + margin-top: 2px; +} + +.autoImportCardError { + font-size: 10px; + color: #f87171; + margin-top: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportCardRight { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; + flex-shrink: 0; + min-width: 80px; +} + +.autoImportConfidenceBar { + width: 60px; + height: 4px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.autoImportConfidenceFill { + height: 100%; + border-radius: 2px; +} +.autoImportConfHigh { + background: #4ade80; +} +.autoImportConfMedium { + background: #fbbf24; +} +.autoImportConfLow { + background: #f87171; +} + +.autoImportConfidenceText { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); +} + +/* Scan Now button */ +.autoImportScanNowBtn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + padding: 4px 12px; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; + transition: all 0.15s; + margin-left: auto; +} + +.autoImportScanNowBtn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.9); +} + +/* Live progress */ +.autoImportProgress { + margin-top: 8px; + padding: 8px 12px; + background: rgba(var(--accent-rgb), 0.04); + border: 1px solid rgba(var(--accent-rgb), 0.1); + border-radius: 8px; +} + +.autoImportProgressText { + font-size: 11px; + color: rgba(255, 255, 255, 0.6); + margin-bottom: 4px; +} + +.autoImportProgressBar { + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 2px; + overflow: hidden; +} + +.autoImportProgressFill { + height: 100%; + background: rgba(var(--accent-rgb), 0.6); + border-radius: 2px; + width: 100%; + animation: adlPulse 1.5s ease-in-out infinite; +} + +/* Stats summary */ +.autoImportStats { + display: flex; + gap: 16px; + padding: 8px 0; + margin-bottom: 4px; +} + +.autoImportStat { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + font-weight: 500; +} + +.autoImportStatReview { + color: #fbbf24; +} +.autoImportStatFailed { + color: #f87171; +} + +/* Filter pills */ +.autoImportFilters { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 0; + margin-bottom: 8px; +} + +.autoImportFilterPill { + padding: 6px 16px; + border-radius: 8px; + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.45); + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.autoImportFilterPill:hover { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); +} + +.autoImportFilterPill.active { + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.15); +} + +.autoImportActionBtn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 9px 18px; + border-radius: 10px; + border: 1px solid transparent; + cursor: pointer; + font-size: 13px; + font-weight: 500; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); + white-space: nowrap; + letter-spacing: 0.1px; +} + +.autoImportActionPrimary { + background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.85)); + color: #fff; + border-color: rgba(var(--accent-rgb), 0.3); + box-shadow: 0 2px 8px rgba(var(--accent-rgb), 0.2); +} + +.autoImportActionPrimary:hover { + transform: translateY(-1px); + box-shadow: 0 4px 14px rgba(var(--accent-rgb), 0.3); + filter: brightness(1.1); +} + +.autoImportActionPrimary:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.autoImportActionSecondary { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.08); +} + +.autoImportActionSecondary:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; + border-color: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); +} + +/* Batch action buttons */ +.autoImportBatchBtn { + font-size: 11px; + padding: 4px 12px; + border-radius: 6px; + border: 1px solid rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); + color: rgba(var(--accent-rgb), 1); + cursor: pointer; + transition: all 0.15s; +} + +.autoImportBatchBtn:hover { + background: rgba(var(--accent-rgb), 0.15); +} + +.autoImportClearBtn { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.5); +} + +.autoImportClearBtn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.8); +} + +.autoImportCardMeta { + display: flex; + gap: 8px; + align-items: center; + font-size: 10px; + color: rgba(255, 255, 255, 0.3); + margin-top: 3px; +} + +.autoImportMethodBadge { + background: rgba(255, 255, 255, 0.06); + padding: 1px 6px; + border-radius: 4px; + font-size: 9px; + color: rgba(255, 255, 255, 0.45); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.autoImportCardFolderPath { + font-size: 9px; + color: rgba(255, 255, 255, 0.15); + margin-top: 6px; + padding-top: 6px; + border-top: 1px solid rgba(255, 255, 255, 0.03); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Expandable track list */ +.autoImportTrackList { + display: none; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.autoImportTrackList.expanded { + display: block; +} + +.autoImportTrackListHeader { + display: grid; + grid-template-columns: 1fr 1fr 50px; + gap: 8px; + font-size: 9px; + font-weight: 600; + color: rgba(255, 255, 255, 0.3); + text-transform: uppercase; + letter-spacing: 0.5px; + padding-bottom: 4px; + margin-bottom: 4px; + border-bottom: 1px solid rgba(255, 255, 255, 0.03); +} + +.autoImportTrackRow { + display: grid; + grid-template-columns: 1fr 1fr 50px; + gap: 8px; + padding: 3px 0; + font-size: 11px; +} + +.autoImportTrackName { + color: rgba(255, 255, 255, 0.7); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportTrackFile { + color: rgba(255, 255, 255, 0.3); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.autoImportTrackConf { + text-align: right; + font-weight: 600; + font-size: 10px; +} + +.autoImportTrackConf.autoImportConfHigh { + color: #4ade80; +} +.autoImportTrackConf.autoImportConfMedium { + color: #fbbf24; +} +.autoImportTrackConf.autoImportConfLow { + color: #f87171; +} + +.autoImportStatusBadge { + font-size: 9px; + font-weight: 600; + padding: 2px 8px; + border-radius: 6px; + white-space: nowrap; +} +.autoImportBadgeCompleted { + background: rgba(74, 222, 128, 0.1); + color: #4ade80; +} +.autoImportBadgeReview { + background: rgba(251, 191, 36, 0.1); + color: #fbbf24; +} +.autoImportBadgeFailed { + background: rgba(248, 113, 113, 0.1); + color: #f87171; +} +.autoImportBadgeNeutral { + background: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.4); +} +.autoImportBadgeProcessing { + background: rgba(96, 165, 250, 0.12); + color: #60a5fa; + animation: auto-import-badge-pulse 1.6s ease-in-out infinite; +} +@keyframes auto-import-badge-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.55; + } +} + +.autoImportTrackRowActive { + background: rgba(96, 165, 250, 0.08); + border-left: 2px solid #60a5fa; + padding-left: 6px; + margin-left: -8px; + border-radius: 3px; +} +.autoImportTrackRowActive .autoImportTrackName { + color: #60a5fa; + font-weight: 600; +} +.autoImportTrackRowDone .autoImportTrackName, +.autoImportTrackRowDone .autoImportTrackFile { + opacity: 0.4; +} + +.autoImportActions { + display: flex; + gap: 4px; + margin-top: 4px; +} + +.autoImportActions button { + font-size: 10px; + padding: 3px 10px; +} + +@media (max-width: 768px) { + .autoImportCard { + flex-direction: column; + align-items: flex-start; + } + .autoImportCardRight { + flex-direction: row; + width: 100%; + justify-content: space-between; + } +} diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx new file mode 100644 index 00000000..b92bb8a8 --- /dev/null +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -0,0 +1,184 @@ +import { Link, Outlet } from '@tanstack/react-router'; + +import { useReactPageShell } from '@/platform/shell/route-controllers'; + +import type { ImportQueueEntry } from '../-import.types'; +import styles from './import-page.module.css'; + +import { + getQueueProgressPercent, + getQueueStatusText, + getStagingStatsText, +} from '../-import.helpers'; +import { useImportQueueWorkflow } from '../-import.store'; +import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared'; + +export function ImportPage() { + useReactPageShell('import'); + + const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging(); + + return ( +
+
+ + + +
+ +
+
+
+ ); +} + +function ImportHeader({ + error, + fileCountText, + loading, + stagingPath, + onRefresh, +}: { + error: unknown; + fileCountText: string; + loading: boolean; + stagingPath: string; + onRefresh: () => void; +}) { + return ( +
+
+

+ + Import Music +

+ +
+
+ + {error ? 'Import folder: error' : `Import: ${stagingPath}`} + + + {loading ? 'loading...' : fileCountText} + +
+
+ ); +} + +function ImportProcessingQueue() { + const { clearFinishedJobs, queue } = useImportQueueWorkflow(); + const hasFinished = queue.some((entry) => entry.status !== 'running'); + + return ( +
+
+ Processing + +
+
+ {queue.map((entry) => ( + + ))} +
+
+ ); +} + +function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { + const statusText = getQueueStatusText(entry); + const statusClass = + entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0) + ? styles.error + : entry.status === 'done' + ? styles.done + : ''; + + return ( +
+ {entry.imageUrl ? ( + + ) : ( +
+ A +
+ )} +
+
{entry.label}
+
{entry.sublabel}
+
+
+
+
+
+
{statusText}
+
+
+ ); +} + +function ImportTabNav() { + return ( + + ); +} diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx new file mode 100644 index 00000000..b025f2a4 --- /dev/null +++ b/webui/src/routes/import/-ui/import-shared.tsx @@ -0,0 +1,106 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; + +import type { ImportQueueJob } from '../-import.types'; + +import { + importStagingFilesQueryOptions, + invalidateImportStagingQueries, + processImportAlbumTrack, + processImportSingleFile, +} from '../-import.api'; +import { getTrackDisplayInfo, IMPORT_PLACEHOLDER_IMAGE } from '../-import.helpers'; +import { useImportQueueWorkflow, useImportWorkflowStore } from '../-import.store'; + +export function useImportStaging() { + const queryClient = useQueryClient(); + const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs); + const stagingQuery = useQuery({ + ...importStagingFilesQueryOptions(), + }); + + return { + refreshStaging: () => { + clearFinishedJobs(); + void invalidateImportStagingQueries(queryClient); + }, + stagingFiles: stagingQuery.data?.files ?? [], + stagingPath: stagingQuery.data?.staging_path || 'Not configured', + stagingQuery, + }; +} + +export function useImportQueueActions() { + const queryClient = useQueryClient(); + const { enqueueQueueJob, updateQueueEntry } = useImportQueueWorkflow(); + + const runQueueJob = async (entryId: number, job: ImportQueueJob) => { + let processed = 0; + const errors: string[] = []; + + for (let index = 0; index < job.items.length; index += 1) { + const itemName = + job.type === 'album' + ? getTrackDisplayInfo(job.items[index], index).name + : job.items[index].title || job.items[index].filename || `File ${index + 1}`; + + updateQueueEntry(entryId, { + sublabel: `Processing ${index + 1}/${job.items.length}: ${itemName}`, + processed, + errors: [...errors], + }); + + try { + const payload = + job.type === 'album' + ? await processImportAlbumTrack({ + album: job.albumData, + match: job.items[index], + }) + : await processImportSingleFile(job.items[index]); + + processed += payload.processed || 0; + if (payload.errors?.length) { + errors.push(...payload.errors); + } + } catch (error) { + errors.push(`${itemName}: ${getErrorMessage(error)}`); + } + + updateQueueEntry(entryId, { + processed, + errors: [...errors], + }); + } + + updateQueueEntry(entryId, { + status: errors.length > 0 && processed === 0 ? 'error' : 'done', + processed, + errors, + }); + void invalidateImportStagingQueries(queryClient); + }; + + return { + addQueueJob: (job: ImportQueueJob) => { + const id = enqueueQueueJob(job); + void runQueueJob(id, job); + }, + }; +} + +export function RefreshIcon() { + return ( + + ); +} + +export function fallbackImage(event: { currentTarget: HTMLImageElement }) { + if (event.currentTarget.src.endsWith(IMPORT_PLACEHOLDER_IMAGE)) return; + event.currentTarget.src = IMPORT_PLACEHOLDER_IMAGE; +} + +export function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : 'Unknown error'; +} diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx new file mode 100644 index 00000000..2c3f5a53 --- /dev/null +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -0,0 +1,330 @@ +import type { SingleSearchState } from '../-import.store'; +import type { ImportTrackResult } from '../-import.types'; +import type { ImportStagingFile } from '../-import.types'; + +import styles from './import-page.module.css'; +import { searchImportTracks } from '../-import.api'; +import { formatDuration } from '../-import.helpers'; +import { useSinglesImportWorkflow } from '../-import.store'; +import { + fallbackImage, + getErrorMessage, + useImportQueueActions, + useImportStaging, +} from './import-shared'; + +export function SinglesImportTab() { + const { refreshStaging, stagingFiles } = useImportStaging(); + const { addQueueJob } = useImportQueueActions(); + const { + clearSinglesSelection, + ensureSingleSearch, + openSingleSearch, + selectedSingles, + selectSingleMatchInStore, + setOpenSingleSearch, + setSingleSearch, + singleSearches, + singlesManualMatches, + toggleAllSingles, + toggleSingleInStore, + } = useSinglesImportWorkflow(); + + const openSingleSearchPanel = (index: number) => { + if (openSingleSearch === index) { + setOpenSingleSearch(null); + return; + } + + setOpenSingleSearch(index); + const file = stagingFiles[index]; + const defaultQuery = + [file?.artist, file?.title].filter(Boolean).join(' ') || + (file?.filename || '').replace(/\.[^.]+$/, ''); + ensureSingleSearch(index, defaultQuery); + if (defaultQuery && !singleSearches[index]?.results.length) { + void runSingleSearch(index, defaultQuery); + } + }; + + const runSingleSearch = async (index: number, query: string) => { + const trimmed = query.trim(); + if (!trimmed) return; + + setSingleSearch(index, (current) => ({ + query: trimmed, + loading: true, + error: null, + results: current.results, + })); + + try { + const payload = await searchImportTracks(trimmed); + setSingleSearch(index, { + query: trimmed, + loading: false, + error: null, + results: payload.tracks ?? [], + }); + } catch (error) { + setSingleSearch(index, { + query: trimmed, + loading: false, + error: getErrorMessage(error), + results: [], + }); + } + }; + + const selectSingleMatch = (fileIndex: number, track: ImportTrackResult) => { + selectSingleMatchInStore(fileIndex, track); + }; + + const processSingles = () => { + if (selectedSingles.size === 0) return; + const filesToProcess = Array.from(selectedSingles).flatMap((index) => { + const file = stagingFiles[index]; + if (!file) return []; + const manualMatch = singlesManualMatches[index]; + return manualMatch ? [{ ...file, manual_match: manualMatch }] : [file]; + }); + + if (filesToProcess.length === 0) return; + + addQueueJob({ + type: 'singles', + label: `${filesToProcess.length} Single${filesToProcess.length === 1 ? '' : 's'}`, + sublabel: + filesToProcess + .map((file) => file.title || file.filename) + .slice(0, 3) + .join(', ') + (filesToProcess.length > 3 ? '...' : ''), + imageUrl: null, + items: filesToProcess, + }); + + clearSinglesSelection(); + refreshStaging(); + }; + + return ( + { + setSingleSearch(index, (current) => ({ + query, + loading: current.loading, + error: current.error, + results: current.results, + })); + }} + onSelectAll={() => toggleAllSingles(stagingFiles.length)} + onSelectMatch={selectSingleMatch} + onToggleSingle={toggleSingleInStore} + /> + ); +} + +export function SinglesImportPanel({ + files, + manualMatches, + openSearchIndex, + searchStates, + selected, + onOpenSearch, + onProcessSingles, + onRunSearch, + onSearchQueryChange, + onSelectAll, + onSelectMatch, + onToggleSingle, +}: { + files: ImportStagingFile[]; + manualMatches: Record; + openSearchIndex: number | null; + searchStates: Record; + selected: Set; + onOpenSearch: (index: number) => void; + onProcessSingles: () => void; + onRunSearch: (index: number, query: string) => void; + onSearchQueryChange: (index: number, query: string) => void; + onSelectAll: () => void; + onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; + onToggleSingle: (index: number) => void; +}) { + const allSelected = files.length > 0 && selected.size === files.length; + + return ( + <> +
+
+ + +
+
+
+ {files.length === 0 ? ( +
No audio files found in import folder
+ ) : ( + files.map((file, index) => { + const manualMatch = manualMatches[index]; + const isSelected = selected.has(index); + const searchState = searchStates[index]; + return ( +
+ +
+ ) : null} +
+
+ +
+ {openSearchIndex === index ? ( + + ) : null} +
+ ); + }) + )} +
+ + ); +} + +function SingleSearchPanel({ + fileIndex, + searchState, + onQueryChange, + onRunSearch, + onSelectMatch, +}: { + fileIndex: number; + searchState: SingleSearchState | undefined; + onQueryChange: (index: number, query: string) => void; + onRunSearch: (index: number, query: string) => void; + onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; +}) { + const query = searchState?.query ?? ''; + + return ( +
+
+ onQueryChange(fileIndex, event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onRunSearch(fileIndex, query); + }} + /> + +
+
+ {searchState?.loading ? ( +
Searching...
+ ) : searchState?.error ? ( +
Error: {searchState.error}
+ ) : searchState?.results.length === 0 ? ( +
No results found
+ ) : ( + searchState?.results.map((track, index) => ( + + )) + )} +
+
+ ); +} diff --git a/webui/src/routes/import/album.tsx b/webui/src/routes/import/album.tsx new file mode 100644 index 00000000..6c4a598a --- /dev/null +++ b/webui/src/routes/import/album.tsx @@ -0,0 +1,15 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { + importStagingGroupsQueryOptions, + importStagingSuggestionsQueryOptions, +} from './-import.api'; +import { AlbumImportTab } from './-ui/album-import-tab'; + +export const Route = createFileRoute('/import/album')({ + loader: async ({ context }) => { + void context.queryClient.prefetchQuery(importStagingGroupsQueryOptions()); + void context.queryClient.prefetchQuery(importStagingSuggestionsQueryOptions()); + }, + component: AlbumImportTab, +}); diff --git a/webui/src/routes/import/auto.tsx b/webui/src/routes/import/auto.tsx new file mode 100644 index 00000000..f2b3a1c6 --- /dev/null +++ b/webui/src/routes/import/auto.tsx @@ -0,0 +1,27 @@ +import { useNavigate } from '@tanstack/react-router'; +import { createFileRoute } from '@tanstack/react-router'; + +import type { ImportAutoFilter } from './-import.types'; + +import { importAutoSearchSchema } from './-import.types'; +import { AutoImportPanel } from './-ui/auto-import-tab'; + +export const Route = createFileRoute('/import/auto')({ + validateSearch: importAutoSearchSchema, + component: AutoImportRoute, +}); + +function AutoImportRoute() { + const navigate = useNavigate({ from: Route.fullPath }); + const { autoFilter } = Route.useSearch(); + + const setAutoFilter = (nextFilter: ImportAutoFilter) => { + void navigate({ + to: Route.fullPath, + search: (prev) => ({ ...prev, autoFilter: nextFilter }), + replace: true, + }); + }; + + return ; +} diff --git a/webui/src/routes/import/index.tsx b/webui/src/routes/import/index.tsx new file mode 100644 index 00000000..72f25616 --- /dev/null +++ b/webui/src/routes/import/index.tsx @@ -0,0 +1,7 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +export const Route = createFileRoute('/import/')({ + beforeLoad: () => { + throw redirect({ to: '/import/album', replace: true }); + }, +}); diff --git a/webui/src/routes/import/route.tsx b/webui/src/routes/import/route.tsx new file mode 100644 index 00000000..5cb5e60d --- /dev/null +++ b/webui/src/routes/import/route.tsx @@ -0,0 +1,20 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; + +import { getProfileHomePath } from '@/platform/shell/bridge'; + +import { importStagingFilesQueryOptions } from './-import.api'; +import { ImportPage } from './-ui/import-page'; + +export const Route = createFileRoute('/import')({ + beforeLoad: ({ context }) => { + const { bridge } = context.shell; + + if (!bridge.isPageAllowed('import')) { + throw redirect({ href: getProfileHomePath(bridge), replace: true }); + } + }, + loader: async ({ context }) => { + await context.queryClient.ensureQueryData(importStagingFilesQueryOptions()); + }, + component: ImportPage, +}); diff --git a/webui/src/routes/import/singles.tsx b/webui/src/routes/import/singles.tsx new file mode 100644 index 00000000..89a4621b --- /dev/null +++ b/webui/src/routes/import/singles.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from '@tanstack/react-router'; + +import { SinglesImportTab } from './-ui/singles-import-tab'; + +export const Route = createFileRoute('/import/singles')({ + component: SinglesImportTab, +}); From 7853d0cfb86baab032010af4416dfcdea384e415 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sun, 24 May 2026 11:42:21 +0300 Subject: [PATCH 11/42] chore(webui): remove legacy import feature js / css code --- .../routes/import/-ui/import-page.module.css | 17 +- webui/static/init.js | 3 - webui/static/mobile.css | 77 - webui/static/stats-automations.js | 1390 +-------------- webui/static/style.css | 1508 +---------------- 5 files changed, 32 insertions(+), 2963 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 2f7e71b9..db15a907 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -969,19 +969,12 @@ color: #ef4444; } -/* ======================================== - END IMPORT PAGE - .importPageFileChip { - cursor: pointer; - } - - .importPageMatchRow { - cursor: pointer; - } -} - -/* Import Page — small screen */ @media (max-width: 768px) { + .importPageFileChip, + .importPageMatchRow { + cursor: pointer; + } + .importPageContainer { padding: 16px; } diff --git a/webui/static/init.js b/webui/static/init.js index 6b760b0c..10a047b4 100644 --- a/webui/static/init.js +++ b/webui/static/init.js @@ -2420,9 +2420,6 @@ async function loadPageData(pageId) { loadApiKeys(); loadBlacklistCount(); break; - case 'import': - initializeImportPage(); - break; case 'hydrabase': // Check connection status and pre-fill saved credentials try { diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 043ea773..bd81ae64 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -2312,86 +2312,9 @@ opacity: 0.7; transition: opacity 0.1s ease; } - - /* Import Page - Touch fallback */ - .import-page-file-chip { - cursor: pointer; - } - - .import-page-match-row { - cursor: pointer; - } } -/* Import Page — small screen */ @media (max-width: 768px) { - .import-page-container { - padding: 16px; - } - - .import-page-title { - font-size: 22px; - } - - .import-page-album-grid { - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: 10px; - } - - .import-page-album-hero { - flex-direction: column; - text-align: center; - } - - .import-page-album-hero img { - width: 100px; - height: 100px; - } - - .import-page-match-row { - grid-template-columns: 28px 1fr; - gap: 8px; - } - - .import-page-match-file { - grid-column: 1 / -1; - padding-left: 28px; - } - - .import-page-match-unmatch { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-singles-header { - flex-direction: column; - align-items: flex-start; - } - - .import-page-singles-actions { - width: 100%; - flex-wrap: wrap; - } - - .import-page-single-item { - grid-template-columns: 28px 1fr; - } - - .import-page-single-actions { - grid-column: 1 / -1; - justify-self: end; - } - - .import-page-single-search-panel { - padding-left: 0; - } - - .import-page-staging-bar { - flex-direction: column; - gap: 4px; - align-items: flex-start; - } - /* Profile Picker - Mobile */ .profile-picker-grid { gap: 20px; diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index 68772b35..9723c549 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -1,1377 +1,3 @@ -// IMPORT PAGE (full page, replaces old modal) -// =================================================================== - -let importJobIdCounter = 0; - -const importPageState = { - stagingFiles: [], - selectedSingles: new Set(), - albumData: null, // response from /api/import/album/match - matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides - singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } } - initialized: false, - activeTab: 'album', - tapSelectedChip: null, // for mobile tap-to-assign fallback - // Album lookup cache for click handlers. Populated by suggestions / - // search renderers; read by importPageSelectAlbum so the match POST - // can include `source` + `name` + `artist` (without these, backend - // can't look up cross-source IDs and falls back to a broken - // "Unknown Artist / album_id as title / 0 tracks" placeholder — - // github issue #524). - _albumLookup: {}, // { albumId: { id, name, artist, source } } -}; - -// --- Initialization --- - -function initializeImportPage() { - if (!importPageState.initialized) { - importPageState.initialized = true; - importPageRefreshStaging(); - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - } -} - -async function importPageRefreshStaging() { - // Clear finished jobs from the queue - importPageClearFinishedJobs(); - - try { - const resp = await fetch('/api/import/staging/files'); - const data = await resp.json(); - if (!data.success) { - document.getElementById('import-page-staging-path').textContent = `Import folder: error`; - return; - } - - importPageState.stagingFiles = data.files || []; - document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`; - - const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); - const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` - : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` - : `${(totalSize / 1024).toFixed(0)} KB`; - document.getElementById('import-page-staging-stats').textContent = - `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; - - // Refresh the current tab view after data is loaded - if (importPageState.activeTab === 'singles') { - importPageRenderSinglesList(); - } else if (importPageState.activeTab === 'album') { - importPageLoadAutoGroups(); - } - // Always refresh suggestions and groups in background - importPageLoadSuggestions(); - } catch (err) { - console.error('Failed to refresh staging:', err); - } -} - -function importPageSwitchTab(tab) { - importPageState.activeTab = tab; - document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto'); - document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album'); - document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles'); - document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto'); - - if (tab === 'singles' && importPageState.stagingFiles.length > 0) { - importPageRenderSinglesList(); - } - if (tab === 'auto') { - _autoImportLoadStatus(); - _autoImportLoadResults(); - _autoImportStartPolling(); - } else { - _autoImportStopPolling(); - } -} - -// ── Auto-Import Tab ── -let _autoImportPollInterval = null; -let _autoImportFilter = 'all'; -let _autoImportLastStatus = null; - -function _autoImportStartPolling() { - _autoImportStopPolling(); - _autoImportPollInterval = setInterval(async () => { - if (importPageState.activeTab === 'auto') { - await _autoImportLoadStatus(); - _autoImportLoadResults(); - } - }, 5000); -} - -function _autoImportStopPolling() { - if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; } -} - -async function _autoImportToggle(enabled) { - // Optimistically update toggle state so it doesn't flicker - const toggle = document.getElementById('auto-import-enabled'); - if (toggle) toggle.checked = enabled; - const statusText = document.getElementById('auto-import-status-text'); - if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...'; - - try { - const res = await fetch('/api/auto-import/toggle', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ enabled }) - }); - const data = await res.json(); - if (data.success) { - showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success'); - _autoImportLoadStatus(); - } else { - // Revert on failure - if (toggle) toggle.checked = !enabled; - } - } catch (e) { - showToast('Error: ' + e.message, 'error'); - if (toggle) toggle.checked = !enabled; - } -} - -async function _autoImportLoadStatus() { - try { - const res = await fetch('/api/auto-import/status'); - const data = await res.json(); - if (!data.success) return; - _autoImportLastStatus = data; - - const toggle = document.getElementById('auto-import-enabled'); - const statusText = document.getElementById('auto-import-status-text'); - const settingsRow = document.getElementById('auto-import-settings-row'); - const scanNowBtn = document.getElementById('auto-import-scan-now'); - const progressEl = document.getElementById('auto-import-progress'); - const progressText = document.getElementById('auto-import-progress-text'); - - if (toggle) toggle.checked = data.running; - if (settingsRow) settingsRow.style.display = data.running ? '' : 'none'; - if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none'; - - // Live scan + per-track processing progress. - // `active_imports` (added when the worker switched to a bounded - // executor pool) is the source of truth; multiple albums can be - // in flight at once. Render each one on its own line; fall back - // to the legacy single-line summary for older backend payloads. - if (progressEl) { - const active = Array.isArray(data.active_imports) ? data.active_imports : []; - if (active.length > 0) { - progressEl.style.display = ''; - if (progressText) { - const lines = active.map(a => { - const folder = a.folder_name || '...'; - const idx = a.track_index || 0; - const total = a.track_total || 0; - const trackName = a.track_name || ''; - if (a.status === 'processing' && total > 0) { - return `${folder} — track ${idx}/${total}: ${trackName}`; - } - if (a.status === 'matching') return `${folder} — matching tracks…`; - if (a.status === 'identifying') return `${folder} — identifying…`; - return `${folder} — queued`; - }); - progressText.textContent = lines.length === 1 - ? `Processing ${lines[0]}` - : `Processing ${lines.length} imports:\n${lines.join('\n')}`; - } - } else if (data.current_status === 'scanning') { - progressEl.style.display = ''; - if (progressText) { - const stats = data.stats || {}; - progressText.textContent = `Scanning… (${stats.scanned || 0} processed)`; - } - } else { - progressEl.style.display = 'none'; - } - } - - if (statusText) { - if (data.paused) statusText.textContent = 'Paused'; - else if (data.current_status === 'processing') statusText.textContent = 'Processing...'; - else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...'; - else if (data.running) { - // Show last scan time - let watchText = 'Watching'; - if (data.last_scan_time) { - try { - const lastScan = new Date(data.last_scan_time); - const diffS = Math.floor((Date.now() - lastScan) / 1000); - if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`; - else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`; - } catch (e) {} - } - statusText.textContent = watchText; - } else statusText.textContent = 'Disabled'; - const _runningClass = data.current_status === 'scanning' - ? 'scanning' - : data.current_status === 'processing' - ? 'processing' - : 'active'; - statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled'); - } - } catch (e) {} -} - -async function _autoImportLoadResults() { - const container = document.getElementById('auto-import-results'); - if (!container) return; - try { - const res = await fetch('/api/auto-import/results?limit=100'); - const data = await res.json(); - if (!data.success || !data.results || data.results.length === 0) { - if (!container.querySelector('.auto-import-card')) { - container.innerHTML = `
-

No imports yet. Drop album folders or single tracks into your import folder.

-
`; - } - // Hide stats and filters - const statsEl = document.getElementById('auto-import-stats'); - const filtersEl = document.getElementById('auto-import-filters'); - if (statsEl) statsEl.style.display = 'none'; - if (filtersEl) filtersEl.style.display = 'none'; - return; - } - - // Compute stats - const allResults = data.results; - const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length; - const reviewCount = allResults.filter(r => r.status === 'pending_review').length; - const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length; - - // Update stats - const statsEl = document.getElementById('auto-import-stats'); - if (statsEl) { - statsEl.style.display = ''; - document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`; - document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`; - document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`; - } - - // Show filters - const filtersEl = document.getElementById('auto-import-filters'); - if (filtersEl) { - filtersEl.style.display = ''; - // Show batch action buttons when applicable - const approveAllBtn = document.getElementById('auto-import-approve-all'); - const clearBtn = document.getElementById('auto-import-clear-completed'); - if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none'; - if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none'; - } - - // Apply filter - let filtered = allResults; - if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review'); - else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved'); - else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification'); - - if (filtered.length === 0) { - const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter; - container.innerHTML = `

No ${filterName} items.

`; - return; - } - - container.innerHTML = filtered.map((r, idx) => { - const confPct = Math.round((r.confidence || 0) * 100); - const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low'; - const statusLabels = { - 'completed': 'Imported', 'pending_review': 'Needs Review', - 'needs_identification': 'Unidentified', 'failed': 'Failed', - 'scanning': 'Scanning...', 'matched': 'Matched', - 'rejected': 'Dismissed', 'approved': 'Approved', - 'processing': 'Processing', - }; - const statusIcons = { - 'completed': '\u2713', 'pending_review': '\u26A0', - 'needs_identification': '\u2717', 'failed': '\u2717', - 'scanning': '\u231B', 'matched': '\u2713', - 'rejected': '\u2715', 'approved': '\u2713', - 'processing': '\u29D7', - }; - const statusLabel = statusLabels[r.status] || r.status; - const statusIcon = statusIcons[r.status] || ''; - const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' : - r.status === 'failed' || r.status === 'needs_identification' ? 'failed' : - r.status === 'processing' ? 'processing' : 'neutral'; - - // Live per-track progress for the row currently being processed. - // Match by folder_hash through the `active_imports` array - // — the worker now runs multiple imports in parallel via a - // bounded executor pool, so `current_folder` alone can't - // identify a row's live state. - const liveStatus = _autoImportLastStatus; - const liveActive = (liveStatus && Array.isArray(liveStatus.active_imports)) - ? liveStatus.active_imports.find(a => a.folder_hash === r.folder_hash) - : null; - const isLiveProcessing = r.status === 'processing' - && liveActive && liveActive.status === 'processing'; - const liveTrackIdx = isLiveProcessing ? (liveActive.track_index || 0) : 0; - const liveTrackTotal = isLiveProcessing ? (liveActive.track_total || 0) : 0; - const liveTrackName = isLiveProcessing ? (liveActive.track_name || '') : ''; - - // Parse match data for track details - let matchCount = 0, totalTracks = 0, trackDetails = []; - if (r.match_data) { - try { - const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data; - matchCount = md.matched_count || 0; - totalTracks = md.total_tracks || 0; - if (md.matches) { - trackDetails = md.matches.map(m => ({ - name: m.track_name || m.track?.name || 'Unknown', - file: m.file ? m.file.split(/[/\\]/).pop() : '?', - confidence: Math.round((m.confidence || 0) * 100), - })); - } - } catch (e) {} - } - - let matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`; - if (isLiveProcessing && liveTrackTotal > 0) { - matchSummary = `track ${liveTrackIdx}/${liveTrackTotal}: ${liveTrackName}`; - } - const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' }; - const methodLabel = methodLabels[r.identification_method] || r.identification_method || ''; - - // Time ago - let timeAgo = ''; - if (r.created_at) { - try { - const d = new Date(r.created_at); - const diffM = Math.floor((Date.now() - d) / 60000); - if (diffM < 1) timeAgo = 'just now'; - else if (diffM < 60) timeAgo = `${diffM}m ago`; - else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`; - else timeAgo = `${Math.floor(diffM / 1440)}d ago`; - } catch (e) {} - } - - let actions = ''; - if (r.status === 'pending_review') { - actions = `
- - -
`; - } - - // Expanded track list (hidden by default) - let trackListHtml = ''; - if (trackDetails.length > 0) { - trackListHtml = `
-
- TrackMatched FileConf -
- ${trackDetails.map((t, tIdx) => { - const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low'; - // 1-based liveTrackIdx — current row glows, prior rows dim as "done". - let rowState = ''; - if (isLiveProcessing && liveTrackIdx > 0) { - if (tIdx + 1 === liveTrackIdx) rowState = ' auto-import-track-row-active'; - else if (tIdx + 1 < liveTrackIdx) rowState = ' auto-import-track-row-done'; - } - return `
- ${escapeHtml(t.name)} - ${escapeHtml(t.file)} - ${t.confidence}% -
`; - }).join('')} -
`; - } - - return `
-
-
- ${r.image_url ? `` : `
\uD83D\uDCBF
`} -
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
- ${matchSummary} - ${methodLabel ? `${methodLabel}` : ''} - ${timeAgo ? `${timeAgo}` : ''} -
- ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''} -
-
-
${statusIcon} ${statusLabel}
-
-
-
-
${confPct}% confidence
- ${actions} -
-
-
${escapeHtml(r.folder_name)}
- ${trackListHtml} -
`; - }).join(''); - - } catch (e) {} -} - -async function _autoImportSaveSettings() { - const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100; - const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60); - try { - await fetch('/api/auto-import/settings', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval }) - }); - showToast('Settings saved', 'success'); - } catch (e) { showToast('Error', 'error'); } -} - -function _autoImportSetFilter(filter) { - _autoImportFilter = filter; - document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p => - p.classList.toggle('active', p.dataset.filter === filter)); - _autoImportLoadResults(); -} - -async function _autoImportScanNow() { - try { - const res = await fetch('/api/auto-import/scan-now', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast('Scan triggered', 'success'); - _autoImportLoadStatus(); - } else { - showToast(data.error || 'Failed to trigger scan', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportApproveAll() { - const confirmed = await showConfirmDialog({ - title: 'Approve All', - message: 'Approve and import all pending review items?', - confirmText: 'Approve All', - }); - if (!confirmed) return; - try { - const res = await fetch('/api/auto-import/approve-all', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Approved ${data.count || 0} items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -async function _autoImportClearCompleted() { - try { - const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - showToast(`Cleared ${data.count || 0} imported items`, 'success'); - _autoImportLoadResults(); - } else { - showToast(data.error || 'Failed', 'error'); - } - } catch (e) { showToast('Error: ' + e.message, 'error'); } -} - -function _autoImportToggleDetail(idx) { - const trackList = document.getElementById(`auto-import-tracks-${idx}`); - if (trackList) { - trackList.classList.toggle('expanded'); - } -} -window._autoImportToggleDetail = _autoImportToggleDetail; -window._autoImportSetFilter = _autoImportSetFilter; -window._autoImportScanNow = _autoImportScanNow; -window._autoImportApproveAll = _autoImportApproveAll; -window._autoImportClearCompleted = _autoImportClearCompleted; - -async function _autoImportApprove(id) { - try { - const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -async function _autoImportReject(id) { - try { - const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' }); - const data = await res.json(); - if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); } - else showToast(data.error || 'Failed', 'error'); - } catch (e) { showToast('Error', 'error'); } -} - -// --- Album Tab: Auto-Detected Groups (from file tags) --- - -async function importPageLoadAutoGroups() { - const grid = document.getElementById('import-page-suggestions-grid'); - if (!grid) return; - - try { - const resp = await fetch('/api/import/staging/groups'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.groups || data.groups.length === 0) return; - - // Build auto-groups section above suggestions - let groupsContainer = document.getElementById('import-page-auto-groups'); - if (!groupsContainer) { - groupsContainer = document.createElement('div'); - groupsContainer.id = 'import-page-auto-groups'; - groupsContainer.style.marginBottom = '16px'; - const suggestionsSection = document.getElementById('import-page-suggestions'); - if (suggestionsSection) { - suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection); - } else { - grid.parentNode.insertBefore(groupsContainer, grid); - } - } - - groupsContainer.innerHTML = ` -
- Auto-Detected Albums -
-
- ${data.groups.map((g, idx) => ` -
-
- ${g.file_count} -
-
-
${_esc(g.album)}
-
${_esc(g.artist)} · ${g.file_count} tracks
-
-
- `).join('')} -
- `; - - // Store groups for click handler - importPageState._autoGroups = data.groups; - } catch (err) { - console.warn('Failed to load auto-groups:', err); - } -} - -async function importPageMatchAutoGroup(groupIdx) { - const group = importPageState._autoGroups?.[groupIdx]; - if (!group) return; - - // Search for the album by name + artist - const query = `${group.artist} ${group.album}`; - const searchInput = document.getElementById('import-page-album-search-input'); - if (searchInput) searchInput.value = query; - - // Hide suggestions/groups, show search results - const suggestionsEl = document.getElementById('import-page-suggestions'); - const groupsEl = document.getElementById('import-page-auto-groups'); - if (suggestionsEl) suggestionsEl.style.display = 'none'; - if (groupsEl) groupsEl.style.display = 'none'; - - const grid = document.getElementById('import-page-album-results'); - if (grid) grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - - if (data.success && data.albums && data.albums.length > 0) { - // Store file_paths filter so match only includes this group's files - importPageState._autoGroupFilePaths = group.file_paths; - - // Render results — user picks the right album - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } else { - grid.innerHTML = '
No albums found — try searching manually
'; - } - } catch (err) { - console.error('Auto-group search failed:', err); - if (grid) grid.innerHTML = '
Search failed
'; - } -} - -// --- Album Tab: Suggestions (server-side cache, just fetch and render) --- - -async function importPageLoadSuggestions() { - const section = document.getElementById('import-page-suggestions'); - const grid = document.getElementById('import-page-suggestions-grid'); - if (!section || !grid) return; - - try { - const resp = await fetch('/api/import/staging/suggestions'); - if (!resp.ok) return; - const data = await resp.json(); - - if (!data.success || !data.suggestions || data.suggestions.length === 0) { - if (!data.ready) { - // Server is still building cache — show placeholder, retry shortly - section.style.display = ''; - grid.innerHTML = '
Loading suggestions...
'; - setTimeout(() => importPageLoadSuggestions(), 3000); - } else { - section.style.display = 'none'; - grid.innerHTML = ''; - } - return; - } - - section.style.display = ''; - const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source); - grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - } catch (err) { - // Network error or server not ready — fail silently - console.warn('Failed to load import suggestions:', err); - } -} - -function _renderSuggestionCard(a, primarySource) { - // Cache the album lookup so importPageSelectAlbum can pull source + - // name + artist on click (the onclick can only carry the ID string - // — see github issue #524 root cause). - importPageState._albumLookup[a.id] = { - id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '', - }; - // Surface the served source when it differs from the user's configured - // primary — the search route silently falls through to the next source - // in METADATA_SOURCE_PRIORITY when the primary returns nothing - // (intentional design, see core/auto_import_worker.py:1316). Without - // this badge the user has no idea their MusicBrainz / Discogs choice - // got bypassed (github issue #681). - const sourceBadge = (a.source && primarySource && a.source !== primarySource) - ? `
via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}
` - : ''; - const metaParts = [ - `${a.total_tracks || 0} tracks`, - a.release_date ? a.release_date.substring(0, 4) : '', - a.format || '', - a.country || '', - a.disambiguation || '', - ].filter(Boolean); - const details = [a.status || '', a.label || ''].filter(Boolean); - const detailsLine = details.length - ? `
${_esc(details.join(' · '))}
` - : ''; - return `
- ${_escAttr(a.name)} -
${_esc(a.name)}
-
${_esc(a.artist)}
-
${_esc(metaParts.join(' · '))}
- ${detailsLine} - ${sourceBadge} -
`; -} - -function _renderImportFallbackBanner(albums, primarySource) { - if (!primarySource || !albums || !albums.length) return ''; - const allFallback = albums.every(a => a.source && a.source !== primarySource); - if (!allFallback) return ''; - const servedSource = albums[0].source; - const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource; - const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource; - // Neutral wording — covers both live-search fallback (primary returned 0) - // and cache-stale suggestions (primary changed since cache was built). - return `
Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).
`; -} - -// --- Album Tab: Search --- - -async function importPageSearchAlbum() { - const query = document.getElementById('import-page-album-search-input').value.trim(); - if (!query) return; - - document.getElementById('import-page-suggestions').style.display = 'none'; - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = 'none'; - const grid = document.getElementById('import-page-album-results'); - grid.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`); - const data = await resp.json(); - if (!data.success || !data.albums.length) { - grid.innerHTML = '
No albums found
'; - return; - } - const banner = _renderImportFallbackBanner(data.albums, data.primary_source); - grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join(''); - document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); - } catch (err) { - grid.innerHTML = `
Error: ${err.message}
`; - } -} - -// --- Album Tab: Select Album & Match --- - -async function importPageSelectAlbum(albumId) { - document.getElementById('import-page-album-search-section').classList.add('hidden'); - document.getElementById('import-page-album-match-section').classList.remove('hidden'); - - const matchList = document.getElementById('import-page-match-list'); - matchList.innerHTML = '
Matching files to tracklist...
'; - - try { - // Include file_paths filter if matching from an auto-group. - // CRITICAL: include source + album_name + album_artist from the - // search/suggestion result. Without `source`, the backend can't - // route the lookup to the metadata source the album_id came - // from — for example a Deezer album_id needs Deezer's get_album - // call. Cross-source lookup fails silently, returns the - // failure-fallback dict with album_id-as-name + Unknown Artist - // + 0 tracks, then the import flow writes that broken metadata - // to the library DB (github issue #524). - const cached = importPageState._albumLookup[albumId] || {}; - const matchBody = { - album_id: albumId, - source: cached.source || '', - album_name: cached.name || '', - album_artist: cached.artist || '', - }; - if (importPageState._autoGroupFilePaths) { - matchBody.file_paths = importPageState._autoGroupFilePaths; - importPageState._autoGroupFilePaths = null; // clear after use - } - const resp = await fetch('/api/import/album/match', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(matchBody) - }); - const data = await resp.json(); - if (!data.success) { - matchList.innerHTML = `
Error: ${data.error}
`; - return; - } - - importPageState.albumData = data; - importPageState.matchOverrides = {}; - - // Render hero - const album = data.album; - const heroMetaParts = [ - `${album.total_tracks || 0} tracks`, - album.release_date ? album.release_date.substring(0, 4) : '', - album.format || '', - album.country || '', - album.disambiguation || '', - ].filter(Boolean); - document.getElementById('import-page-album-hero').innerHTML = ` - ${_escAttr(album.name)} -
-
${_esc(album.name)}
-
${_esc(album.artist)}
-
${_esc(heroMetaParts.join(' · '))}
-
- `; - - importPageRenderMatchList(); - } catch (err) { - matchList.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageRenderMatchList() { - const data = importPageState.albumData; - if (!data) return; - - const matchList = document.getElementById('import-page-match-list'); - const overrides = importPageState.matchOverrides; - - // Build effective matches: auto-match overridden by manual overrides - // Also track which staging files are used (auto or override) - const usedStagingFiles = new Set(); - - // First pass: collect overridden indices - Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx)); - - // Build rows - let matchedCount = 0; - const rows = data.matches.map((m, idx) => { - const trackInfo = _importPageGetTrackDisplayInfo(m, idx); - let file = null; - let confidence = m.confidence; - let isOverride = false; - - if (overrides.hasOwnProperty(idx)) { - const sfIdx = overrides[idx]; - if (sfIdx === -1) { - // Forcibly unmatched — no file - file = null; - } else { - // Manual override - file = importPageState.stagingFiles[sfIdx] || null; - confidence = 1.0; - isOverride = true; - usedStagingFiles.add(sfIdx); - } - } else if (m.staging_file) { - file = m.staging_file; - // Check if this file was reassigned to another track via override - const autoFileName = m.staging_file.filename; - const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => { - const sf = importPageState.stagingFiles[sfIdx]; - return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx; - }); - if (!reassigned) { - usedStagingFiles.add(-1); // placeholder — auto-matched file - } else { - file = null; // file was reassigned elsewhere - } - } - - if (file) matchedCount++; - const confPercent = Math.round(confidence * 100); - const confClass = confidence >= 0.7 ? '' : 'low'; - - return ` -
- ${trackInfo.displayTrackNumber} - ${_esc(trackInfo.name)} - - ${file - ? `${_esc(file.filename)} - ${confPercent}%` - : `Drop a file here`} - - ${file ? `` : ''} -
- `; - }); - - matchList.innerHTML = rows.join(''); - - // Unmatched file pool - const unmatchedFiles = []; - importPageState.stagingFiles.forEach((f, i) => { - // Check if used by override - if (Object.values(overrides).includes(i)) return; - // Check if used by auto-match (not overridden away) - const autoUsed = data.matches.some((m, mIdx) => { - if (overrides.hasOwnProperty(mIdx)) return false; - return m.staging_file && m.staging_file.filename === f.filename; - }); - if (autoUsed) return; - unmatchedFiles.push({ file: f, index: i }); - }); - - const poolChips = document.getElementById('import-page-pool-chips'); - document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length; - - if (unmatchedFiles.length === 0) { - poolChips.innerHTML = 'All files matched'; - } else { - poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` - - ${_esc(file.filename)} - - `).join(''); - } - - // Stats & button - document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`; - const processBtn = document.getElementById('import-page-album-process-btn'); - processBtn.disabled = matchedCount === 0; - processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`; -} - -function _importPageGetTrackDisplayInfo(item, index) { - const track = item?.track || item?.spotify_track || {}; - const rawTrackNumber = track.track_number ?? track.trackNumber ?? null; - const trackNumber = rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === '' - ? null - : String(rawTrackNumber).split('/')[0].trim(); - - return { - track, - name: track.name || track.title || `Track ${index + 1}`, - trackNumber, - displayTrackNumber: trackNumber || String(index + 1), - }; -} - -// --- Album Tab: Drag and Drop --- - -function importPageStartDrag(event, stagingFileIndex) { - event.dataTransfer.setData('text/plain', stagingFileIndex.toString()); - event.dataTransfer.effectAllowed = 'move'; -} - -function importPageHandleDragOver(event) { - event.preventDefault(); - event.dataTransfer.dropEffect = 'move'; - event.currentTarget.classList.add('drag-over'); - // Remove drag-over from others - document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => { - if (el !== event.currentTarget) el.classList.remove('drag-over'); - }); -} - -function importPageHandleDrop(event, trackIndex) { - event.preventDefault(); - event.currentTarget.classList.remove('drag-over'); - const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain')); - if (isNaN(stagingFileIndex)) return; - - // Remove this staging file from any other track it was assigned to - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// Mobile tap-to-assign fallback -function importPageTapSelectChip(stagingFileIndex) { - if (importPageState.tapSelectedChip === stagingFileIndex) { - importPageState.tapSelectedChip = null; - } else { - importPageState.tapSelectedChip = stagingFileIndex; - } - importPageRenderMatchList(); -} - -function importPageTapAssign(trackIndex) { - if (importPageState.tapSelectedChip === null) return; - const stagingFileIndex = importPageState.tapSelectedChip; - - // Remove from any other track - Object.keys(importPageState.matchOverrides).forEach(k => { - if (importPageState.matchOverrides[k] === stagingFileIndex) { - delete importPageState.matchOverrides[k]; - } - }); - - importPageState.matchOverrides[trackIndex] = stagingFileIndex; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -function importPageUnmatchTrack(trackIndex) { - delete importPageState.matchOverrides[trackIndex]; - // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay. - // Actually, to truly unmatch: we need to suppress the auto-match too. - // We'll use a sentinel: override = -1 means "forcibly unmatched" - const m = importPageState.albumData?.matches[trackIndex]; - if (m && m.staging_file) { - importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match - } - importPageRenderMatchList(); -} - -function importPageAutoRematch() { - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageRenderMatchList(); -} - -// --- Album Tab: Process --- - -function importPageProcessAlbum() { - const data = importPageState.albumData; - if (!data) return; - - // Build effective matches with overrides applied - const overrides = importPageState.matchOverrides; - const effectiveMatches = []; - data.matches.forEach((m, idx) => { - if (overrides.hasOwnProperty(idx)) { - if (overrides[idx] === -1) return; // forcibly unmatched — skip - const sf = importPageState.stagingFiles[overrides[idx]]; - effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 }); - } else if (m.staging_file !== null) { - effectiveMatches.push(m); - } - }); - - if (effectiveMatches.length === 0) return; - - // Add to queue and reset search immediately so user can queue more - const album = data.album; - _importQueueAdd({ - type: 'album', - label: album.name, - sublabel: `${album.artist} · ${effectiveMatches.length} tracks`, - imageUrl: album.image_url, - items: effectiveMatches, - albumData: album, - }); - - importPageResetAlbumSearch(); -} - -function importPageResetAlbumSearch() { - importPageState.albumData = null; - importPageState.matchOverrides = {}; - importPageState.tapSelectedChip = null; - importPageState._autoGroupFilePaths = null; - - document.getElementById('import-page-album-search-section').classList.remove('hidden'); - document.getElementById('import-page-album-match-section').classList.add('hidden'); - - // Clear search - document.getElementById('import-page-album-results').innerHTML = ''; - document.getElementById('import-page-album-search-input').value = ''; - document.getElementById('import-page-album-clear-btn').classList.add('hidden'); - - // Re-show auto-groups - const groupsEl = document.getElementById('import-page-auto-groups'); - if (groupsEl) groupsEl.style.display = ''; - - // Refresh suggestions & staging - importPageLoadAutoGroups(); - importPageLoadSuggestions(); - importPageRefreshStaging(); -} - -// --- Singles Tab --- - -function importPageRenderSinglesList() { - const list = document.getElementById('import-page-singles-list'); - const files = importPageState.stagingFiles; - - if (files.length === 0) { - list.innerHTML = '
No audio files found in import folder
'; - return; - } - - list.innerHTML = files.map((f, i) => { - const isSelected = importPageState.selectedSingles.has(i); - const manualMatch = importPageState.singlesManualMatches[i]; - const searchOpen = document.querySelector(`[data-singles-search="${i}"]`); - - let html = ` -
-
-
-
${_esc(f.filename)}
-
- ${f.title ? `${_esc(f.title)}` : ''} - ${f.artist ? `${_esc(f.artist)}` : ''} - ${f.extension ? `${f.extension}` : ''} -
- ${manualMatch ? ` -
- ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)} - change -
- ` : ''} -
-
- -
-
- `; - return html; - }).join(''); - - importPageUpdateSinglesProcessButton(); -} - -function importPageToggleSingle(idx) { - if (importPageState.selectedSingles.has(idx)) { - importPageState.selectedSingles.delete(idx); - } else { - importPageState.selectedSingles.add(idx); - } - // Update checkbox UI without full re-render - const item = document.querySelector(`[data-single-idx="${idx}"]`); - if (item) { - const cb = item.querySelector('.import-page-single-checkbox'); - if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx)); - } - importPageUpdateSinglesProcessButton(); -} - -function importPageSelectAllSingles() { - const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length; - if (allSelected) { - importPageState.selectedSingles.clear(); - } else { - importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i)); - } - document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All'; - // Update all checkboxes - document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => { - cb.classList.toggle('checked', importPageState.selectedSingles.has(i)); - }); - importPageUpdateSinglesProcessButton(); -} - -function importPageUpdateSinglesProcessButton() { - const btn = document.getElementById('import-page-singles-process-btn'); - const count = importPageState.selectedSingles.size; - btn.textContent = `Process Selected (${count})`; - btn.disabled = count === 0; -} - -function importPageOpenSingleSearch(fileIdx) { - const item = document.querySelector(`[data-single-idx="${fileIdx}"]`); - if (!item) return; - - // Remove any existing search panel - const existing = item.querySelector('.import-page-single-search-panel'); - if (existing) { - existing.remove(); - return; - } - - // Close other open panels - document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove()); - - const f = importPageState.stagingFiles[fileIdx]; - const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, ''); - - const panel = document.createElement('div'); - panel.className = 'import-page-single-search-panel'; - panel.innerHTML = ` - -
- `; - item.appendChild(panel); - - // Auto-search - const input = panel.querySelector('input'); - input.focus(); - if (defaultQuery) { - importPageSearchSingleTrack(fileIdx, defaultQuery); - } -} - -async function importPageSearchSingleTrack(fileIdx, query) { - if (!query || !query.trim()) return; - - const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`); - if (!resultsDiv) return; - resultsDiv.innerHTML = '
Searching...
'; - - try { - const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`); - const data = await resp.json(); - if (!data.success || !data.tracks.length) { - resultsDiv.innerHTML = '
No results found
'; - return; - } - // Store results in a temp cache so we can reference by index - window._importSingleSearchResults = window._importSingleSearchResults || {}; - window._importSingleSearchResults[fileIdx] = data.tracks; - - resultsDiv.innerHTML = data.tracks.map((t, tIdx) => { - const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : ''; - return ` -
- ${t.image_url ? `` : ''} -
-
${_esc(t.name)} - ${_esc(t.artist)}
-
${_esc(t.album)}${dur ? ' · ' + dur : ''}
-
- -
- `; - }).join(''); - } catch (err) { - resultsDiv.innerHTML = `
Error: ${err.message}
`; - } -} - -function importPageSelectSingleMatch(fileIdx, trackIdx) { - const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx]; - if (!trackData) return; - importPageState.singlesManualMatches[fileIdx] = trackData; - - // Auto-select this file - importPageState.selectedSingles.add(fileIdx); - - // Close search panel and re-render this item - importPageRenderSinglesList(); -} - -// --- Singles Tab: Process --- - -function importPageProcessSingles() { - if (importPageState.selectedSingles.size === 0) return; - - const filesToProcess = Array.from(importPageState.selectedSingles).map(i => { - const f = importPageState.stagingFiles[i]; - const manualMatch = importPageState.singlesManualMatches[i]; - if (manualMatch) { - return { ...f, manual_match: manualMatch }; - } - return f; - }); - - // Add to queue and reset immediately - _importQueueAdd({ - type: 'singles', - label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`, - sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''), - imageUrl: null, - items: filesToProcess, - }); - - importPageState.selectedSingles.clear(); - importPageState.singlesManualMatches = {}; - importPageUpdateSinglesProcessButton(); - importPageRefreshStaging(); -} - -// --- Processing Queue --- - -const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors } - -function _importQueueAdd(job) { - const id = ++importJobIdCounter; - const entry = { - id, - type: job.type, - label: job.label, - sublabel: job.sublabel, - imageUrl: job.imageUrl, - status: 'running', // running | done | error - processed: 0, - total: job.items.length, - errors: [], - }; - _importQueue.push(entry); - _importQueueRender(); - - // Fire and forget — runs in background - _importQueueRunJob(entry, job); -} - -async function _importQueueRunJob(entry, job) { - for (let i = 0; i < job.items.length; i++) { - const itemName = job.type === 'album' - ? _importPageGetTrackDisplayInfo(job.items[i], i).name - : (job.items[i].title || job.items[i].filename || `File ${i + 1}`); - - // Update status with current track info - entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`; - _importQueueRender(); - - try { - let resp; - if (job.type === 'album') { - resp = await fetch('/api/import/album/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - album: job.albumData, - matches: [job.items[i]] - }) - }); - } else { - resp = await fetch('/api/import/singles/process', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ files: [job.items[i]] }) - }); - } - - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - if (data.success) entry.processed += (data.processed || 0); - if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors); - } catch (err) { - entry.errors.push(`${itemName}: ${err.message}`); - } - - _importQueueRender(); - } - - entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done'; - _importQueueRender(); - - // Refresh staging and suggestions since files moved - importPageRefreshStaging(); - importPageLoadSuggestions(); -} - -function _importQueueRender() { - const container = document.getElementById('import-page-queue'); - const list = document.getElementById('import-page-queue-list'); - const clearBtn = document.getElementById('import-page-queue-clear'); - if (!container || !list) return; - - if (_importQueue.length === 0) { - container.classList.add('hidden'); - return; - } - - container.classList.remove('hidden'); - - // Show clear button only if there are finished jobs - const hasFinished = _importQueue.some(j => j.status !== 'running'); - clearBtn.style.display = hasFinished ? '' : 'none'; - - list.innerHTML = _importQueue.map(j => { - const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0; - const fillClass = j.status === 'error' ? 'error' : ''; - let statusText, statusClass; - if (j.status === 'running') { - statusText = `${j.processed}/${j.total}`; - statusClass = ''; - } else if (j.status === 'done') { - statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done'; - statusClass = j.errors.length > 0 ? 'error' : 'done'; - } else { - statusText = 'Failed'; - statusClass = 'error'; - } - - return ` -
- ${j.imageUrl - ? `` - : `
`} -
-
${_esc(j.label)}
-
${_esc(j.sublabel)}
-
-
-
-
-
-
${statusText}
-
-
- `; - }).join(''); -} - -function importPageClearFinishedJobs() { - for (let i = _importQueue.length - 1; i >= 0; i--) { - if (_importQueue[i].status !== 'running') { - _importQueue.splice(i, 1); - } - } - _importQueueRender(); -} - // ── Import File Tab ────────────────────────────────────────────────── let _importFileState = { @@ -6056,14 +4682,15 @@ async function playArtistRadio() { const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; // Clear existing queue and disable radio before starting fresh - npSetRadioMode(false, { toast: false }); + npRadioMode = false; clearQueue(); if (audioPlayer && !audioPlayer.paused) { audioPlayer.pause(); } - // Play the track first so currentTrack is populated before radio seeds the queue. - await playLibraryTrack({ + // Play the track first, then enable radio mode after a short delay + // so currentTrack is set and the radio queue fill triggers + playLibraryTrack({ id: random.track.id, title: random.track.title, file_path: random.track.file_path, @@ -6072,9 +4699,14 @@ async function playArtistRadio() { album_id: random.album.id, }, random.album.title || '', artistName); - npSetRadioMode(true, { toast: false, fetchIfNeeded: true }); + // Enable radio mode after track starts loading + setTimeout(() => { + npRadioMode = true; + const radioBtn = document.querySelector('.np-radio-btn'); + if (radioBtn) radioBtn.classList.add('active'); + }, 1000); - showToast(`Playing ${artistName} radio - similar tracks will auto-queue`, 'success'); + showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); } catch (e) { showToast(`Failed to start artist radio: ${e.message}`, 'error'); } diff --git a/webui/static/style.css b/webui/static/style.css index ea65ca7f..7706efc0 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -10376,8 +10376,7 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(167, 139, 250, 0.25); } -.library-history-badge.download.source-staging, -.library-history-badge.download.source-auto-import { +.library-history-badge.download.source-staging { color: rgba(255, 255, 255, 0.55); background: rgba(255, 255, 255, 0.05); border-color: rgba(255, 255, 255, 0.12); @@ -40072,956 +40071,6 @@ div.artist-hero-badge { opacity: 1; } -/* ======================================== - IMPORT PAGE (full page, replaces modal) - ======================================== */ - -/* ============================================================================ - IMPORT PAGE - ============================================================================ */ - -.import-page-container { - max-width: 1200px; - margin: 0 auto; - padding: 24px 32px; -} - -.import-page-header { - margin-bottom: 20px; -} - -.import-page-title-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.import-page-title { - font-size: 28px; - font-weight: 700; - margin: 0; - letter-spacing: -0.3px; - font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif; - display: flex; - align-items: center; - gap: 14px; -} - -.import-page-title > span { - background: linear-gradient(90deg, #ffffff 0%, rgb(var(--accent-light-rgb)) 50%, rgb(var(--accent-rgb)) 100%); - background-size: 200% 100%; - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - animation: page-title-shimmer 6s ease-in-out infinite; -} - -.import-page-refresh-btn { - display: flex; - align-items: center; - gap: 6px; - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-refresh-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-staging-bar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -.import-staging-path { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-staging-stats { - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -/* Tab Bar */ -.import-page-tab-bar { - display: flex; - gap: 4px; - margin-bottom: 24px; - border-bottom: 1px solid rgba(255, 255, 255, 0.08); - padding-bottom: 0; -} - -.import-page-tab { - padding: 10px 24px; - background: none; - border: none; - border-bottom: 2px solid transparent; - color: rgba(255, 255, 255, 0.5); - font-size: 14px; - font-weight: 500; - cursor: pointer; - transition: all 0.2s; - margin-bottom: -1px; -} - -.import-page-tab:hover { - color: rgba(255, 255, 255, 0.8); -} - -.import-page-tab.active { - color: rgb(var(--accent-light-rgb)); - border-bottom-color: rgb(var(--accent-light-rgb)); -} - -.import-page-tab-content { - display: none; -} - -.import-page-tab-content.active { - display: block; -} - -/* Section labels */ -.import-page-section-label { - font-size: 13px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 12px; - text-transform: uppercase; - letter-spacing: 0.5px; - font-weight: 500; -} - -/* Search bar */ -.import-page-search-bar { - display: flex; - gap: 8px; - margin-bottom: 20px; - position: relative; -} - -.import-page-search-input { - flex: 1; - padding: 10px 16px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 10px; - color: #fff; - font-size: 14px; - outline: none; - transition: border-color 0.2s; -} - -.import-page-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.5); -} - -.import-page-search-btn { - padding: 10px 20px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: background 0.2s; - white-space: nowrap; -} - -.import-page-search-btn:hover { - background: #1fdf64; -} - -.import-page-clear-btn { - position: absolute; - right: 100px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: rgba(255, 255, 255, 0.4); - font-size: 16px; - cursor: pointer; - padding: 4px 8px; -} - -.import-page-clear-btn:hover { - color: #fff; -} - -/* Album grid */ -.import-page-album-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 16px; - margin-bottom: 24px; -} - -.import-page-album-card { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 12px; - cursor: pointer; - transition: all 0.2s; - text-align: center; -} - -.import-page-album-card:hover { - background: rgba(255, 255, 255, 0.1); - border-color: rgba(var(--accent-light-rgb), 0.3); - transform: translateY(-2px); -} - -.import-page-album-card img { - width: 100%; - aspect-ratio: 1; - object-fit: cover; - border-radius: 8px; - margin-bottom: 8px; -} - -.import-page-album-card-title { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - margin-bottom: 2px; -} - -.import-page-album-card-artist { - font-size: 11px; - color: rgba(255, 255, 255, 0.5); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-meta { - font-size: 10px; - color: rgba(255, 255, 255, 0.3); - margin-top: 4px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-detail { - font-size: 10px; - color: rgba(255, 255, 255, 0.36); - margin-top: 2px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-album-card-source { - font-size: 10px; - color: rgba(255, 200, 100, 0.75); - margin-top: 2px; - font-style: italic; -} - -.import-page-fallback-banner { - grid-column: 1 / -1; - padding: 10px 14px; - margin-bottom: 12px; - background: rgba(255, 200, 100, 0.08); - border: 1px solid rgba(255, 200, 100, 0.25); - border-radius: 8px; - color: rgba(255, 220, 170, 0.9); - font-size: 12px; - line-height: 1.4; -} - -/* Album hero (selected album) */ -.import-page-album-hero { - display: flex; - gap: 20px; - padding: 20px; - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 14px; - margin-bottom: 20px; - align-items: center; -} - -.import-page-album-hero img { - width: 120px; - height: 120px; - border-radius: 10px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-album-hero-info { - flex: 1; - min-width: 0; -} - -.import-page-album-hero-title { - font-size: 22px; - font-weight: 700; - color: #fff; - margin-bottom: 4px; -} - -.import-page-album-hero-artist { - font-size: 15px; - color: rgba(255, 255, 255, 0.6); - margin-bottom: 4px; -} - -.import-page-album-hero-meta { - font-size: 12px; - color: rgba(255, 255, 255, 0.35); -} - -/* Match header */ -.import-page-match-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 16px; -} - -.import-page-match-header h3 { - font-size: 16px; - font-weight: 600; - color: #fff; - margin: 0; -} - -.import-page-match-actions { - display: flex; - gap: 8px; -} - -/* Match list */ -.import-page-match-list { - display: flex; - flex-direction: column; - gap: 4px; - margin-bottom: 16px; -} - -.import-page-match-row { - display: grid; - grid-template-columns: 36px 1fr 1fr 36px; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - min-height: 44px; - transition: all 0.2s; -} - -.import-page-match-row.drag-over { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.4); - box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15); -} - -.import-page-match-row.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-match-num { - font-size: 13px; - color: rgba(255, 255, 255, 0.3); - text-align: center; - font-weight: 500; -} - -.import-page-match-track { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-match-file { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - display: flex; - align-items: center; - gap: 6px; - overflow: hidden; -} - -.import-page-match-file.has-file { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-match-file-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.import-page-match-confidence { - font-size: 10px; - padding: 2px 6px; - border-radius: 4px; - background: rgba(var(--accent-light-rgb), 0.15); - color: rgb(var(--accent-light-rgb)); - font-weight: 500; - white-space: nowrap; -} - -.import-page-match-confidence.low { - background: rgba(255, 165, 0, 0.15); - color: #ffa500; -} - -.import-page-match-drop-zone { - font-size: 11px; - color: rgba(255, 255, 255, 0.25); - font-style: italic; -} - -.import-page-match-unmatch { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; - cursor: pointer; - padding: 4px; - border-radius: 4px; - transition: all 0.2s; - display: flex; - align-items: center; - justify-content: center; -} - -.import-page-match-unmatch:hover { - color: #ff4444; - background: rgba(255, 68, 68, 0.1); -} - -/* Unmatched file pool */ -.import-page-unmatched-pool { - padding: 16px; - background: rgba(255, 255, 255, 0.02); - border: 1px dashed rgba(255, 255, 255, 0.1); - border-radius: 12px; - margin-bottom: 16px; -} - -.import-page-pool-label { - font-size: 12px; - color: rgba(255, 255, 255, 0.4); - margin-bottom: 10px; - font-weight: 500; -} - -.import-page-pool-chips { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.import-page-file-chip { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 20px; - font-size: 12px; - color: #ccc; - cursor: grab; - transition: all 0.2s; - user-select: none; -} - -.import-page-file-chip:active { - cursor: grabbing; -} - -.import-page-file-chip:hover { - background: rgba(255, 255, 255, 0.14); - border-color: rgba(var(--accent-light-rgb), 0.3); - color: #fff; -} - -.import-page-file-chip.selected { - background: rgba(var(--accent-light-rgb), 0.15); - border-color: rgba(var(--accent-light-rgb), 0.4); - color: rgb(var(--accent-light-rgb)); -} - -.import-page-pool-empty { - font-size: 12px; - color: rgba(255, 255, 255, 0.2); - font-style: italic; -} - -/* Match footer */ -.import-page-match-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding-top: 16px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-match-stats { - font-size: 13px; - color: rgba(255, 255, 255, 0.5); -} - -/* Buttons */ -.import-page-process-btn { - padding: 10px 24px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 10px; - color: #000; - font-size: 14px; - font-weight: 600; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-process-btn:hover { - background: #1fdf64; -} - -.import-page-process-btn:disabled { - background: rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.3); - cursor: not-allowed; -} - -.import-page-secondary-btn { - padding: 8px 16px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #ccc; - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-secondary-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -.import-page-back-btn { - padding: 8px 16px; - background: none; - border: 1px solid rgba(255, 255, 255, 0.1); - border-radius: 8px; - color: rgba(255, 255, 255, 0.5); - font-size: 13px; - cursor: pointer; - transition: all 0.2s; -} - -.import-page-back-btn:hover { - color: #fff; - border-color: rgba(255, 255, 255, 0.2); -} - -/* Singles */ -.import-page-singles-header { - display: flex; - align-items: center; - justify-content: flex-end; - margin-bottom: 16px; -} - -.import-page-singles-actions { - display: flex; - gap: 8px; - align-items: center; -} - -.import-page-singles-list { - display: flex; - flex-direction: column; - gap: 4px; -} - -.import-page-single-item { - display: grid; - grid-template-columns: 32px 1fr auto; - align-items: center; - gap: 12px; - padding: 10px 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 10px; - transition: all 0.2s; -} - -.import-page-single-item.matched { - border-color: rgba(var(--accent-light-rgb), 0.2); -} - -.import-page-single-checkbox { - width: 18px; - height: 18px; - border: 2px solid rgba(255, 255, 255, 0.2); - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.import-page-single-checkbox.checked { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.import-page-single-checkbox.checked::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; -} - -.import-page-single-info { - min-width: 0; -} - -.import-page-single-filename { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-meta { - font-size: 11px; - color: rgba(255, 255, 255, 0.35); - margin-top: 2px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.import-page-single-matched-info { - font-size: 12px; - color: rgb(var(--accent-light-rgb)); - margin-top: 4px; - display: flex; - align-items: center; - gap: 6px; -} - -.import-page-single-matched-change { - color: rgba(255, 255, 255, 0.4); - cursor: pointer; - font-size: 11px; - text-decoration: underline; -} - -.import-page-single-matched-change:hover { - color: #fff; -} - -.import-page-single-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -.import-page-identify-btn { - padding: 6px 12px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - color: #ccc; - font-size: 12px; - cursor: pointer; - transition: all 0.2s; - display: flex; - align-items: center; - gap: 4px; -} - -.import-page-identify-btn:hover { - background: rgba(255, 255, 255, 0.14); - color: #fff; -} - -/* Inline search panel for singles */ -.import-page-single-search-panel { - grid-column: 1 / -1; - padding: 12px 0 4px 44px; -} - -.import-page-single-search-bar { - display: flex; - gap: 8px; - margin-bottom: 10px; -} - -.import-page-single-search-input { - flex: 1; - padding: 8px 12px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - color: #fff; - font-size: 13px; - outline: none; -} - -.import-page-single-search-input:focus { - border-color: rgba(var(--accent-light-rgb), 0.4); -} - -.import-page-single-search-go { - padding: 8px 14px; - background: rgb(var(--accent-light-rgb)); - border: none; - border-radius: 8px; - color: #000; - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -.import-page-single-search-results { - display: flex; - flex-direction: column; - gap: 4px; - max-height: 200px; - overflow-y: auto; -} - -.import-page-single-result-item { - display: flex; - align-items: center; - gap: 10px; - padding: 8px 10px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 8px; - cursor: pointer; - transition: all 0.15s; -} - -.import-page-single-result-item:hover { - background: rgba(var(--accent-light-rgb), 0.08); - border-color: rgba(var(--accent-light-rgb), 0.25); -} - -.import-page-single-result-img { - width: 36px; - height: 36px; - border-radius: 4px; - object-fit: cover; - flex-shrink: 0; -} - -.import-page-single-result-info { - flex: 1; - min-width: 0; -} - -.import-page-single-result-name { - font-size: 13px; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-single-result-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); -} - -.import-page-single-result-select { - padding: 4px 12px; - background: rgba(var(--accent-light-rgb), 0.15); - border: 1px solid rgba(var(--accent-light-rgb), 0.3); - border-radius: 6px; - color: rgb(var(--accent-light-rgb)); - font-size: 11px; - font-weight: 600; - cursor: pointer; - flex-shrink: 0; -} - -.import-page-single-result-select:hover { - background: rgba(var(--accent-light-rgb), 0.25); -} - -/* Empty state */ -.import-page-empty-state { - text-align: center; - padding: 60px 20px; - color: rgba(255, 255, 255, 0.3); - font-size: 14px; -} - -/* Suggestions */ -.import-page-suggestions { - margin-bottom: 24px; -} - -/* Processing Queue */ -.import-page-queue { - margin-bottom: 20px; - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - background: rgba(255, 255, 255, 0.02); - overflow: hidden; -} - -.import-page-queue-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); -} - -.import-page-queue-title { - font-size: 13px; - font-weight: 600; - color: rgba(255, 255, 255, 0.6); - text-transform: uppercase; - letter-spacing: 0.5px; -} - -.import-page-queue-clear { - background: none; - border: none; - color: rgba(255, 255, 255, 0.3); - font-size: 12px; - cursor: pointer; - padding: 2px 8px; -} - -.import-page-queue-clear:hover { - color: #fff; -} - -.import-page-queue-list { - display: flex; - flex-direction: column; -} - -.import-page-queue-item { - display: grid; - grid-template-columns: 44px 1fr auto; - gap: 12px; - align-items: center; - padding: 10px 16px; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); -} - -.import-page-queue-item:last-child { - border-bottom: none; -} - -.import-page-queue-art { - width: 44px; - height: 44px; - border-radius: 6px; - object-fit: cover; -} - -.import-page-queue-info { - min-width: 0; -} - -.import-page-queue-name { - font-size: 13px; - font-weight: 600; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.import-page-queue-detail { - font-size: 11px; - color: rgba(255, 255, 255, 0.4); - margin-top: 2px; -} - -.import-page-queue-progress { - width: 120px; - flex-shrink: 0; -} - -.import-page-queue-bar { - height: 4px; - background: rgba(255, 255, 255, 0.08); - border-radius: 2px; - overflow: hidden; - margin-bottom: 4px; -} - -.import-page-queue-fill { - height: 100%; - background: rgb(var(--accent-light-rgb)); - border-radius: 2px; - width: 0%; - transition: width 0.3s ease; -} - -.import-page-queue-fill.error { - background: #ef4444; -} - -.import-page-queue-status { - font-size: 10px; - color: rgba(255, 255, 255, 0.4); - text-align: right; -} - -.import-page-queue-status.done { - color: rgb(var(--accent-light-rgb)); -} - -.import-page-queue-status.error { - color: #ef4444; -} - -/* ======================================== - END IMPORT PAGE - ======================================== */ - /* ======================================== FAILED DOWNLOAD ERROR TOOLTIP ======================================== */ @@ -45706,10 +44755,6 @@ textarea.enhanced-meta-field-input { .discog-download-wrap { margin: 12px 0 4px; } -.artist-hero-actions .discog-download-wrap { margin: 0; } - -.artist-hero-actions .discog-btn-compact { margin-top: 0; } - .discog-download-btn { position: relative; display: flex; @@ -45757,112 +44802,6 @@ textarea.enhanced-meta-field-input { animation: discogShimmer 3s ease-in-out infinite; } -.artist-hero-actions { - gap: 10px; - align-items: center; -} - -.artist-hero-actions .library-artist-radio-btn, -.artist-hero-actions .library-artist-watchlist-btn, -.artist-hero-actions .discog-download-btn, -.artist-hero-actions .library-artist-enhance-btn { - --artist-action-rgb: var(--accent-rgb); - min-height: 38px; - width: auto; - max-width: none; - padding: 8px 14px 8px 12px; - gap: 8px; - border-radius: 999px; - color: rgba(255, 255, 255, 0.82); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.13), rgba(var(--artist-action-rgb), 0.03)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--artist-action-rgb), 0.26); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.08), - 0 8px 22px rgba(0, 0, 0, 0.22); - font-size: 12px; - font-weight: 650; - letter-spacing: 0; - line-height: 1; - backdrop-filter: blur(14px) saturate(1.25); - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn { --artist-action-rgb: 79, 195, 247; } -.artist-hero-actions .library-artist-watchlist-btn { --artist-action-rgb: 255, 193, 7; } -.artist-hero-actions .discog-download-btn { --artist-action-rgb: var(--accent-rgb); } -.artist-hero-actions .library-artist-enhance-btn { --artist-action-rgb: 180, 132, 255; } - -.artist-hero-actions .library-artist-radio-btn::before, -.artist-hero-actions .library-artist-watchlist-btn::before, -.artist-hero-actions .library-artist-enhance-btn::before, -.artist-hero-actions .discog-download-btn::before { - content: ''; - width: 7px; - height: 7px; - border-radius: 999px; - position: relative; - inset: auto; - flex: 0 0 auto; - opacity: 1; - background: rgb(var(--artist-action-rgb)); - box-shadow: 0 0 14px rgba(var(--artist-action-rgb), 0.85); - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.artist-hero-actions .library-artist-radio-btn:hover, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled), -.artist-hero-actions .discog-download-btn:hover, -.artist-hero-actions .library-artist-enhance-btn:hover { - color: #ffffff; - border-color: rgba(var(--artist-action-rgb), 0.5); - background: - linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.22), rgba(var(--artist-action-rgb), 0.06)), - rgba(255, 255, 255, 0.055); - box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.12), - 0 12px 28px rgba(0, 0, 0, 0.28), - 0 0 0 1px rgba(var(--artist-action-rgb), 0.12); - transform: translateY(-1px); -} - -.artist-hero-actions .library-artist-radio-btn:hover::before, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled)::before, -.artist-hero-actions .discog-download-btn:hover::before, -.artist-hero-actions .library-artist-enhance-btn:hover::before { - transform: scale(1.25); - box-shadow: 0 0 18px rgba(var(--artist-action-rgb), 1); -} - -.artist-hero-actions .library-artist-watchlist-btn.watching { - color: #ffd761; - border-color: rgba(255, 193, 7, 0.52); - background: - linear-gradient(135deg, rgba(255, 193, 7, 0.24), rgba(255, 193, 7, 0.06)), - rgba(255, 255, 255, 0.04); -} - -.artist-hero-actions .library-artist-radio-btn .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn .watchlist-icon, -.artist-hero-actions .discog-download-btn .discog-btn-icon, -.artist-hero-actions .library-artist-enhance-btn .enhance-icon { - font-size: 13px; - position: relative; - z-index: 1; - transform: none; -} - -.artist-hero-actions .library-artist-radio-btn:hover .radio-icon, -.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon, -.artist-hero-actions .library-artist-enhance-btn:hover .enhance-icon { - transform: none; -} - -.artist-hero-actions .discog-btn-shimmer { - display: none; -} - @keyframes discogShimmer { 0%, 100% { left: -100%; } 50% { left: 150%; } @@ -47281,56 +46220,26 @@ textarea.enhanced-meta-field-input { /* Radio mode button */ .np-radio-btn { - --np-radio-rgb: 79, 195, 247; - position: relative; - overflow: hidden; - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.1), rgba(var(--np-radio-rgb), 0.025)), - rgba(255, 255, 255, 0.035); - border: 1px solid rgba(var(--np-radio-rgb), 0.22); - color: rgba(255, 255, 255, 0.62); + background: none; + border: 1px solid rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.35); font-size: 11px; - font-weight: 650; - line-height: 1; - padding: 7px 10px; - border-radius: 999px; + padding: 3px 8px; + border-radius: 6px; cursor: pointer; display: flex; align-items: center; - gap: 6px; - min-height: 30px; - transition: all 0.18s ease; + gap: 4px; + transition: all 0.15s ease; } .np-radio-btn:hover { - color: rgba(255, 255, 255, 0.9); - border-color: rgba(var(--np-radio-rgb), 0.42); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.16), rgba(var(--np-radio-rgb), 0.04)), - rgba(255, 255, 255, 0.055); + color: rgba(255, 255, 255, 0.7); + border-color: rgba(255, 255, 255, 0.2); } .np-radio-btn.active { - color: #ffffff; - border-color: rgba(var(--np-radio-rgb), 0.55); - background: - linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.28), rgba(var(--np-radio-rgb), 0.07)), - rgba(255, 255, 255, 0.06); - box-shadow: 0 0 0 1px rgba(var(--np-radio-rgb), 0.08), 0 0 18px rgba(var(--np-radio-rgb), 0.12); -} -.np-radio-label { - position: relative; - z-index: 1; -} -.np-radio-pulse { - width: 6px; - height: 6px; - border-radius: 50%; - background: rgba(255, 255, 255, 0.24); - box-shadow: none; - transition: all 0.18s ease; -} -.np-radio-btn.active .np-radio-pulse { - background: rgb(var(--np-radio-rgb)); - box-shadow: 0 0 12px rgba(var(--np-radio-rgb), 0.95); + color: rgb(var(--accent-rgb)); + border-color: rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); } .np-queue-header-actions { display: flex; @@ -50453,18 +49362,15 @@ tr.tag-diff-same { transition: transform 0.2s ease; } -.repair-master-toggle input, -.auto-import-toggle-label input { +.repair-master-toggle input { display: none; } -.repair-master-toggle input:checked + .repair-toggle-slider, -.auto-import-toggle-label input:checked + .repair-toggle-slider { +.repair-master-toggle input:checked + .repair-toggle-slider { background: var(--accent-color, #6366f1); } -.repair-master-toggle input:checked + .repair-toggle-slider::after, -.auto-import-toggle-label input:checked + .repair-toggle-slider::after { +.repair-master-toggle input:checked + .repair-toggle-slider::after { transform: translateX(20px); } @@ -59601,388 +58507,6 @@ body.reduce-effects *::after { .wl-orb-group.expanded { max-width: 100%; } } -/* ═══════════════════════════════════════════════════════════════════ - AUTO-IMPORT TAB - ═══════════════════════════════════════════════════════════════════ */ - -.auto-import-controls { - padding: 12px 0 16px; - border-bottom: 1px solid rgba(255,255,255,0.05); - margin-bottom: 16px; -} - -.auto-import-toggle-row { - display: flex; - align-items: center; - gap: 12px; -} - -.auto-import-toggle-label { - display: flex; - align-items: center; - gap: 8px; - cursor: pointer; - font-size: 13px; - font-weight: 600; - color: rgba(255,255,255,0.7); -} - -.auto-import-toggle-label input { display: none; } - -.auto-import-status { - font-size: 12px; - font-weight: 500; - padding: 2px 10px; - border-radius: 6px; -} -.auto-import-status.active { color: #4ade80; background: rgba(74,222,128,0.1); } -.auto-import-status.scanning { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); } -.auto-import-status.disabled { color: rgba(255,255,255,0.3); } - -.auto-import-settings-row { - display: flex; - align-items: center; - gap: 16px; - margin-top: 10px; - flex-wrap: wrap; - font-size: 12px; - color: rgba(255,255,255,0.5); -} - -.auto-import-settings-row label { display: flex; align-items: center; gap: 6px; } -.auto-import-settings-row input[type="range"] { width: 100px; } -.auto-import-settings-row select { - background: rgba(255,255,255,0.05); - border: 1px solid rgba(255,255,255,0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} - -.auto-import-empty { - text-align: center; - padding: 40px 20px; - color: rgba(255,255,255,0.3); - font-size: 13px; -} - -/* Result cards */ -.auto-import-card { - display: flex; - flex-direction: column; - padding: 14px 16px; - background: rgba(255,255,255,0.02); - border: 1px solid rgba(255,255,255,0.06); - border-radius: 12px; - margin-bottom: 8px; - transition: all 0.2s; -} - -.auto-import-card-top { - display: flex; - gap: 14px; - align-items: center; -} - -.auto-import-card:hover { - background: rgba(255,255,255,0.04); - border-color: rgba(255,255,255,0.1); -} - -.auto-import-completed { border-left: 3px solid #4ade80; } -.auto-import-review { border-left: 3px solid #fbbf24; } -.auto-import-failed { border-left: 3px solid #f87171; } -.auto-import-processing { border-left: 3px solid #60a5fa; } - -.auto-import-card-art { - width: 56px; height: 56px; - border-radius: 8px; - object-fit: cover; - flex-shrink: 0; -} - -.auto-import-card-art-fallback { - width: 56px; height: 56px; - border-radius: 8px; - background: rgba(255,255,255,0.05); - display: flex; align-items: center; justify-content: center; - font-size: 22px; opacity: 0.3; flex-shrink: 0; -} - -.auto-import-card-center { flex: 1; min-width: 0; } - -.auto-import-card-album { - font-size: 14px; font-weight: 600; color: #fff; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-artist { - font-size: 12px; color: rgba(255,255,255,0.45); - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-folder { - font-size: 10px; color: rgba(255,255,255,0.25); margin-top: 2px; -} - -.auto-import-match-info { - font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 2px; -} - -.auto-import-card-error { - font-size: 10px; color: #f87171; margin-top: 2px; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} - -.auto-import-card-right { - display: flex; flex-direction: column; align-items: flex-end; gap: 4px; - flex-shrink: 0; min-width: 80px; -} - -.auto-import-confidence-bar { - width: 60px; height: 4px; - background: rgba(255,255,255,0.06); - border-radius: 2px; overflow: hidden; -} - -.auto-import-confidence-fill { height: 100%; border-radius: 2px; } -.auto-import-conf-high { background: #4ade80; } -.auto-import-conf-medium { background: #fbbf24; } -.auto-import-conf-low { background: #f87171; } - -.auto-import-confidence-text { - font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5); -} - -/* Scan Now button */ -.auto-import-scan-now-btn { - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - color: rgba(255,255,255,0.6); - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 5px; - transition: all 0.15s; - margin-left: auto; -} - -.auto-import-scan-now-btn:hover { - background: rgba(255,255,255,0.1); - color: rgba(255,255,255,0.9); -} - -/* Live progress */ -.auto-import-progress { - margin-top: 8px; - padding: 8px 12px; - background: rgba(var(--accent-rgb), 0.04); - border: 1px solid rgba(var(--accent-rgb), 0.1); - border-radius: 8px; -} - -.auto-import-progress-text { - font-size: 11px; - color: rgba(255,255,255,0.6); - margin-bottom: 4px; -} - -.auto-import-progress-bar { - height: 3px; - background: rgba(255,255,255,0.06); - border-radius: 2px; - overflow: hidden; -} - -.auto-import-progress-fill { - height: 100%; - background: rgba(var(--accent-rgb), 0.6); - border-radius: 2px; - width: 100%; - animation: adlPulse 1.5s ease-in-out infinite; -} - -/* Stats summary */ -.auto-import-stats { - display: flex; - gap: 16px; - padding: 8px 0; - margin-bottom: 4px; -} - -.auto-import-stat { - font-size: 12px; - color: rgba(255,255,255,0.5); - font-weight: 500; -} - -.auto-import-stat-review { color: #fbbf24; } -.auto-import-stat-failed { color: #f87171; } - -/* Filter pills */ -.auto-import-filters { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 0; - margin-bottom: 8px; -} - -/* Batch action buttons */ -.auto-import-batch-btn { - font-size: 11px; - padding: 4px 12px; - border-radius: 6px; - border: 1px solid rgba(var(--accent-rgb), 0.3); - background: rgba(var(--accent-rgb), 0.08); - color: rgba(var(--accent-rgb), 1); - cursor: pointer; - transition: all 0.15s; -} - -.auto-import-batch-btn:hover { - background: rgba(var(--accent-rgb), 0.15); -} - -.auto-import-clear-btn { - border-color: rgba(255,255,255,0.1); - background: rgba(255,255,255,0.04); - color: rgba(255,255,255,0.5); -} - -.auto-import-clear-btn:hover { - background: rgba(255,255,255,0.08); - color: rgba(255,255,255,0.8); -} - -.auto-import-card-meta { - display: flex; gap: 8px; align-items: center; - font-size: 10px; color: rgba(255,255,255,0.3); margin-top: 3px; -} - -.auto-import-method-badge { - background: rgba(255,255,255,0.06); - padding: 1px 6px; - border-radius: 4px; - font-size: 9px; - color: rgba(255,255,255,0.45); - text-transform: uppercase; - letter-spacing: 0.3px; -} - -.auto-import-card-folder-path { - font-size: 9px; - color: rgba(255,255,255,0.15); - margin-top: 6px; - padding-top: 6px; - border-top: 1px solid rgba(255,255,255,0.03); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Expandable track list */ -.auto-import-track-list { - display: none; - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255,255,255,0.04); -} - -.auto-import-track-list.expanded { - display: block; -} - -.auto-import-track-list-header { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - font-size: 9px; - font-weight: 600; - color: rgba(255,255,255,0.3); - text-transform: uppercase; - letter-spacing: 0.5px; - padding-bottom: 4px; - margin-bottom: 4px; - border-bottom: 1px solid rgba(255,255,255,0.03); -} - -.auto-import-track-row { - display: grid; - grid-template-columns: 1fr 1fr 50px; - gap: 8px; - padding: 3px 0; - font-size: 11px; -} - -.auto-import-track-name { - color: rgba(255,255,255,0.7); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-file { - color: rgba(255,255,255,0.3); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.auto-import-track-conf { - text-align: right; - font-weight: 600; - font-size: 10px; -} - -.auto-import-track-conf.auto-import-conf-high { color: #4ade80; } -.auto-import-track-conf.auto-import-conf-medium { color: #fbbf24; } -.auto-import-track-conf.auto-import-conf-low { color: #f87171; } - -.auto-import-status-badge { - font-size: 9px; font-weight: 600; padding: 2px 8px; - border-radius: 6px; white-space: nowrap; -} -.auto-import-badge-completed { background: rgba(74,222,128,0.1); color: #4ade80; } -.auto-import-badge-review { background: rgba(251,191,36,0.1); color: #fbbf24; } -.auto-import-badge-failed { background: rgba(248,113,113,0.1); color: #f87171; } -.auto-import-badge-neutral { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4); } -.auto-import-badge-processing { - background: rgba(96,165,250,0.12); - color: #60a5fa; - animation: auto-import-badge-pulse 1.6s ease-in-out infinite; -} -@keyframes auto-import-badge-pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.55; } -} - -.auto-import-track-row-active { - background: rgba(96,165,250,0.08); - border-left: 2px solid #60a5fa; - padding-left: 6px; - margin-left: -8px; - border-radius: 3px; -} -.auto-import-track-row-active .auto-import-track-name { color: #60a5fa; font-weight: 600; } -.auto-import-track-row-done .auto-import-track-name, -.auto-import-track-row-done .auto-import-track-file { opacity: 0.4; } - -.auto-import-actions { - display: flex; gap: 4px; margin-top: 4px; -} - -.auto-import-actions button { font-size: 10px; padding: 3px 10px; } - -@media (max-width: 768px) { - .auto-import-card { flex-direction: column; align-items: flex-start; } - .auto-import-card-right { flex-direction: row; width: 100%; justify-content: space-between; } -} - /* ── Legacy (hidden) ── */ #wishlist-page-categories { display: none; margin-bottom: 24px; From 3fdc81579439fbac1972f55690bcc2be3627bbdc Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:08:05 +0300 Subject: [PATCH 12/42] fix(webui): show import refresh timestamp --- .../routes/import/-ui/import-page.module.css | 10 +++++ webui/src/routes/import/-ui/import-page.tsx | 37 ++++++++++++++++--- webui/src/routes/import/-ui/import-shared.tsx | 4 +- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index db15a907..209d6d52 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -63,6 +63,11 @@ color: #fff; } +.importPageRefreshBtnRefreshing { + opacity: 0.72; + cursor: progress; +} + .importPageStagingBar { display: flex; align-items: center; @@ -88,6 +93,11 @@ white-space: nowrap; } +.importStagingRefreshAt { + white-space: nowrap; + color: rgba(255, 255, 255, 0.4); +} + /* Tab Bar */ .importPageTabBar { display: flex; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index b92bb8a8..9180903e 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -1,9 +1,9 @@ import { Link, Outlet } from '@tanstack/react-router'; +import { Show } from '@/components/primitives'; import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { ImportQueueEntry } from '../-import.types'; -import styles from './import-page.module.css'; import { getQueueProgressPercent, @@ -11,12 +11,16 @@ import { getStagingStatsText, } from '../-import.helpers'; import { useImportQueueWorkflow } from '../-import.store'; +import styles from './import-page.module.css'; import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared'; export function ImportPage() { useReactPageShell('import'); const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging(); + const isRefreshing = stagingQuery.isRefetching; + const lastRefreshedAt = + stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null; return (
@@ -26,6 +30,8 @@ export function ImportPage() { fileCountText={getStagingStatsText(stagingFiles)} loading={stagingQuery.isLoading} stagingPath={stagingPath} + refreshing={isRefreshing} + lastRefreshedAt={lastRefreshedAt} onRefresh={refreshStaging} /> @@ -38,17 +44,29 @@ export function ImportPage() { ); } +function formatShortTime(timestamp: number) { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + function ImportHeader({ error, fileCountText, loading, stagingPath, + refreshing, + lastRefreshedAt, onRefresh, }: { error: unknown; fileCountText: string; loading: boolean; stagingPath: string; + refreshing: boolean; + lastRefreshedAt: string | null; onRefresh: () => void; }) { return ( @@ -60,18 +78,27 @@ function ImportHeader({
{error ? 'Import folder: error' : `Import: ${stagingPath}`} + + + {lastRefreshedAt ? `Last refreshed: ${lastRefreshedAt}` : null} + + {loading ? 'loading...' : fileCountText} @@ -129,9 +156,7 @@ function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { onError={fallbackImage} /> ) : ( -
- A -
+
A
)}
{entry.label}
diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx index b025f2a4..dfa5bdda 100644 --- a/webui/src/routes/import/-ui/import-shared.tsx +++ b/webui/src/routes/import/-ui/import-shared.tsx @@ -19,9 +19,9 @@ export function useImportStaging() { }); return { - refreshStaging: () => { + refreshStaging: async () => { clearFinishedJobs(); - void invalidateImportStagingQueries(queryClient); + await invalidateImportStagingQueries(queryClient); }, stagingFiles: stagingQuery.data?.files ?? [], stagingPath: stagingQuery.data?.staging_path || 'Not configured', From fe8ae8f2900f8b212f715ae9621f1b9fd8638568 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:32:04 +0300 Subject: [PATCH 13/42] fix(webui): restore import glyphs - bring the React import page back in line with the legacy emoji/glyph treatment\n- restore album, singles, auto-import, and queue fallback icons\n- keep the visual refresh aligned with the old page while preserving the React port --- webui/src/routes/import/-import.helpers.ts | 18 +++++++++--------- .../src/routes/import/-ui/album-import-tab.tsx | 6 +++--- .../src/routes/import/-ui/auto-import-tab.tsx | 2 +- .../routes/import/-ui/import-page.module.css | 1 + webui/src/routes/import/-ui/import-page.tsx | 2 +- .../routes/import/-ui/singles-import-tab.tsx | 4 ++-- 6 files changed, 17 insertions(+), 16 deletions(-) diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts index 0805b5d9..d6f8d288 100644 --- a/webui/src/routes/import/-import.helpers.ts +++ b/webui/src/routes/import/-import.helpers.ts @@ -235,15 +235,15 @@ export function getAutoImportStatusMeta(status: string): { }; const icons: Record = { - completed: 'OK', - pending_review: '!', - needs_identification: 'x', - failed: 'x', - scanning: '~', - matched: 'OK', - rejected: 'x', - approved: 'OK', - processing: '~', + completed: '✓', + pending_review: '⚠', + needs_identification: '✗', + failed: '✗', + scanning: '⌛', + matched: '✓', + rejected: '✕', + approved: '✓', + processing: '⧗', }; return { diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx index aa07185b..530fb59c 100644 --- a/webui/src/routes/import/-ui/album-import-tab.tsx +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -256,7 +256,7 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode {group.album}
- {group.artist} - {group.file_count} tracks + {group.artist} · {group.file_count} tracks
@@ -369,7 +369,7 @@ function AlbumCard({ {album.artist}
- {album.total_tracks || 0} tracks - {album.release_date?.substring(0, 4) || ''} + {album.total_tracks || 0} tracks · {album.release_date?.substring(0, 4) || ''}
); @@ -422,7 +422,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
{albumMatch.album.name}
{albumMatch.album.artist}
- {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '} + {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks ·{' '} {albumMatch.album.release_date?.substring(0, 4) || ''}
diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index c8ac1075..62b94c84 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -417,7 +417,7 @@ function AutoImportResultCard({ onError={fallbackImage} /> ) : ( -
Album
+
💿
)}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 209d6d52..eb49b5ae 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -115,6 +115,7 @@ color: rgba(255, 255, 255, 0.5); font-size: 14px; font-weight: 500; + text-decoration: none; cursor: pointer; transition: all 0.2s; margin-bottom: -1px; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 9180903e..05a22599 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -156,7 +156,7 @@ function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) { onError={fallbackImage} /> ) : ( -
A
+
)}
{entry.label}
diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 2c3f5a53..b6c3c40d 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -214,7 +214,7 @@ export function SinglesImportPanel({
{manualMatch ? (
- OK {manualMatch.name} - {manualMatch.artist} + ✓ {manualMatch.name} - {manualMatch.artist}
{openSearchIndex === index ? ( From 89252cf6e4c28c6f6af94d484ee650cf733bb642 Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 18:54:08 +0300 Subject: [PATCH 14/42] fix(webui): refine import singles checkbox - switch the singles selector to a real checkbox input for cleaner semantics\n- keep the mobile import page layout aligned while avoiding legacy button sizing\n- tune the checkbox tick so it stays visually centered and readable --- .../routes/import/-ui/import-page.module.css | 81 ++++++++++++++++--- .../routes/import/-ui/singles-import-tab.tsx | 18 +++-- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index eb49b5ae..b53652d5 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -628,9 +628,26 @@ border-color: rgba(var(--accent-light-rgb), 0.2); } +.importPageSingleCheckboxWrap { + grid-area: checkbox; + position: relative; + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 2px; +} + +.importPageSingleCheckboxInput { + position: absolute; + inset: 0; + opacity: 0; + margin: 0; +} + .importPageSingleCheckbox { width: 18px; height: 18px; + box-sizing: border-box; border: 2px solid rgba(255, 255, 255, 0.2); background: transparent; padding: 0; @@ -643,16 +660,28 @@ flex-shrink: 0; } -.importPageSingleCheckbox.checked { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.importPageSingleCheckbox.checked::after { +.importPageSingleCheckbox::after { content: '✓'; color: #000; font-size: 12px; font-weight: 700; + line-height: 1; + opacity: 0; + transform: translateY(-0.5px) scaleX(1.18); +} + +.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); +} + +.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox { + background: rgb(var(--accent-light-rgb)); + border-color: rgb(var(--accent-light-rgb)); +} + +.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after { + opacity: 1; } .importPageSingleInfo { @@ -1010,17 +1039,29 @@ } .importPageMatchRow { - grid-template-columns: 28px 1fr; - gap: 8px; + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "num track track" + "num file unmatch"; + gap: 6px 8px; + align-items: center; + } + + .importPageMatchNum { + grid-area: num; + } + + .importPageMatchTrack { + grid-area: track; } .importPageMatchFile { - grid-column: 1 / -1; - padding-left: 28px; + grid-area: file; + min-width: 0; } .importPageMatchUnmatch { - grid-column: 1 / -1; + grid-area: unmatch; justify-self: end; } @@ -1035,12 +1076,26 @@ } .importPageSingleItem { - grid-template-columns: 28px 1fr; + grid-template-columns: 28px minmax(0, 1fr) auto; + grid-template-areas: + "checkbox info actions" + "search search search"; + align-items: start; + gap: 8px; + } + + .importPageSingleInfo { + grid-area: info; } .importPageSingleActions { - grid-column: 1 / -1; + grid-area: actions; justify-self: end; + align-self: start; + } + + .importPageSingleSearchPanel { + grid-area: search; } .importPageSingleSearchPanel { diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index b6c3c40d..2b7133b9 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -197,14 +197,16 @@ export function SinglesImportPanel({ }`} data-single-idx={index} > -
-
+
{files.length === 0 ? (
No audio files found in import folder
) : ( - files.map((file, index) => { - const manualMatch = manualMatches[index]; - const isSelected = selected.has(index); - const searchState = searchStates[index]; + files.map((file) => { + const fileKey = getStagingFileKey(file); + const manualMatch = manualMatches[fileKey]; + const isSelected = selected.has(fileKey); + const searchState = searchStates[fileKey]; return (
@@ -220,7 +228,7 @@ export function SinglesImportPanel({ @@ -231,14 +239,14 @@ export function SinglesImportPanel({
- {openSearchIndex === index ? ( + {openSearchKey === fileKey ? ( void; - onRunSearch: (index: number, query: string) => void; - onSelectMatch: (fileIndex: number, track: ImportTrackResult) => void; + onQueryChange: (fileKey: string, query: string) => void; + onRunSearch: (fileKey: string, query: string) => void; + onSelectMatch: (fileKey: string, track: ImportTrackResult) => void; }) { const query = searchState?.query ?? ''; @@ -277,20 +285,20 @@ function SingleSearchPanel({ className={styles.importPageSingleSearchInput} value={query} placeholder="Search artist - title..." - onChange={(event) => onQueryChange(fileIndex, event.target.value)} + onChange={(event) => onQueryChange(fileKey, event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter') onRunSearch(fileIndex, query); + if (event.key === 'Enter') onRunSearch(fileKey, query); }} />
-
+
{searchState?.loading ? (
Searching...
) : searchState?.error ? ( @@ -303,7 +311,7 @@ function SingleSearchPanel({ key={`${track.source || 'source'}-${track.id}-${index}`} type="button" className={styles.importPageSingleResultItem} - onClick={() => onSelectMatch(fileIndex, track)} + onClick={() => onSelectMatch(fileKey, track)} > {track.image_url ? ( Date: Sat, 16 May 2026 19:58:36 +0300 Subject: [PATCH 16/42] refactor(webui): share import form primitives - add shared Base UI-backed checkbox and slider primitives under the form component layer - move the singles import checkbox and auto-import confidence slider to the shared controls - keep the import route tests aligned with the new accessible component roles --- webui/src/components/form/form.module.css | 114 ++++++++++++++++++ webui/src/components/form/form.test.tsx | 28 +++++ webui/src/components/form/form.tsx | 81 +++++++++++++ webui/src/routes/import/-route.test.tsx | 8 +- .../routes/import/-ui/album-import-tab.tsx | 28 +++-- .../src/routes/import/-ui/auto-import-tab.tsx | 63 +++++----- .../routes/import/-ui/import-page.module.css | 73 ++--------- webui/src/routes/import/-ui/import-page.tsx | 9 +- .../routes/import/-ui/singles-import-tab.tsx | 25 ++-- 9 files changed, 298 insertions(+), 131 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index bf673cac..54638847 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -129,6 +129,120 @@ color: #fff; } +.checkbox { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + box-sizing: border-box; + margin: 0; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + background: transparent; + color: #000; + cursor: pointer; + transition: + border-color 0.18s ease, + background 0.18s ease, + box-shadow 0.18s ease, + transform 0.18s ease; + color-scheme: dark; +} + +.checkbox[data-checked] { + border-color: rgb(var(--accent-light-rgb)); + background: rgb(var(--accent-light-rgb)); +} + +.checkbox[data-focused] { + outline: none; + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); +} + +.checkboxIndicator { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + opacity: 0; + transition: opacity 0.18s ease; +} + +.checkbox[data-checked] .checkboxIndicator { + opacity: 1; +} + +.checkboxIcon { + color: #000; + font-size: 12px; + font-weight: 700; + line-height: 1; + transform: translateY(-0.5px) scaleX(1.18); +} + +.rangeRoot { + display: inline-flex; + flex: 0 0 160px; + width: 160px; + min-width: 160px; + color-scheme: dark; +} + +.rangeControl { + display: flex; + align-items: center; + width: 100%; + min-height: 28px; + cursor: pointer; +} + +.rangeTrack { + position: relative; + width: 100%; + height: 8px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04); +} + +.rangeIndicator { + height: 100%; + border-radius: inherit; + background: linear-gradient( + 90deg, + rgba(var(--accent-rgb), 0.95), + rgba(var(--accent-light-rgb), 0.95) + ); +} + +.rangeThumb { + width: 16px; + height: 16px; + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 50%; + background: + radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%), + rgb(var(--accent-light-rgb)); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28); + transition: + transform 0.18s ease, + box-shadow 0.18s ease; +} + +.rangeThumb:hover { + transform: scale(1.04); +} + +.rangeThumb:focus-visible { + box-shadow: + 0 0 0 4px rgba(var(--accent-light-rgb), 0.14), + 0 2px 8px rgba(0, 0, 0, 0.28); +} + .optionCardGroup { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 3488a2f6..2feb7a2e 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { Button, + Checkbox, FormActions, FormError, FormField, @@ -11,6 +12,7 @@ import { OptionButtonGroup, OptionCard, OptionCardGroup, + RangeInput, Select, TextArea, TextInput, @@ -22,6 +24,8 @@ function FormDemo() { const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover'); const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal'); const [status, setStatus] = useState('open'); + const [archive, setArchive] = useState(false); + const [confidence, setConfidence] = useState(90); return (
@@ -90,6 +94,20 @@ function FormDemo() { + + + + + + + + @@ -112,6 +130,16 @@ describe('form primitives', () => { fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } }); expect(screen.getByLabelText('Status')).toHaveValue('resolved'); + const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' }); + expect(archiveCheckbox).not.toBeChecked(); + fireEvent.click(archiveCheckbox); + expect(archiveCheckbox).toBeChecked(); + + const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' }); + expect(confidenceSlider).toHaveValue('90'); + fireEvent.change(confidenceSlider, { target: { value: '75' } }); + expect(confidenceSlider).toHaveValue('75'); + const wrongCover = screen.getByRole('button', { name: /wrong cover/i }); const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i }); expect(wrongCover).toHaveAttribute('aria-pressed', 'true'); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 15938435..1a26870e 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -1,10 +1,13 @@ import { Button as BaseButton } from '@base-ui/react/button'; +import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox'; import { Field } from '@base-ui/react/field'; import { Input as BaseInput } from '@base-ui/react/input'; +import { Slider } from '@base-ui/react/slider'; import { Toggle as BaseToggle } from '@base-ui/react/toggle'; import clsx from 'clsx'; import { forwardRef, + type CSSProperties, type ComponentPropsWithoutRef, type ButtonHTMLAttributes, type SelectHTMLAttributes, @@ -82,6 +85,84 @@ export const Select = forwardRef(function Select return - - +
@@ -431,20 +433,20 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {

Track Matching

- - +
@@ -504,7 +506,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) { {file ? ( - + ) : null}
@@ -564,7 +566,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
- +
) : ( diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx index 62b94c84..471c10f3 100644 --- a/webui/src/routes/import/-ui/auto-import-tab.tsx +++ b/webui/src/routes/import/-ui/auto-import-tab.tsx @@ -1,6 +1,8 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; +import { Button, RangeInput, Select } from '@/components/form/form'; + import type { ImportAutoFilter, ImportAutoImportResult, @@ -171,7 +173,7 @@ export function AutoImportPanel({ {getAutoImportStatusText(statusQuery.data)} {statusQuery.data?.running ? ( - + ) : null}
{statusQuery.data?.running ? (
- - -
+ +
) : null} {activeLines.length > 0 ? ( @@ -264,7 +267,7 @@ export function AutoImportPanel({
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - + ))}
{counts.review > 0 ? ( - + ) : null} {counts.imported + counts.failed > 0 ? ( - + ) : null}
@@ -445,7 +448,7 @@ function AutoImportResultCard({
{confidencePercent}% confidence
{result.status === 'pending_review' ? (
- - +
) : null}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css index 1ac434ef..cf99b973 100644 --- a/webui/src/routes/import/-ui/import-page.module.css +++ b/webui/src/routes/import/-ui/import-page.module.css @@ -637,53 +637,6 @@ margin-top: 2px; } -.importPageSingleCheckboxInput { - position: absolute; - inset: 0; - opacity: 0; - margin: 0; -} - -.importPageSingleCheckbox { - width: 18px; - height: 18px; - box-sizing: border-box; - border: 2px solid rgba(255, 255, 255, 0.2); - background: transparent; - padding: 0; - border-radius: 4px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.2s; - flex-shrink: 0; -} - -.importPageSingleCheckbox::after { - content: '✓'; - color: #000; - font-size: 12px; - font-weight: 700; - line-height: 1; - opacity: 0; - transform: translateY(-0.5px) scaleX(1.18); -} - -.importPageSingleCheckboxInput:focus-visible + .importPageSingleCheckbox { - outline: none; - box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox { - background: rgb(var(--accent-light-rgb)); - border-color: rgb(var(--accent-light-rgb)); -} - -.importPageSingleCheckboxInput:checked + .importPageSingleCheckbox::after { - opacity: 1; -} - .importPageSingleInfo { grid-column: 2; min-width: 0; @@ -1199,23 +1152,19 @@ color: rgba(255, 255, 255, 0.5); } -.autoImportSettingsRow label { +.autoImportSetting { display: flex; align-items: center; gap: 6px; } -.autoImportSettingsRow input[type='range'] { - width: 100px; -} -.autoImportSettingsRow select { - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.1); - color: #fff; - border-radius: 6px; - padding: 3px 8px; - font-size: 12px; -} +.autoImportConfidenceValue { + display: inline-block; + flex: 0 0 5ch; + width: 5ch; + text-align: right; + font-variant-numeric: tabular-nums; +} .autoImportEmpty { text-align: center; padding: 40px 20px; @@ -1468,12 +1417,6 @@ display: inline-flex; align-items: center; gap: 7px; - padding: 9px 18px; - border-radius: 10px; - border: 1px solid transparent; - cursor: pointer; - font-size: 13px; - font-weight: 500; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; letter-spacing: 0.1px; diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx index 05a22599..932d4922 100644 --- a/webui/src/routes/import/-ui/import-page.tsx +++ b/webui/src/routes/import/-ui/import-page.tsx @@ -1,6 +1,7 @@ import { Link, Outlet } from '@tanstack/react-router'; import { Show } from '@/components/primitives'; +import { Button } from '@/components/form/form'; import { useReactPageShell } from '@/platform/shell/route-controllers'; import type { ImportQueueEntry } from '../-import.types'; @@ -76,7 +77,7 @@ function ImportHeader({ Import Music - +
@@ -118,7 +119,7 @@ function ImportProcessingQueue() { >
Processing - +
{queue.map((entry) => ( diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx index 63885b36..a9c05a8b 100644 --- a/webui/src/routes/import/-ui/singles-import-tab.tsx +++ b/webui/src/routes/import/-ui/singles-import-tab.tsx @@ -1,5 +1,7 @@ import { useEffect } from 'react'; +import { Button, Checkbox, TextInput } from '@/components/form/form'; + import type { SingleSearchState } from '../-import.store'; import type { ImportTrackResult } from '../-import.types'; import type { ImportStagingFile } from '../-import.types'; @@ -172,12 +174,12 @@ export function SinglesImportPanel({ <>
- - +
@@ -206,14 +208,11 @@ export function SinglesImportPanel({ data-single-key={fileKey} >
{file.filename}
@@ -280,7 +279,7 @@ function SingleSearchPanel({ return (
- - +
{searchState?.loading ? ( From af3d51c2ed110ab874c052b34452d413f07f8e8a Mon Sep 17 00:00:00 2001 From: Antti Kettunen Date: Sat, 16 May 2026 20:42:05 +0300 Subject: [PATCH 17/42] refactor(webui): theme import buttons - add shared button variants and size controls backed by theme-aware styles - move album import search controls onto shared button styling - keep the import route layout-specific CSS limited to positioning --- webui/src/components/form/form.module.css | 76 ++++++++++++++++++- webui/src/components/form/form.test.tsx | 9 ++- webui/src/components/form/form.tsx | 26 ++++++- .../routes/import/-ui/album-import-tab.tsx | 11 +-- .../routes/import/-ui/import-page.module.css | 68 +---------------- .../routes/import/-ui/singles-import-tab.tsx | 2 +- 6 files changed, 115 insertions(+), 77 deletions(-) diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css index 54638847..2da07111 100644 --- a/webui/src/components/form/form.module.css +++ b/webui/src/components/form/form.module.css @@ -363,11 +363,8 @@ color: #fff; cursor: pointer; font: inherit; - font-size: 13px; font-weight: 600; line-height: 1.2; - min-height: 36px; - padding: 8px 12px; transition: transform 0.18s ease, border-color 0.18s ease, @@ -376,6 +373,34 @@ color 0.18s ease; } +.buttonSizeSm { + min-height: 32px; + padding: 6px 10px; + font-size: 13px; +} + +.buttonSizeMd { + min-height: 36px; + padding: 8px 12px; + font-size: 13px; +} + +.buttonSizeLg { + min-height: 40px; + padding: 10px 20px; + font-size: 14px; +} + +.buttonIcon { + width: var(--button-icon-size, 36px); + height: var(--button-icon-size, 36px); + min-height: var(--button-icon-size, 36px); + min-width: var(--button-icon-size, 36px); + padding: 0; + font-size: var(--button-icon-font-size, 15px); + line-height: 1; +} + .button:hover:not(:disabled) { transform: translateY(-1px); border-color: rgba(255, 255, 255, 0.18); @@ -394,6 +419,51 @@ transform: none; } +.buttonPrimary { + border-color: rgba(var(--accent-light-rgb), 0.45); + background: rgb(var(--accent-light-rgb)); + color: #000; +} + +.buttonPrimary:hover:not(:disabled) { + border-color: rgba(var(--accent-light-rgb), 0.55); + background: rgba(var(--accent-light-rgb), 0.95); + color: #000; +} + +.buttonPrimary:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.8); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16); +} + +.buttonPrimary:disabled { + opacity: 0.62; + background: rgba(var(--accent-light-rgb), 0.45); + color: rgba(0, 0, 0, 0.55); +} + +.buttonGhost { + border-color: transparent; + background: transparent; + color: rgba(255, 255, 255, 0.55); +} + +.buttonGhost:hover:not(:disabled) { + transform: none; + border-color: rgba(255, 255, 255, 0.14); + background: rgba(255, 255, 255, 0.07); + color: #fff; +} + +.buttonGhost:focus-visible { + border-color: rgba(var(--accent-light-rgb), 0.42); + box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12); +} + +.buttonGhost:disabled { + opacity: 0.5; +} + .formError { color: #ff8d8d; font-size: 12px; diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx index 2feb7a2e..21a2cfa3 100644 --- a/webui/src/components/form/form.test.tsx +++ b/webui/src/components/form/form.test.tsx @@ -112,7 +112,9 @@ function FormDemo() { - + ); @@ -154,6 +156,9 @@ describe('form primitives', () => { expect(highPriority).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute( + 'data-variant', + 'primary', + ); }); }); diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx index 1a26870e..6d6b04fa 100644 --- a/webui/src/components/form/form.tsx +++ b/webui/src/components/form/form.tsx @@ -247,13 +247,35 @@ type BaseButtonProps = ComponentPropsWithoutRef; export type ButtonProps = Omit & { className?: string; + size?: 'sm' | 'md' | 'lg' | 'icon'; + variant?: 'default' | 'primary' | 'ghost'; }; export const Button = forwardRef(function Button( - { className, type = 'button', ...props }, + { className, size = 'md', type = 'button', variant = 'default', ...props }, ref, ) { - return ; + return ( + + ); }); export function FormError({ className, message }: { className?: string; message?: ReactNode }) { diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx index 82d3fed3..aae1089d 100644 --- a/webui/src/routes/import/-ui/album-import-tab.tsx +++ b/webui/src/routes/import/-ui/album-import-tab.tsx @@ -298,18 +298,19 @@ function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewMode if (event.key === 'Enter') onRunSearch(); }} /> - +
@@ -568,7 +569,7 @@ function AlbumMatchPanel({ viewModel }: { viewModel: AlbumImportViewModel }) {
-
+ {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => ( - + ))}
{counts.review > 0 ? ( ) : null} -
+
) : null} @@ -449,7 +450,8 @@ function AutoImportResultCard({
- +
{openSearchKey === fileKey ? (
@@ -203,9 +205,7 @@ export function SinglesImportPanel({ return (