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:
parent
5680e52ceb
commit
27fbc80e7a
26 changed files with 5326 additions and 171 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
315
webui/docs/migration/import-migration-plan.md
Normal file
315
webui/docs/migration/import-migration-plan.md
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
153
webui/index.html
153
webui/index.html
|
|
@ -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">
|
||||
|
|
|
|||
32
webui/package-lock.json
generated
32
webui/package-lock.json
generated
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
7
webui/src/platform/shell/globals.d.ts
vendored
7
webui/src/platform/shell/globals.d.ts
vendored
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
293
webui/src/routes/import/-import.api.ts
Normal file
293
webui/src/routes/import/-import.api.ts
Normal 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'] }),
|
||||
]);
|
||||
}
|
||||
286
webui/src/routes/import/-import.helpers.ts
Normal file
286
webui/src/routes/import/-import.helpers.ts
Normal 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}`;
|
||||
}
|
||||
236
webui/src/routes/import/-import.store.ts
Normal file
236
webui/src/routes/import/-import.store.ts
Normal 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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
226
webui/src/routes/import/-import.types.ts
Normal file
226
webui/src/routes/import/-import.types.ts
Normal 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;
|
||||
272
webui/src/routes/import/-route.test.tsx
Normal file
272
webui/src/routes/import/-route.test.tsx
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
581
webui/src/routes/import/-ui/album-import-tab.tsx
Normal file
581
webui/src/routes/import/-ui/album-import-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
580
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal file
580
webui/src/routes/import/-ui/auto-import-tab.tsx
Normal 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);
|
||||
}
|
||||
1645
webui/src/routes/import/-ui/import-page.module.css
Normal file
1645
webui/src/routes/import/-ui/import-page.module.css
Normal file
File diff suppressed because it is too large
Load diff
184
webui/src/routes/import/-ui/import-page.tsx
Normal file
184
webui/src/routes/import/-ui/import-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
webui/src/routes/import/-ui/import-shared.tsx
Normal file
106
webui/src/routes/import/-ui/import-shared.tsx
Normal 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';
|
||||
}
|
||||
330
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal file
330
webui/src/routes/import/-ui/singles-import-tab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
webui/src/routes/import/album.tsx
Normal file
15
webui/src/routes/import/album.tsx
Normal 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,
|
||||
});
|
||||
27
webui/src/routes/import/auto.tsx
Normal file
27
webui/src/routes/import/auto.tsx
Normal 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} />;
|
||||
}
|
||||
7
webui/src/routes/import/index.tsx
Normal file
7
webui/src/routes/import/index.tsx
Normal 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 });
|
||||
},
|
||||
});
|
||||
20
webui/src/routes/import/route.tsx
Normal file
20
webui/src/routes/import/route.tsx
Normal 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,
|
||||
});
|
||||
7
webui/src/routes/import/singles.tsx
Normal file
7
webui/src/routes/import/singles.tsx
Normal 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,
|
||||
});
|
||||
Loading…
Reference in a new issue