feat(webui): migrate import route to React

- Move import page, tabs, workflow state, and route tests into React-owned route slices
- Preserve shell gating, staging queries, album matching, singles matching, auto-import, and queue behavior
- Add migration plan snapshot so cleanup/refinement can build on a stable baseline
This commit is contained in:
Antti Kettunen 2026-05-16 11:13:12 +03:00
parent 5680e52ceb
commit 27fbc80e7a
No known key found for this signature in database
GPG key ID: C6B2A3D250359BD7
26 changed files with 5326 additions and 171 deletions

View file

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

View file

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

View file

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

View file

@ -6199,159 +6199,6 @@
</div>
</div>
<!-- Import Page -->
<div class="page" id="import-page">
<div class="import-page-container">
<!-- Header with staging info -->
<div class="import-page-header">
<div class="import-page-title-row">
<h1 class="import-page-title"><img src="/static/import.png" class="page-header-icon" alt=""><span>Import Music</span></h1>
<button class="import-page-refresh-btn" onclick="importPageRefreshStaging()" title="Re-scan import folder">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
Refresh
</button>
</div>
<div class="import-page-staging-bar" id="import-staging-bar">
<span class="import-staging-path" id="import-page-staging-path">Import folder: loading...</span>
<span class="import-staging-stats" id="import-page-staging-stats"></span>
</div>
</div>
<!-- Processing Queue -->
<div class="import-page-queue hidden" id="import-page-queue">
<div class="import-page-queue-header">
<span class="import-page-queue-title">Processing</span>
<button class="import-page-queue-clear" id="import-page-queue-clear" onclick="importPageClearFinishedJobs()">Clear finished</button>
</div>
<div class="import-page-queue-list" id="import-page-queue-list"></div>
</div>
<!-- Tab Bar -->
<div class="import-page-tab-bar">
<button class="import-page-tab" id="import-page-tab-auto" onclick="importPageSwitchTab('auto')">Auto</button>
<button class="import-page-tab active" id="import-page-tab-album" onclick="importPageSwitchTab('album')">Albums</button>
<button class="import-page-tab" id="import-page-tab-singles" onclick="importPageSwitchTab('singles')">Singles</button>
</div>
<!-- Auto Import Tab -->
<div class="import-page-tab-content" id="import-page-auto-content">
<div class="auto-import-controls">
<div class="auto-import-toggle-row">
<label class="auto-import-toggle-label">
<input type="checkbox" id="auto-import-enabled" onchange="_autoImportToggle(this.checked)">
<span class="repair-toggle-slider"></span>
<span>Auto-Import</span>
</label>
<span class="auto-import-status" id="auto-import-status-text">Disabled</span>
<button class="auto-import-scan-now-btn" id="auto-import-scan-now" onclick="_autoImportScanNow()" title="Scan import folder now" style="display:none">
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z"/></svg>
Scan Now
</button>
</div>
<div class="auto-import-settings-row" id="auto-import-settings-row" style="display:none;">
<label>Confidence: <input type="range" id="auto-import-confidence" min="50" max="100" value="90" oninput="document.getElementById('auto-import-conf-val').textContent=this.value+'%'"> <span id="auto-import-conf-val">90%</span></label>
<label>Interval: <select id="auto-import-interval" onchange="_autoImportSaveSettings()">
<option value="30">30s</option>
<option value="60" selected>60s</option>
<option value="120">2m</option>
<option value="300">5m</option>
</select></label>
<button class="watchlist-action-btn watchlist-action-secondary" onclick="_autoImportSaveSettings()">Save</button>
</div>
<!-- Live scan progress -->
<div class="auto-import-progress" id="auto-import-progress" style="display:none">
<div class="auto-import-progress-text" id="auto-import-progress-text">Scanning...</div>
<div class="auto-import-progress-bar"><div class="auto-import-progress-fill" id="auto-import-progress-fill"></div></div>
</div>
</div>
<!-- Stats summary -->
<div class="auto-import-stats" id="auto-import-stats" style="display:none">
<span class="auto-import-stat" id="auto-import-stat-imported">0 imported</span>
<span class="auto-import-stat auto-import-stat-review" id="auto-import-stat-review">0 review</span>
<span class="auto-import-stat auto-import-stat-failed" id="auto-import-stat-failed">0 failed</span>
</div>
<!-- Filter pills -->
<div class="auto-import-filters" id="auto-import-filters" style="display:none">
<button class="adl-pill active" data-filter="all" onclick="_autoImportSetFilter('all')">All</button>
<button class="adl-pill" data-filter="pending" onclick="_autoImportSetFilter('pending')">Needs Review</button>
<button class="adl-pill" data-filter="imported" onclick="_autoImportSetFilter('imported')">Imported</button>
<button class="adl-pill" data-filter="failed" onclick="_autoImportSetFilter('failed')">Failed</button>
<div style="flex:1"></div>
<button class="auto-import-batch-btn" id="auto-import-approve-all" onclick="_autoImportApproveAll()" style="display:none">Approve All</button>
<button class="auto-import-batch-btn auto-import-clear-btn" id="auto-import-clear-completed" onclick="_autoImportClearCompleted()" style="display:none">Clear History</button>
</div>
<div class="auto-import-results" id="auto-import-results">
<div class="auto-import-empty">
<p>Enable auto-import to watch your import folder for new music.</p>
<p style="opacity:0.5;font-size:12px;">Drop album folders or single tracks into your import folder and SoulSync will identify, match, and import them automatically.</p>
</div>
</div>
</div>
<!-- Album Tab -->
<div class="import-page-tab-content active" id="import-page-album-content">
<!-- Search state -->
<div id="import-page-album-search-section">
<div class="import-page-suggestions" id="import-page-suggestions">
<div class="import-page-section-label">Suggested from your import folder</div>
<div class="import-page-album-grid" id="import-page-suggestions-grid"></div>
</div>
<div class="import-page-search-bar">
<input type="text" id="import-page-album-search-input" class="import-page-search-input"
placeholder="Search for an album..." onkeydown="if(event.key==='Enter')importPageSearchAlbum()">
<button class="import-page-search-btn" onclick="importPageSearchAlbum()">Search</button>
<button class="import-page-clear-btn hidden" id="import-page-album-clear-btn"
onclick="importPageResetAlbumSearch()" title="Clear search">✕</button>
</div>
<div class="import-page-album-grid" id="import-page-album-results"></div>
</div>
<!-- Match state (hidden initially) -->
<div id="import-page-album-match-section" class="hidden">
<div class="import-page-album-hero" id="import-page-album-hero"></div>
<div class="import-page-match-header">
<h3>Track Matching</h3>
<div class="import-page-match-actions">
<button class="import-page-secondary-btn" onclick="importPageAutoRematch()">Re-match Automatically</button>
<button class="import-page-back-btn" onclick="importPageResetAlbumSearch()">Back to Search</button>
</div>
</div>
<div class="import-page-match-list" id="import-page-match-list"></div>
<!-- Unmatched file pool for drag-drop -->
<div class="import-page-unmatched-pool" id="import-page-unmatched-pool">
<div class="import-page-pool-label">Unmatched Files (<span id="import-page-unmatched-count">0</span>)</div>
<div class="import-page-pool-chips" id="import-page-pool-chips"></div>
</div>
<div class="import-page-match-footer">
<div class="import-page-match-stats" id="import-page-match-stats"></div>
<button class="import-page-process-btn" id="import-page-album-process-btn"
onclick="importPageProcessAlbum()">Process Album</button>
</div>
</div>
</div>
<!-- Singles Tab -->
<div class="import-page-tab-content" id="import-page-singles-content">
<div class="import-page-singles-header">
<div class="import-page-singles-actions">
<button class="import-page-secondary-btn" onclick="importPageSelectAllSingles()">
<span id="import-page-select-all-text">Select All</span>
</button>
<button class="import-page-process-btn" id="import-page-singles-process-btn"
onclick="importPageProcessSingles()" disabled>Process Selected (0)</button>
</div>
</div>
<div class="import-page-singles-list" id="import-page-singles-list">
<div class="import-page-empty-state">Navigate to this page to scan your import folder for audio files.</div>
</div>
</div>
</div>
</div>
<!-- Help & Docs Page -->
<div class="page" id="help-page">
<div class="docs-layout">

