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 @@
-
-
-
-
-
-
-
-
-
-
-
- Auto
- Albums
- Singles
-
-
-
-
-
-
-
-
-
- Auto-Import
-
-
Disabled
-
-
- Scan Now
-
-
-
- Confidence: 90%
- Interval:
- 30s
- 60s
- 2m
- 5m
-
- Save
-
-
-
-
-
-
- 0 imported
- 0 review
- 0 failed
-
-
-
-
All
-
Needs Review
-
Imported
-
Failed
-
-
Approve All
-
Clear History
-
-
-
-
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.
-
-
-
-
-
-
-
-
-
-
Suggested from your import folder
-
-
-
-
- Search
- ✕
-
-
-
-
-
-
-
-
-
-
-
-
-
Unmatched Files (0 )
-
-
-
-
-
-
-
-
-
-
-
-
-
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) => (
+
onRunGroupSearch(group)}
+ >
+ {group.file_count}
+
+
+ {group.album}
+
+
+ {group.artist} - {group.file_count} tracks
+
+
+
+ ))}
+
+
+ )}
+
+
+
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();
+ }}
+ />
+
+ Search
+
+
+ x
+
+
+
+
+ {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 (
+ onSelect(album)}>
+
+
+ {album.name}
+
+
+ {album.artist}
+
+
+ {album.total_tracks || 0} tracks - {album.release_date?.substring(0, 4) || ''}
+
+
+ );
+}
+
+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.artist}
+
+ {albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '}
+ {albumMatch.album.release_date?.substring(0, 4) || ''}
+
+
+
+
+
+
Track Matching
+
+
+ Re-match Automatically
+
+
+ Back to Search
+
+
+
+
+
+ {(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 ? (
+ {
+ event.stopPropagation();
+ onUnmatchTrack(index);
+ }}
+ >
+ x
+
+ ) : 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
+
+
+ Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
+
+
+ >
+ ) : (
+ 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 (
+ <>
+
+
+
+ toggleMutation.mutate(event.target.checked)}
+ />
+
+ Auto-Import
+
+
+ {getAutoImportStatusText(statusQuery.data)}
+
+ {statusQuery.data?.running ? (
+ scanMutation.mutate()}
+ >
+
+ Scan Now
+
+ ) : null}
+
+ {statusQuery.data?.running ? (
+
+
+ Confidence:{' '}
+ setConfidence(Number(event.target.value))}
+ />{' '}
+ {confidence}%
+
+
+ Interval:{' '}
+ setInterval(Number(event.target.value))}
+ >
+ 30s
+ 60s
+ 2m
+ 5m
+
+
+
+ saveSettingsMutation.mutate({
+ confidenceThreshold: confidence / 100,
+ scanInterval: interval,
+ })
+ }
+ >
+ Save
+
+
+ ) : 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) => (
+
onFilterChange(filter)}
+ >
+ {filter === 'pending' ? 'Needs Review' : titleCase(filter)}
+
+ ))}
+
+ {counts.review > 0 ? (
+
approveAllMutation.mutate()}
+ >
+ Approve All
+
+ ) : null}
+ {counts.imported + counts.failed > 0 ? (
+
clearMutation.mutate()}
+ >
+ Clear History
+
+ ) : 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' ? (
+
+ {
+ event.stopPropagation();
+ onApprove();
+ }}
+ >
+ Approve & Import
+
+ {
+ event.stopPropagation();
+ onReject();
+ }}
+ >
+ Dismiss
+
+
+ ) : 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 (
+
+ );
+}
+
+function ImportProcessingQueue() {
+ const { clearFinishedJobs, queue } = useImportQueueWorkflow();
+ const hasFinished = queue.some((entry) => entry.status !== 'running');
+
+ return (
+
+
+ Processing
+
+ Clear finished
+
+
+
+ {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}
+
+
+
+ );
+}
+
+function ImportTabNav() {
+ return (
+
+
+ Auto
+
+
+ Albums
+
+
+ Singles
+
+
+ );
+}
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 (
+ <>
+
+
+
+
+ {allSelected ? 'Deselect All' : 'Select All'}
+
+
+
+ Process Selected ({selected.size})
+
+
+
+
+ {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 (
+
+
onToggleSingle(index)}
+ />
+
+
{file.filename}
+
+ {file.title ? {file.title} : null}
+ {file.artist ? {file.artist} : null}
+ {file.extension ? {file.extension} : null}
+
+ {manualMatch ? (
+
+ OK {manualMatch.name} - {manualMatch.artist}
+ onOpenSearch(index)}
+ >
+ change
+
+
+ ) : null}
+
+
+ onOpenSearch(index)}
+ >
+ Search Identify
+
+
+ {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);
+ }}
+ />
+ onRunSearch(fileIndex, query)}
+ >
+ Search
+
+
+
+ {searchState?.loading ? (
+
Searching...
+ ) : searchState?.error ? (
+
Error: {searchState.error}
+ ) : searchState?.results.length === 0 ? (
+
No results found
+ ) : (
+ searchState?.results.map((track, index) => (
+
onSelectMatch(fileIndex, track)}
+ >
+ {track.image_url ? (
+
+ ) : null}
+
+
+ {track.name} - {track.artist}
+
+
+ {track.album}
+ {track.duration_ms ? ` - ${formatDuration(track.duration_ms)}` : ''}
+
+
+ Select
+
+ ))
+ )}
+
+
+ );
+}
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,
+});