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, +});