View file

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

View file

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

View file

@ -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<boolean>;
SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise<void>;

View file

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

View file

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

View file

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

View file

@ -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<T extends { success: boolean; error?: string }>(
payload: T,
fallback: string,
): T {
if (!payload.success) {
throw new Error(payload.error || fallback);
}
return payload;
}
export async function fetchImportStagingFiles(): Promise<ImportStagingFilesPayload> {
return assertSuccess(
await readJson<ImportStagingFilesPayload>(apiClient.get('import/staging/files')),
'Failed to load import folder',
);
}
export async function fetchImportStagingGroups(): Promise<ImportStagingGroupsPayload> {
return assertSuccess(
await readJson<ImportStagingGroupsPayload>(apiClient.get('import/staging/groups')),
'Failed to load auto-detected albums',
);
}
export async function fetchImportStagingSuggestions(): Promise<ImportAlbumSearchPayload> {
return assertSuccess(
await readJson<ImportAlbumSearchPayload>(apiClient.get('import/staging/suggestions')),
'Failed to load import suggestions',
);
}
export async function searchImportAlbums(query: string): Promise<ImportAlbumSearchPayload> {
return assertSuccess(
await readJson<ImportAlbumSearchPayload>(
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<ImportAlbumMatchPayload> {
return assertSuccess(
await readJson<ImportAlbumMatchPayload>(
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<ImportProcessPayload> {
return assertSuccess(
await readJson<ImportProcessPayload>(
apiClient.post('import/album/process', {
json: {
album: input.album,
matches: [input.match],
},
}),
),
'Failed to process album track',
);
}
export async function searchImportTracks(query: string): Promise<ImportTrackSearchPayload> {
return assertSuccess(
await readJson<ImportTrackSearchPayload>(
apiClient.get('import/search/tracks', {
searchParams: {
q: query,
limit: '6',
},
}),
),
'Track search failed',
);
}
export async function processImportSingleFile(file: unknown): Promise<ImportProcessPayload> {
return assertSuccess(
await readJson<ImportProcessPayload>(
apiClient.post('import/singles/process', {
json: {
files: [file],
},
}),
),
'Failed to process single',
);
}
export async function fetchAutoImportStatus(): Promise<ImportAutoImportStatusPayload> {
return assertSuccess(
await readJson<ImportAutoImportStatusPayload>(apiClient.get('auto-import/status')),
'Failed to load auto-import status',
);
}
export async function fetchAutoImportSettings(): Promise<ImportAutoImportSettingsPayload> {
return assertSuccess(
await readJson<ImportAutoImportSettingsPayload>(apiClient.get('auto-import/settings')),
'Failed to load auto-import settings',
);
}
export async function saveAutoImportSettings(input: {
confidenceThreshold: number;
scanInterval: number;
}): Promise<void> {
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<ImportAutoImportResultsPayload> {
return assertSuccess(
await readJson<ImportAutoImportResultsPayload>(
apiClient.get('auto-import/results', {
searchParams: {
limit: '100',
},
}),
),
'Failed to load auto-import results',
);
}
export async function toggleAutoImport(enabled: boolean): Promise<void> {
assertSuccess(
await readJson<{ success: boolean; error?: string }>(
apiClient.post('auto-import/toggle', {
json: { enabled },
}),
),
'Failed to toggle auto-import',
);
}
export async function triggerAutoImportScan(): Promise<void> {
assertSuccess(
await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now')),
'Failed to trigger scan',
);
}
export async function approveAutoImportResult(id: number): Promise<void> {
assertSuccess(
await readJson<{ success: boolean; error?: string }>(
apiClient.post(`auto-import/approve/${id}`),
),
'Failed to approve import',
);
}
export async function rejectAutoImportResult(id: number): Promise<void> {
assertSuccess(
await readJson<{ success: boolean; error?: string }>(
apiClient.post(`auto-import/reject/${id}`),
),
'Failed to dismiss import',
);
}
export async function approveAllAutoImportResults(): Promise<number> {
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<number> {
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'] }),
]);
}

View file

@ -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<number, number>,
): 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<number, number>,
): { 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<number, number>,
): 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<string, string> = {
completed: 'Imported',
pending_review: 'Needs Review',
needs_identification: 'Unidentified',
failed: 'Failed',
scanning: 'Scanning...',
matched: 'Matched',
rejected: 'Dismissed',
approved: 'Approved',
processing: 'Processing',
};
const icons: Record<string, string> = {
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}`;
}

View file

@ -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> = T | ((current: T) => T);
function resolveState<T>(current: T, updater: StateUpdater<T>) {
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<number, number>,
selectedSingles: new Set<number>(),
singlesManualMatches: {} as Record<number, ImportTrackResult>,
openSingleSearch: null as number | null,
singleSearches: {} as Record<number, SingleSearchState>,
};
}
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<ImportQueueEntry>) => {
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<Record<number, number>>) => {
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<number>()
: new Set(Array.from({ length: fileCount }, (_, index) => index)),
}));
},
clearSinglesSelection: () => {
set({
selectedSingles: new Set<number>(),
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<SingleSearchState>) => {
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,
})),
);
}

View file

@ -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<typeof importAutoSearchSchema>;
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;

View file

@ -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> = {}): 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(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
function getFetchUrls() {
return vi
.mocked(fetch)
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
}
describe('import route', () => {
let albumMatchBodies: Record<string, unknown>[];
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<string, unknown>)
: (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,
);
});
});

View file

@ -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<number | null>(null);
const [tapSelectedChip, setTapSelectedChip] = useState<number | null>(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<typeof useAlbumImportViewModel>;
export function AlbumImportTab() {
const viewModel = useAlbumImportViewModel();
return <AlbumImportPanelContent viewModel={viewModel} />;
}
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 (
<>
<div
id="import-page-album-search-section"
className={showingMatch ? styles.hidden : ''}
>
{albumResults === null && (
<>
{groups.length > 0 && (
<div id="import-page-auto-groups" className={styles.importPageAutoGroups}>
<div className={styles.importPageSectionLabel}>Auto-Detected Albums</div>
<div className={styles.importPageAlbumGrid}>
{groups.map((group, index) => (
<button
key={`${group.artist}-${group.album}-${index}`}
type="button"
className={`${styles.importPageAlbumCard} ${styles.importPageAutoGroupCard}`}
onClick={() => onRunGroupSearch(group)}
>
<div className={styles.importPageAutoGroupCount}>{group.file_count}</div>
<div className={styles.importPageAutoGroupInfo}>
<div className={styles.importPageAlbumCardTitle} title={group.album}>
{group.album}
</div>
<div className={styles.importPageAlbumCardArtist} title={group.artist}>
{group.artist} - {group.file_count} tracks
</div>
</div>
</button>
))}
</div>
</div>
)}
<div className={styles.importPageSuggestions} id="import-page-suggestions">
<div className={styles.importPageSectionLabel}>Suggested from your import folder</div>
<div className={styles.importPageAlbumGrid} id="import-page-suggestions-grid">
{suggestions.length > 0 ? (
suggestions.map((album) => (
<AlbumCard
key={`${album.source || 'source'}-${album.id}`}
album={album}
onSelect={onSelectAlbum}
/>
))
) : suggestionsReady ? null : (
<div className={styles.importPageEmptyState}>Loading suggestions...</div>
)}
</div>
</div>
</>
)}
<div className={styles.importPageSearchBar}>
<input
type="text"
id="import-page-album-search-input"
className={styles.importPageSearchInput}
placeholder="Search for an album..."
value={albumQuery}
onChange={(event) => onAlbumQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onRunSearch();
}}
/>
<button type="button" className={styles.importPageSearchBtn} onClick={onRunSearch}>
Search
</button>
<button
type="button"
className={`${styles.importPageClearBtn} ${albumResults === null ? styles.hidden : ''}`}
id="import-page-album-clear-btn"
title="Clear search"
onClick={onBackToSearch}
>
x
</button>
</div>
<div className={styles.importPageAlbumGrid} id="import-page-album-results">
{albumSearchLoading ? (
<div className={styles.importPageEmptyState}>Searching...</div>
) : albumSearchError ? (
<div className={styles.importPageEmptyState}>Error: {albumSearchError}</div>
) : albumResults?.length === 0 ? (
<div className={styles.importPageEmptyState}>No albums found</div>
) : (
albumResults?.map((album) => (
<AlbumCard
key={`${album.source || 'source'}-${album.id}`}
album={album}
onSelect={onSelectAlbum}
/>
))
)}
</div>
</div>
<div
id="import-page-album-match-section"
className={showingMatch ? '' : styles.hidden}
>
{albumMatchLoading ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
) : albumMatch?.album ? (
<AlbumMatchPanel viewModel={viewModel} />
) : (
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
)}
</div>
</>
);
}
function AlbumCard({
album,
onSelect,
}: {
album: ImportAlbumResult;
onSelect: (album: ImportAlbumResult) => void;
}) {
return (
<button type="button" className={styles.importPageAlbumCard} onClick={() => onSelect(album)}>
<img
src={album.image_url || IMPORT_PLACEHOLDER_IMAGE}
alt={album.name}
loading="lazy"
onError={fallbackImage}
/>
<div className={styles.importPageAlbumCardTitle} title={album.name}>
{album.name}
</div>
<div className={styles.importPageAlbumCardArtist} title={album.artist}>
{album.artist}
</div>
<div className={styles.importPageAlbumCardMeta}>
{album.total_tracks || 0} tracks - {album.release_date?.substring(0, 4) || ''}
</div>
</button>
);
}
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 ? (
<div className={styles.importPageEmptyState}>Matching files to tracklist...</div>
) : albumMatchError ? (
<div className={styles.importPageEmptyState}>Error: {albumMatchError}</div>
) : albumMatch?.album ? (
<>
<div className={styles.importPageAlbumHero} id="import-page-album-hero">
<img
src={albumMatch.album.image_url || IMPORT_PLACEHOLDER_IMAGE}
alt={albumMatch.album.name}
loading="lazy"
onError={fallbackImage}
/>
<div className={styles.importPageAlbumHeroInfo}>
<div className={styles.importPageAlbumHeroTitle}>{albumMatch.album.name}</div>
<div className={styles.importPageAlbumHeroArtist}>{albumMatch.album.artist}</div>
<div className={styles.importPageAlbumHeroMeta}>
{albumMatch.album.total_tracks || albumMatch.matches?.length || 0} tracks -{' '}
{albumMatch.album.release_date?.substring(0, 4) || ''}
</div>
</div>
</div>
<div className={styles.importPageMatchHeader}>
<h3>Track Matching</h3>
<div className={styles.importPageMatchActions}>
<button
type="button"
className={styles.importPageSecondaryBtn}
onClick={onAutoRematch}
>
Re-match Automatically
</button>
<button
type="button"
className={styles.importPageBackBtn}
onClick={onBackToSearch}
>
Back to Search
</button>
</div>
</div>
<div className={styles.importPageMatchList} id="import-page-match-list">
{(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 (
<div
key={`${trackInfo.displayTrackNumber}-${trackInfo.name}-${index}`}
className={`${styles.importPageMatchRow} ${
file ? styles.matched : ''
} ${dragOverTrack === index ? styles.dragOver : ''}`}
onClick={() => {
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);
}}
>
<span className={styles.importPageMatchNum}>{trackInfo.displayTrackNumber}</span>
<span className={styles.importPageMatchTrack}>{trackInfo.name}</span>
<span
className={`${styles.importPageMatchFile} ${
file ? styles.hasFile : ''
}`}
>
{file ? (
<>
<span className={styles.importPageMatchFileName}>{file.filename}</span>
<span
className={`${styles.importPageMatchConfidence} ${
confidence >= 0.7 ? '' : styles.low
}`}
>
{confidencePercent}%
</span>
</>
) : (
<span className={styles.importPageMatchDropZone}>Drop a file here</span>
)}
</span>
<span>
{file ? (
<button
type="button"
className={styles.importPageMatchUnmatch}
onClick={(event) => {
event.stopPropagation();
onUnmatchTrack(index);
}}
>
x
</button>
) : null}
</span>
</div>
);
})}
</div>
<div className={styles.importPageUnmatchedPool} id="import-page-unmatched-pool">
<div className={styles.importPagePoolLabel}>
Unmatched Files (<span id="import-page-unmatched-count">{unmatchedFiles.length}</span>)
</div>
<div className={styles.importPagePoolChips} id="import-page-pool-chips">
{unmatchedFiles.length === 0 ? (
<span className={styles.importPagePoolEmpty}>All files matched</span>
) : (
unmatchedFiles.map(({ file, index }) => (
<span
key={`${file.full_path}-${index}`}
role="button"
tabIndex={0}
className={`${styles.importPageFileChip} ${
tapSelectedChip === index ? styles.selected : ''
}`}
draggable
onClick={(event) => {
event.stopPropagation();
onTapSelectChip(index);
}}
onDragStart={(event: DragEvent<HTMLSpanElement>) => {
event.dataTransfer.setData('text/plain', String(index));
event.dataTransfer.effectAllowed = 'move';
}}
onKeyDown={(event: KeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onTapSelectChip(index);
}
}}
>
{file.filename}
</span>
))
)}
</div>
</div>
<div className={styles.importPageMatchFooter}>
<div className={styles.importPageMatchStats} id="import-page-match-stats">
{matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
</div>
<button
type="button"
className={styles.importPageProcessBtn}
id="import-page-album-process-btn"
disabled={matchedCount === 0}
onClick={onProcessAlbum}
>
Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
</button>
</div>
</>
) : (
<div className={styles.importPageEmptyState}>Select an album to start matching files.</div>
);
}

View file

@ -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<Set<number>>(() => 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 (
<div className={styles.autoImportEmpty}>
Auto-import is unavailable: {getErrorMessage(statusQuery.error)}
</div>
);
}
return (
<>
<div className={styles.autoImportControls}>
<div className={styles.autoImportToggleRow}>
<label className={styles.autoImportToggleLabel}>
<input
type="checkbox"
id="auto-import-enabled"
checked={Boolean(statusQuery.data?.running)}
disabled={toggleMutation.isPending}
onChange={(event) => toggleMutation.mutate(event.target.checked)}
/>
<span className={styles.autoImportToggleSlider} />
<span>Auto-Import</span>
</label>
<span
className={`${styles.autoImportStatus} ${styles[statusClassName]}`}
id="auto-import-status-text"
>
{getAutoImportStatusText(statusQuery.data)}
</span>
{statusQuery.data?.running ? (
<button
type="button"
className={styles.autoImportScanNowBtn}
id="auto-import-scan-now"
title="Scan import folder now"
disabled={scanMutation.isPending}
onClick={() => scanMutation.mutate()}
>
<RefreshIcon />
Scan Now
</button>
) : null}
</div>
{statusQuery.data?.running ? (
<div className={styles.autoImportSettingsRow} id="auto-import-settings-row">
<label>
Confidence:{' '}
<input
type="range"
id="auto-import-confidence"
min="50"
max="100"
value={confidence}
onChange={(event) => setConfidence(Number(event.target.value))}
/>{' '}
<span id="auto-import-conf-val">{confidence}%</span>
</label>
<label>
Interval:{' '}
<select
id="auto-import-interval"
value={interval}
onChange={(event) => setInterval(Number(event.target.value))}
>
<option value="30">30s</option>
<option value="60">60s</option>
<option value="120">2m</option>
<option value="300">5m</option>
</select>
</label>
<button
type="button"
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
disabled={saveSettingsMutation.isPending}
onClick={() =>
saveSettingsMutation.mutate({
confidenceThreshold: confidence / 100,
scanInterval: interval,
})
}
>
Save
</button>
</div>
) : null}
{activeLines.length > 0 ? (
<div className={styles.autoImportProgress} id="auto-import-progress">
<div className={styles.autoImportProgressText} id="auto-import-progress-text">
{activeLines.length === 1
? `Processing ${activeLines[0]}`
: `Processing ${activeLines.length} imports:`}
{activeLines.length > 1
? activeLines.map((line) => <div key={line}>{line}</div>)
: null}
</div>
<div className={styles.autoImportProgressBar}>
<div className={styles.autoImportProgressFill} />
</div>
</div>
) : null}
</div>
{allResults.length > 0 ? (
<>
<div className={styles.autoImportStats} id="auto-import-stats">
<span className={styles.autoImportStat} id="auto-import-stat-imported">
{counts.imported} imported
</span>
<span
className={`${styles.autoImportStat} ${styles.autoImportStatReview}`}
id="auto-import-stat-review"
>
{counts.review} review
</span>
<span
className={`${styles.autoImportStat} ${styles.autoImportStatFailed}`}
id="auto-import-stat-failed"
>
{counts.failed} failed
</span>
</div>
<div className={styles.autoImportFilters} id="auto-import-filters">
{(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
<button
key={filter}
type="button"
className={`${styles.autoImportFilterPill} ${
autoFilter === filter ? styles.active : ''
}`}
data-filter={filter}
onClick={() => onFilterChange(filter)}
>
{filter === 'pending' ? 'Needs Review' : titleCase(filter)}
</button>
))}
<div className={styles.importPageFlexSpacer} />
{counts.review > 0 ? (
<button
type="button"
className={styles.autoImportBatchBtn}
id="auto-import-approve-all"
disabled={approveAllMutation.isPending}
onClick={() => approveAllMutation.mutate()}
>
Approve All
</button>
) : null}
{counts.imported + counts.failed > 0 ? (
<button
type="button"
className={`${styles.autoImportBatchBtn} ${styles.autoImportClearBtn}`}
id="auto-import-clear-completed"
disabled={clearMutation.isPending}
onClick={() => clearMutation.mutate()}
>
Clear History
</button>
) : null}
</div>
</>
) : null}
<div className={styles.autoImportResults} id="auto-import-results">
{resultsQuery.error ? (
<div className={styles.autoImportEmpty}>
Failed to load imports: {getErrorMessage(resultsQuery.error)}
</div>
) : allResults.length === 0 ? (
<div className={styles.autoImportEmpty}>
<p>No imports yet. Drop album folders or single tracks into your import folder.</p>
</div>
) : results.length === 0 ? (
<div className={styles.autoImportEmpty}>
<p>No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.</p>
</div>
) : (
results.map((result, index) => (
<AutoImportResultCard
key={result.id}
expanded={expandedRows.has(result.id)}
index={index}
approvePending={approveMutation.isPending}
rejectPending={rejectMutation.isPending}
result={result}
status={statusQuery.data}
onApprove={() => 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;
});
}}
/>
))
)}
</div>
</>
);
}
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 (
<div
className={`${styles.autoImportCard} ${statusCardClass}`}
role="button"
tabIndex={0}
onClick={onToggle}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle();
}
}}
>
<div className={styles.autoImportCardTop}>
<div className={styles.autoImportCardLeft}>
{result.image_url ? (
<img
className={styles.autoImportCardArt}
src={result.image_url}
alt=""
onError={fallbackImage}
/>
) : (
<div className={styles.autoImportCardArtFallback}>Album</div>
)}
</div>
<div className={styles.autoImportCardCenter}>
<div className={styles.autoImportCardAlbum}>{result.album_name || result.folder_name}</div>
<div className={styles.autoImportCardArtist}>{result.artist_name || 'Unknown Artist'}</div>
<div className={styles.autoImportCardMeta}>
<span>{matchSummary}</span>
{methodLabel ? <span className={styles.autoImportMethodBadge}>{methodLabel}</span> : null}
{timeAgo ? <span>{timeAgo}</span> : null}
</div>
{result.error_message ? (
<div className={styles.autoImportCardError}>{result.error_message}</div>
) : null}
</div>
<div className={styles.autoImportCardRight}>
<div className={`${styles.autoImportStatusBadge} ${statusBadgeClass}`}>
{statusMeta.icon} {statusMeta.label}
</div>
<div className={styles.autoImportConfidenceBar}>
<div
className={`${styles.autoImportConfidenceFill} ${confidenceFillClass}`}
style={{ width: `${confidencePercent}%` }}
/>
</div>
<div className={styles.autoImportConfidenceText}>{confidencePercent}% confidence</div>
{result.status === 'pending_review' ? (
<div className={styles.autoImportActions}>
<button
type="button"
className={`${styles.autoImportActionBtn} ${styles.autoImportActionPrimary}`}
disabled={approvePending}
onClick={(event) => {
event.stopPropagation();
onApprove();
}}
>
Approve & Import
</button>
<button
type="button"
className={`${styles.autoImportActionBtn} ${styles.autoImportActionSecondary}`}
disabled={rejectPending}
onClick={(event) => {
event.stopPropagation();
onReject();
}}
>
Dismiss
</button>
</div>
) : null}
</div>
</div>
<div className={styles.autoImportCardFolderPath}>{result.folder_name}</div>
{trackDetails.length > 0 ? (
<div
className={`${styles.autoImportTrackList} ${expanded ? styles.expanded : ''}`}
id={`auto-import-tracks-${index}`}
>
<div className={styles.autoImportTrackListHeader}>
<span>Track</span>
<span>Matched File</span>
<span>Conf</span>
</div>
{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 (
<div
key={`${track.name}-${track.file}-${trackIndex}`}
className={rowClassName}
>
<span className={styles.autoImportTrackName}>{track.name}</span>
<span className={styles.autoImportTrackFile}>{track.file}</span>
<span className={`${styles.autoImportTrackConf} ${getAutoImportConfidenceClass(getConfidenceClass(track.confidence))}`}>
{track.confidence}%
</span>
</div>
);
})}
</div>
) : null}
</div>
);
}
function showMutationError(error: unknown) {
window.showToast?.(getErrorMessage(error), 'error');
}
function getAutoImportCardClass(status: string): string {
const classes: Record<string, string> = {
completed: styles.autoImportCompleted,
review: styles.autoImportReview,
failed: styles.autoImportFailed,
processing: styles.autoImportProcessing,
};
return classes[status] ?? '';
}
function getAutoImportBadgeClass(status: string): string {
const classes: Record<string, string> = {
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<string, string> = {
high: styles.autoImportConfHigh,
medium: styles.autoImportConfMedium,
low: styles.autoImportConfLow,
};
return classes[status] ?? '';
}
function getMethodLabel(method: string | null | undefined): string {
const labels: Record<string, string> = {
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<boolean> {
if (window.showConfirmDialog) {
return await window.showConfirmDialog({ title, message, confirmText });
}
return window.confirm(message);
}

File diff suppressed because it is too large Load diff

View file

@ -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 (
<div id="import-page" data-testid="import-page">
<div className={styles.importPageContainer}>
<ImportHeader
error={stagingQuery.error}
fileCountText={getStagingStatsText(stagingFiles)}
loading={stagingQuery.isLoading}
stagingPath={stagingPath}
onRefresh={refreshStaging}
/>
<ImportProcessingQueue />
<ImportTabNav />
<section className={`${styles.importPageTabContent} ${styles.active}`}>
<Outlet />
</section>
</div>
</div>
);
}
function ImportHeader({
error,
fileCountText,
loading,
stagingPath,
onRefresh,
}: {
error: unknown;
fileCountText: string;
loading: boolean;
stagingPath: string;
onRefresh: () => void;
}) {
return (
<header className={styles.importPageHeader}>
<div className={styles.importPageTitleRow}>
<h1 className={styles.importPageTitle}>
<img src="/static/import.png" className="page-header-icon" alt="" />
<span>Import Music</span>
</h1>
<button
type="button"
className={styles.importPageRefreshBtn}
title="Re-scan import folder"
onClick={onRefresh}
>
<RefreshIcon />
Refresh
</button>
</div>
<div className={styles.importPageStagingBar} id="import-staging-bar">
<span className={styles.importStagingPath} id="import-page-staging-path">
{error ? 'Import folder: error' : `Import: ${stagingPath}`}
</span>
<span className={styles.importStagingStats} id="import-page-staging-stats">
{loading ? 'loading...' : fileCountText}
</span>
</div>
</header>
);
}
function ImportProcessingQueue() {
const { clearFinishedJobs, queue } = useImportQueueWorkflow();
const hasFinished = queue.some((entry) => entry.status !== 'running');
return (
<section
className={`${styles.importPageQueue} ${queue.length === 0 ? styles.hidden : ''}`}
id="import-page-queue"
>
<div className={styles.importPageQueueHeader}>
<span className={styles.importPageQueueTitle}>Processing</span>
<button
type="button"
className={styles.importPageQueueClear}
id="import-page-queue-clear"
style={{ display: hasFinished ? undefined : 'none' }}
onClick={clearFinishedJobs}
>
Clear finished
</button>
</div>
<div className={styles.importPageQueueList} id="import-page-queue-list">
{queue.map((entry) => (
<ImportQueueItem key={entry.id} entry={entry} />
))}
</div>
</section>
);
}
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 (
<div className={styles.importPageQueueItem}>
{entry.imageUrl ? (
<img
className={styles.importPageQueueArt}
src={entry.imageUrl}
alt=""
onError={fallbackImage}
/>
) : (
<div className={`${styles.importPageQueueArt} ${styles.importPageQueueArtEmpty}`}>
A
</div>
)}
<div className={styles.importPageQueueInfo}>
<div className={styles.importPageQueueName}>{entry.label}</div>
<div className={styles.importPageQueueDetail}>{entry.sublabel}</div>
</div>
<div className={styles.importPageQueueProgress}>
<div className={styles.importPageQueueBar}>
<div
className={`${styles.importPageQueueFill} ${
entry.status === 'error' ? styles.error : ''
}`}
style={{ width: `${getQueueProgressPercent(entry)}%` }}
/>
</div>
<div className={`${styles.importPageQueueStatus} ${statusClass}`}>{statusText}</div>
</div>
</div>
);
}
function ImportTabNav() {
return (
<nav className={styles.importPageTabBar} aria-label="Import modes">
<Link
to="/import/auto"
className={styles.importPageTab}
activeProps={{ className: `${styles.importPageTab} ${styles.active}` }}
id="import-page-tab-auto"
>
Auto
</Link>
<Link
to="/import/album"
className={styles.importPageTab}
activeProps={{ className: `${styles.importPageTab} ${styles.active}` }}
id="import-page-tab-album"
>
Albums
</Link>
<Link
to="/import/singles"
className={styles.importPageTab}
activeProps={{ className: `${styles.importPageTab} ${styles.active}` }}
id="import-page-tab-singles"
>
Singles
</Link>
</nav>
);
}

View file

@ -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 (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M13.65 2.35A8 8 0 1 0 16 8h-2a6 6 0 1 1-1.76-4.24L10 6h6V0l-2.35 2.35z" />
</svg>
);
}
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';
}

View file

@ -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 (
<SinglesImportPanel
files={stagingFiles}
manualMatches={singlesManualMatches}
openSearchIndex={openSingleSearch}
searchStates={singleSearches}
selected={selectedSingles}
onOpenSearch={openSingleSearchPanel}
onProcessSingles={processSingles}
onRunSearch={runSingleSearch}
onSearchQueryChange={(index, query) => {
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<number, ImportTrackResult>;
openSearchIndex: number | null;
searchStates: Record<number, SingleSearchState>;
selected: Set<number>;
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 (
<>
<div className={styles.importPageSinglesHeader}>
<div className={styles.importPageSinglesActions}>
<button type="button" className={styles.importPageSecondaryBtn} onClick={onSelectAll}>
<span id="import-page-select-all-text">
{allSelected ? 'Deselect All' : 'Select All'}
</span>
</button>
<button
type="button"
className={styles.importPageProcessBtn}
id="import-page-singles-process-btn"
disabled={selected.size === 0}
onClick={onProcessSingles}
>
Process Selected ({selected.size})
</button>
</div>
</div>
<div className={styles.importPageSinglesList} id="import-page-singles-list">
{files.length === 0 ? (
<div className={styles.importPageEmptyState}>No audio files found in import folder</div>
) : (
files.map((file, index) => {
const manualMatch = manualMatches[index];
const isSelected = selected.has(index);
const searchState = searchStates[index];
return (
<div
key={`${file.full_path}-${index}`}
className={`${styles.importPageSingleItem} ${
manualMatch ? styles.matched : ''
}`}
data-single-idx={index}
>
<button
type="button"
aria-label={`Select ${file.filename}`}
className={`${styles.importPageSingleCheckbox} ${
isSelected ? styles.checked : ''
}`}
onClick={() => onToggleSingle(index)}
/>
<div className={styles.importPageSingleInfo}>
<div className={styles.importPageSingleFilename}>{file.filename}</div>
<div className={styles.importPageSingleMeta}>
{file.title ? <span>{file.title}</span> : null}
{file.artist ? <span>{file.artist}</span> : null}
{file.extension ? <span>{file.extension}</span> : null}
</div>
{manualMatch ? (
<div className={styles.importPageSingleMatchedInfo}>
OK {manualMatch.name} - {manualMatch.artist}
<button
type="button"
className={styles.importPageSingleMatchedChange}
onClick={() => onOpenSearch(index)}
>
change
</button>
</div>
) : null}
</div>
<div className={styles.importPageSingleActions}>
<button
type="button"
className={styles.importPageIdentifyBtn}
onClick={() => onOpenSearch(index)}
>
Search Identify
</button>
</div>
{openSearchIndex === index ? (
<SingleSearchPanel
fileIndex={index}
searchState={searchState}
onQueryChange={onSearchQueryChange}
onRunSearch={onRunSearch}
onSelectMatch={onSelectMatch}
/>
) : null}
</div>
);
})
)}
</div>
</>
);
}
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 (
<div className={styles.importPageSingleSearchPanel}>
<div className={styles.importPageSingleSearchBar}>
<input
type="text"
className={styles.importPageSingleSearchInput}
value={query}
placeholder="Search artist - title..."
onChange={(event) => onQueryChange(fileIndex, event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onRunSearch(fileIndex, query);
}}
/>
<button
type="button"
className={styles.importPageSingleSearchGo}
onClick={() => onRunSearch(fileIndex, query)}
>
Search
</button>
</div>
<div className={styles.importPageSingleSearchResults} id={`import-single-results-${fileIndex}`}>
{searchState?.loading ? (
<div className={styles.importPageEmptyState}>Searching...</div>
) : searchState?.error ? (
<div className={styles.importPageEmptyState}>Error: {searchState.error}</div>
) : searchState?.results.length === 0 ? (
<div className={styles.importPageEmptyState}>No results found</div>
) : (
searchState?.results.map((track, index) => (
<button
key={`${track.source || 'source'}-${track.id}-${index}`}
type="button"
className={styles.importPageSingleResultItem}
onClick={() => onSelectMatch(fileIndex, track)}
>
{track.image_url ? (
<img
className={styles.importPageSingleResultImg}
src={track.image_url}
alt=""
onError={fallbackImage}
/>
) : null}
<div className={styles.importPageSingleResultInfo}>
<div className={styles.importPageSingleResultName}>
{track.name} - {track.artist}
</div>
<div className={styles.importPageSingleResultDetail}>
{track.album}
{track.duration_ms ? ` - ${formatDuration(track.duration_ms)}` : ''}
</div>
</div>
<span className={styles.importPageSingleResultSelect}>Select</span>
</button>
))
)}
</div>
</div>
);
}

View file

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

View file

@ -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 <AutoImportPanel autoFilter={autoFilter} onFilterChange={setAutoFilter} />;
}

View file

@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/import/')({
beforeLoad: () => {
throw redirect({ to: '/import/album', replace: true });
},
});

View file

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

View file

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