diff --git a/webui/package-lock.json b/webui/package-lock.json
index 23b81ab1..3e6bf5a6 100644
--- a/webui/package-lock.json
+++ b/webui/package-lock.json
@@ -15,7 +15,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"recharts": "^3.8.1",
- "zod": "^4.4.2"
+ "zod": "^4.4.2",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
@@ -5391,6 +5392,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+ },
+ "node_modules/zustand": {
+ "version": "5.0.13",
+ "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz",
+ "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ },
+ "peerDependencies": {
+ "@types/react": ">=18.0.0",
+ "immer": ">=9.0.6",
+ "react": ">=18.0.0",
+ "use-sync-external-store": ">=1.2.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "immer": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "use-sync-external-store": {
+ "optional": true
+ }
+ }
}
}
}
diff --git a/webui/package.json b/webui/package.json
index 56123f51..ad4cb775 100644
--- a/webui/package.json
+++ b/webui/package.json
@@ -21,7 +21,8 @@
"react": "^19.2.5",
"react-dom": "^19.2.5",
"recharts": "^3.8.1",
- "zod": "^4.4.2"
+ "zod": "^4.4.2",
+ "zustand": "^5.0.13"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
diff --git a/webui/src/components/form/form.module.css b/webui/src/components/form/form.module.css
index bf673cac..31eff1a8 100644
--- a/webui/src/components/form/form.module.css
+++ b/webui/src/components/form/form.module.css
@@ -73,6 +73,7 @@
.select {
width: auto;
min-width: 130px;
+ box-sizing: border-box;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
background:
@@ -95,6 +96,7 @@
font: inherit;
font-size: 13px;
line-height: 1.5;
+ min-height: 36px;
padding: 8px 32px 8px 12px;
transition:
border-color 0.18s ease,
@@ -105,28 +107,204 @@
-webkit-appearance: none;
color-scheme: dark;
cursor: pointer;
+ &:hover {
+ border-color: rgba(255, 255, 255, 0.16);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
+ rgba(255, 255, 255, 0.06);
+ }
+
+ &:focus {
+ outline: none;
+ border-color: rgba(var(--accent-light-rgb), 0.55);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
+ rgba(255, 255, 255, 0.07);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+ }
+
+ &[data-size='sm'] {
+ min-height: 32px;
+ padding: 6px 30px 6px 10px;
+ background-position:
+ 0 0,
+ 0 0,
+ right 10px center;
+ }
+
+ & option,
+ & optgroup {
+ background: #1a1a2e;
+ color: #fff;
+ }
}
-.select:hover {
- border-color: rgba(255, 255, 255, 0.16);
+.checkbox {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ margin: 0;
+ border: 2px solid rgba(255, 255, 255, 0.2);
+ border-radius: 4px;
+ background: transparent;
+ color: #000;
+ cursor: pointer;
+ transition:
+ border-color 0.18s ease,
+ background 0.18s ease,
+ box-shadow 0.18s ease,
+ transform 0.18s ease;
+ color-scheme: dark;
+ &[data-checked] {
+ border-color: rgb(var(--accent-light-rgb));
+ background: rgb(var(--accent-light-rgb));
+ }
+
+ &[data-focused] {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
+ }
+
+ &[data-checked] .checkboxIndicator {
+ opacity: 1;
+ }
+}
+
+.checkboxIndicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ height: 100%;
+ opacity: 0;
+ transition: opacity 0.18s ease;
+}
+
+.checkboxIcon {
+ color: #000;
+ font-size: 12px;
+ font-weight: 700;
+ line-height: 1;
+ transform: translateY(-0.5px) scaleX(1.18);
+}
+
+.switch {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ width: 44px;
+ height: 24px;
+ flex-shrink: 0;
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0 3px;
+ border: 1px solid rgba(255, 255, 255, 0.16);
+ border-radius: 999px;
background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.045)),
+ linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.04)),
rgba(255, 255, 255, 0.06);
+ cursor: pointer;
+ transition:
+ border-color 0.18s ease,
+ background 0.18s ease,
+ box-shadow 0.18s ease,
+ transform 0.18s ease;
+ color-scheme: dark;
+ &[data-checked] {
+ border-color: rgba(var(--accent-light-rgb), 0.55);
+ background:
+ linear-gradient(180deg, rgba(var(--accent-light-rgb), 0.55), rgba(var(--accent-rgb), 0.8)),
+ rgba(var(--accent-rgb), 0.45);
+ }
+
+ &[data-focused] {
+ outline: none;
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.14);
+ }
+
+ &[data-disabled] {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+
+ &[data-checked] .switchThumb {
+ transform: translateX(20px);
+ }
}
-.select:focus {
- outline: none;
- border-color: rgba(var(--accent-light-rgb), 0.55);
+.switchThumb {
+ display: block;
+ width: 18px;
+ height: 18px;
+ border-radius: 50%;
+ background: #fff;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+ transform: translateX(0);
+ transition: transform 0.18s ease;
+}
+
+.rangeRoot {
+ display: inline-flex;
+ flex: 0 0 160px;
+ width: 160px;
+ min-width: 160px;
+ color-scheme: dark;
+}
+
+.rangeControl {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ min-height: 28px;
+ cursor: pointer;
+}
+
+.rangeTrack {
+ position: relative;
+ width: 100%;
+ height: 8px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 999px;
+ background: rgba(255, 255, 255, 0.08);
+ box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.04);
+}
+
+.rangeIndicator {
+ height: 100%;
+ border-radius: inherit;
+ background: linear-gradient(
+ 90deg,
+ rgba(var(--accent-rgb), 0.95),
+ rgba(var(--accent-light-rgb), 0.95)
+ );
+}
+
+.rangeThumb {
+ width: 16px;
+ height: 16px;
+ border: 1px solid rgba(255, 255, 255, 0.24);
+ border-radius: 50%;
background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.05)),
- rgba(255, 255, 255, 0.07);
- box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+ radial-gradient(circle at 30% 28%, rgba(255, 255, 255, 0.3), transparent 46%),
+ rgb(var(--accent-light-rgb));
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.28);
+ transition:
+ transform 0.18s ease,
+ box-shadow 0.18s ease;
}
-.select option,
-.select optgroup {
- background: #1a1a2e;
- color: #fff;
+.rangeThumb:hover {
+ transform: scale(1.04);
+}
+
+.rangeThumb:focus-visible {
+ box-shadow:
+ 0 0 0 4px rgba(var(--accent-light-rgb), 0.14),
+ 0 2px 8px rgba(0, 0, 0, 0.28);
}
.optionCardGroup {
@@ -154,25 +332,24 @@
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
-}
+ &:hover {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.14);
+ background:
+ linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
+ rgba(255, 255, 255, 0.04);
+ box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
+ }
-.optionCard:hover {
- transform: translateY(-1px);
- border-color: rgba(255, 255, 255, 0.14);
- background:
- linear-gradient(180deg, rgba(255, 255, 255, 0.075), rgba(255, 255, 255, 0.04)),
- rgba(255, 255, 255, 0.04);
- box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
-}
-
-.optionCardSelected {
- border-color: rgba(var(--accent-light-rgb), 0.45);
- background:
- linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
- rgba(255, 255, 255, 0.05);
- box-shadow:
- 0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
- 0 14px 32px rgba(0, 0, 0, 0.26);
+ &[data-selected='true'] {
+ border-color: rgba(var(--accent-light-rgb), 0.45);
+ background:
+ linear-gradient(180deg, rgba(var(--accent-rgb), 0.18), rgba(255, 255, 255, 0.04)),
+ rgba(255, 255, 255, 0.05);
+ box-shadow:
+ 0 0 0 1px rgba(var(--accent-light-rgb), 0.1),
+ 0 14px 32px rgba(0, 0, 0, 0.26);
+ }
}
.optionCardIcon {
@@ -206,9 +383,22 @@
display: flex;
flex-wrap: wrap;
gap: 8px;
+ &[data-size='sm'] {
+ gap: 6px;
+ }
+
+ &[data-size='sm'] .optionButton {
+ min-width: 0;
+ padding: 6px 12px;
+ font-size: 12px;
+ gap: 6px;
+ }
}
.optionButton {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
@@ -218,24 +408,45 @@
font-size: 13px;
font-weight: 600;
min-width: 80px;
- padding: 10px 14px;
+ padding: 8px 16px;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
-}
+ &:hover {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.08);
+ }
-.optionButton:hover {
- transform: translateY(-1px);
- border-color: rgba(255, 255, 255, 0.18);
- background: rgba(255, 255, 255, 0.08);
-}
+ &[data-variant='ghost'] {
+ border-color: transparent;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.58);
+ }
-.optionButtonSelected {
- border-color: rgba(var(--accent-light-rgb), 0.5);
- background: rgba(var(--accent-rgb), 0.18);
- box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
+ &[data-variant='ghost']:hover:not(:disabled) {
+ transform: none;
+ border-color: rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.06);
+ color: rgba(255, 255, 255, 0.85);
+ }
+
+ &[data-selected='true'] {
+ color: #fff;
+ border-color: rgba(var(--accent-light-rgb), 0.5);
+ background: rgba(var(--accent-rgb), 0.18);
+ box-shadow: 0 0 0 1px rgba(var(--accent-light-rgb), 0.08);
+ }
+
+ &[data-selected='true']:hover {
+ border-color: rgba(var(--accent-light-rgb), 0.62);
+ background: rgba(var(--accent-rgb), 0.26);
+ box-shadow:
+ 0 0 0 1px rgba(var(--accent-light-rgb), 0.12),
+ 0 10px 24px rgba(0, 0, 0, 0.14);
+ }
}
.button {
@@ -260,24 +471,118 @@
box-shadow 0.18s ease,
background 0.18s ease,
color 0.18s ease;
-}
+ &:hover:not(:disabled) {
+ transform: translateY(-1px);
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.08);
+ }
-.button:hover:not(:disabled) {
- transform: translateY(-1px);
- border-color: rgba(255, 255, 255, 0.18);
- background: rgba(255, 255, 255, 0.08);
-}
+ &:focus-visible {
+ outline: none;
+ border-color: rgba(var(--accent-light-rgb), 0.55);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+ }
-.button:focus-visible {
- outline: none;
- border-color: rgba(var(--accent-light-rgb), 0.55);
- box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
-}
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ transform: none;
+ }
-.button:disabled {
- opacity: 0.55;
- cursor: not-allowed;
- transform: none;
+ &[data-size='sm'] {
+ min-height: 32px;
+ padding: 6px 10px;
+ font-size: 13px;
+ }
+
+ &[data-size='lg'] {
+ min-height: 40px;
+ padding: 10px 20px;
+ font-size: 14px;
+ }
+
+ &[data-size='icon'] {
+ width: var(--button-icon-size, 36px);
+ height: var(--button-icon-size, 36px);
+ min-height: var(--button-icon-size, 36px);
+ min-width: var(--button-icon-size, 36px);
+ padding: 0;
+ font-size: var(--button-icon-font-size, 15px);
+ line-height: 1;
+ }
+
+ &[data-variant='primary'] {
+ border-color: rgba(var(--accent-light-rgb), 0.45);
+ background: rgb(var(--accent-light-rgb));
+ color: #000;
+ }
+
+ &[data-variant='primary']:hover:not(:disabled) {
+ border-color: rgba(var(--accent-light-rgb), 0.55);
+ background: rgba(var(--accent-light-rgb), 0.95);
+ color: #000;
+ }
+
+ &[data-variant='primary']:focus-visible {
+ border-color: rgba(var(--accent-light-rgb), 0.8);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.16);
+ }
+
+ &[data-variant='primary']:disabled {
+ opacity: 0.62;
+ background: rgba(var(--accent-light-rgb), 0.45);
+ color: rgba(0, 0, 0, 0.55);
+ }
+
+ &[data-variant='primary'] [data-slot='badge'] {
+ color: #fff;
+ background: rgba(0, 0, 0, 0.18);
+ border-color: rgba(0, 0, 0, 0.22);
+ }
+
+ &[data-variant='secondary'] {
+ border-color: rgba(255, 255, 255, 0.12);
+ background: rgba(255, 255, 255, 0.08);
+ color: #ccc;
+ }
+
+ &[data-variant='secondary']:hover:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.18);
+ background: rgba(255, 255, 255, 0.14);
+ color: #fff;
+ }
+
+ &[data-variant='secondary']:focus-visible {
+ border-color: rgba(var(--accent-light-rgb), 0.45);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+ }
+
+ &[data-variant='secondary']:disabled {
+ opacity: 0.55;
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.45);
+ }
+
+ &[data-variant='ghost'] {
+ border-color: transparent;
+ background: transparent;
+ color: rgba(255, 255, 255, 0.55);
+ }
+
+ &[data-variant='ghost']:hover:not(:disabled) {
+ border-color: rgba(255, 255, 255, 0.14);
+ background: rgba(255, 255, 255, 0.07);
+ color: #fff;
+ }
+
+ &[data-variant='ghost']:focus-visible {
+ border-color: rgba(var(--accent-light-rgb), 0.42);
+ box-shadow: 0 0 0 3px rgba(var(--accent-light-rgb), 0.12);
+ }
+
+ &[data-variant='ghost']:disabled {
+ opacity: 0.55;
+ }
}
.formError {
diff --git a/webui/src/components/form/form.test.tsx b/webui/src/components/form/form.test.tsx
index 3488a2f6..77b7677b 100644
--- a/webui/src/components/form/form.test.tsx
+++ b/webui/src/components/form/form.test.tsx
@@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest';
import {
Button,
+ Checkbox,
FormActions,
FormError,
FormField,
@@ -11,7 +12,9 @@ import {
OptionButtonGroup,
OptionCard,
OptionCardGroup,
+ RangeInput,
Select,
+ Switch,
TextArea,
TextInput,
} from './form';
@@ -22,6 +25,9 @@ function FormDemo() {
const [category, setCategory] = useState<'wrong_cover' | 'wrong_metadata'>('wrong_cover');
const [priority, setPriority] = useState<'low' | 'normal' | 'high'>('normal');
const [status, setStatus] = useState('open');
+ const [archive, setArchive] = useState(false);
+ const [enabled, setEnabled] = useState(true);
+ const [confidence, setConfidence] = useState(90);
return (
);
@@ -109,9 +135,25 @@ describe('form primitives', () => {
expect(screen.getByText('Short summary')).toBeInTheDocument();
expect(screen.getByRole('alert')).toHaveTextContent('Validation failed');
expect(screen.getByLabelText('Status')).toHaveValue('open');
+ expect(screen.getByLabelText('Status')).toHaveAttribute('data-size', 'md');
fireEvent.change(screen.getByLabelText('Status'), { target: { value: 'resolved' } });
expect(screen.getByLabelText('Status')).toHaveValue('resolved');
+ const archiveCheckbox = screen.getByRole('checkbox', { name: 'Archive' });
+ expect(archiveCheckbox).not.toBeChecked();
+ fireEvent.click(archiveCheckbox);
+ expect(archiveCheckbox).toBeChecked();
+
+ const enabledSwitch = screen.getByRole('switch', { name: 'Enabled' });
+ expect(enabledSwitch).toBeChecked();
+ fireEvent.click(enabledSwitch);
+ expect(enabledSwitch).not.toBeChecked();
+
+ const confidenceSlider = screen.getByLabelText('Confidence', { selector: 'input' });
+ expect(confidenceSlider).toHaveValue('90');
+ fireEvent.change(confidenceSlider, { target: { value: '75' } });
+ expect(confidenceSlider).toHaveValue('75');
+
const wrongCover = screen.getByRole('button', { name: /wrong cover/i });
const wrongMetadata = screen.getByRole('button', { name: /wrong metadata/i });
expect(wrongCover).toHaveAttribute('aria-pressed', 'true');
@@ -126,6 +168,32 @@ describe('form primitives', () => {
expect(highPriority).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
- expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Save' })).toHaveAttribute('data-variant', 'primary');
+ });
+
+ it('supports compact option button groups', () => {
+ const { container } = render(
+
+ All
+ Pending
+ ,
+ );
+
+ expect(container.querySelector('[data-size="sm"]')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Pending' })).toHaveAttribute(
+ 'data-variant',
+ 'ghost',
+ );
+ });
+
+ it('supports compact select sizing', () => {
+ render(
+
+ One
+ Two
+ ,
+ );
+
+ expect(screen.getByLabelText('Compact')).toHaveAttribute('data-size', 'sm');
});
});
diff --git a/webui/src/components/form/form.tsx b/webui/src/components/form/form.tsx
index 15938435..9e894d43 100644
--- a/webui/src/components/form/form.tsx
+++ b/webui/src/components/form/form.tsx
@@ -1,10 +1,14 @@
import { Button as BaseButton } from '@base-ui/react/button';
+import { Checkbox as BaseCheckbox } from '@base-ui/react/checkbox';
import { Field } from '@base-ui/react/field';
import { Input as BaseInput } from '@base-ui/react/input';
+import { Slider } from '@base-ui/react/slider';
+import { Switch as BaseSwitch } from '@base-ui/react/switch';
import { Toggle as BaseToggle } from '@base-ui/react/toggle';
import clsx from 'clsx';
import {
forwardRef,
+ type CSSProperties,
type ComponentPropsWithoutRef,
type ButtonHTMLAttributes,
type SelectHTMLAttributes,
@@ -73,13 +77,118 @@ export const TextArea = forwardRef
(function
return ;
});
-export type SelectProps = SelectHTMLAttributes;
+export type SelectSize = 'sm' | 'md';
+
+export type SelectProps = Omit, 'className' | 'size'> & {
+ className?: string;
+ size?: SelectSize;
+};
export const Select = forwardRef(function Select(
+ { className, size = 'md', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+type BaseCheckboxProps = ComponentPropsWithoutRef;
+
+export type CheckboxProps = Omit & {
+ className?: string;
+};
+
+export const Checkbox = forwardRef(function Checkbox(
{ className, ...props },
ref,
) {
- return ;
+ return (
+
+
+
+ ✓
+
+
+
+ );
+});
+
+type BaseSwitchProps = ComponentPropsWithoutRef;
+
+export type SwitchProps = Omit & {
+ className?: string;
+};
+
+export const Switch = forwardRef(function Switch(
+ { className, ...props },
+ ref,
+) {
+ return (
+
+
+
+ );
+});
+
+export interface RangeInputProps {
+ className?: string;
+ disabled?: boolean;
+ defaultValue?: number;
+ label?: ReactNode;
+ max?: number;
+ min?: number;
+ name?: string;
+ step?: number;
+ style?: CSSProperties;
+ value?: number;
+ onValueChange?: (value: number) => void;
+}
+
+export const RangeInput = forwardRef(function RangeInput(
+ {
+ className,
+ defaultValue,
+ disabled,
+ label,
+ max = 100,
+ min = 0,
+ name,
+ onValueChange,
+ step = 1,
+ style,
+ value,
+ },
+ ref,
+) {
+ return (
+ {
+ onValueChange?.(Array.isArray(nextValue) ? nextValue[0] : nextValue);
+ }}
+ >
+
+
+
+
+
+
+
+ );
});
export function OptionCardGroup({
@@ -112,7 +221,8 @@ export const OptionCard = forwardRef(functio
@@ -129,31 +239,42 @@ export const OptionCard = forwardRef(functio
);
});
-export function OptionButtonGroup({
- className,
- children,
-}: {
+export type OptionButtonGroupSize = 'sm' | 'md';
+
+export interface OptionButtonGroupProps {
children: ReactNode;
className?: string;
-}) {
- return {children}
;
+ size?: OptionButtonGroupSize;
}
+export function OptionButtonGroup({ className, children, size = 'md' }: OptionButtonGroupProps) {
+ return (
+
+ {children}
+
+ );
+}
+
+export type OptionButtonVariant = 'default' | 'ghost';
+
export interface OptionButtonProps extends Omit, 'value'> {
className?: string;
selected?: boolean;
+ variant?: OptionButtonVariant;
value?: string;
}
export const OptionButton = forwardRef(function OptionButton(
- { className, children, selected = false, type = 'button', ...props },
+ { className, children, selected = false, type = 'button', variant = 'default', ...props },
ref,
) {
return (
@@ -166,13 +287,24 @@ type BaseButtonProps = ComponentPropsWithoutRef;
export type ButtonProps = Omit & {
className?: string;
+ size?: 'sm' | 'md' | 'lg' | 'icon';
+ variant?: 'default' | 'primary' | 'secondary' | 'ghost';
};
export const Button = forwardRef(function Button(
- { className, type = 'button', ...props },
+ { className, size = 'md', type = 'button', variant = 'default', ...props },
ref,
) {
- return ;
+ return (
+
+ );
});
export function FormError({ className, message }: { className?: string; message?: ReactNode }) {
diff --git a/webui/src/components/primitives/index.ts b/webui/src/components/primitives/index.ts
index 849c5bcc..6b63ff8a 100644
--- a/webui/src/components/primitives/index.ts
+++ b/webui/src/components/primitives/index.ts
@@ -1 +1 @@
-export { Show } from './show';
+export * from './primitives';
diff --git a/webui/src/components/primitives/primitives.module.css b/webui/src/components/primitives/primitives.module.css
new file mode 100644
index 00000000..f585d5e8
--- /dev/null
+++ b/webui/src/components/primitives/primitives.module.css
@@ -0,0 +1,87 @@
+.notice {
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ margin: 0 0 12px;
+ padding: 10px 14px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.82);
+ font-size: 12px;
+ font-weight: 500;
+ line-height: 1.4;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
+}
+
+.notice[data-tone='neutral'] {
+ border-color: rgba(255, 255, 255, 0.08);
+ background: rgba(255, 255, 255, 0.04);
+ color: rgba(255, 255, 255, 0.82);
+}
+
+.notice[data-tone='info'] {
+ border-color: rgba(var(--accent-light-rgb), 0.24);
+ background: rgba(var(--accent-light-rgb), 0.08);
+ color: rgba(255, 232, 188, 0.94);
+}
+
+.notice[data-tone='success'] {
+ border-color: rgba(110, 220, 150, 0.26);
+ background: rgba(110, 220, 150, 0.08);
+ color: rgba(210, 255, 228, 0.94);
+}
+
+.notice[data-tone='warning'] {
+ border-color: rgba(255, 200, 100, 0.26);
+ background: rgba(255, 200, 100, 0.08);
+ color: rgba(255, 232, 188, 0.94);
+}
+
+.notice[data-tone='danger'] {
+ border-color: rgba(255, 120, 120, 0.26);
+ background: rgba(255, 120, 120, 0.08);
+ color: rgba(255, 214, 214, 0.94);
+}
+
+.badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 2ch;
+ padding: 1px 7px;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ font-size: 0.74rem;
+ font-weight: 700;
+ line-height: 1.2;
+ white-space: nowrap;
+ vertical-align: middle;
+ color: rgba(255, 255, 255, 0.45);
+ background: rgba(255, 255, 255, 0.05);
+ border-color: rgba(255, 255, 255, 0.08);
+}
+
+.badge[data-tone='info'] {
+ color: rgb(var(--accent-light-rgb));
+ background: rgba(var(--accent-rgb), 0.12);
+ border-color: rgba(var(--accent-light-rgb), 0.12);
+}
+
+.badge[data-tone='success'] {
+ color: #4ade80;
+ background: rgba(74, 222, 128, 0.12);
+ border-color: rgba(74, 222, 128, 0.12);
+}
+
+.badge[data-tone='warning'] {
+ color: #fbbf24;
+ background: rgba(251, 191, 36, 0.12);
+ border-color: rgba(251, 191, 36, 0.12);
+}
+
+.badge[data-tone='danger'] {
+ color: #f87171;
+ background: rgba(248, 113, 113, 0.12);
+ border-color: rgba(248, 113, 113, 0.12);
+}
diff --git a/webui/src/components/primitives/primitives.test.tsx b/webui/src/components/primitives/primitives.test.tsx
new file mode 100644
index 00000000..9f5d6e2c
--- /dev/null
+++ b/webui/src/components/primitives/primitives.test.tsx
@@ -0,0 +1,67 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+
+import { Badge, Notice, Show } from './primitives';
+
+describe('Show', () => {
+ it('renders children when the condition is true', () => {
+ render(
+
+ Visible
+ ,
+ );
+
+ expect(screen.getByText('Visible')).toBeInTheDocument();
+ });
+
+ it('renders fallback when the condition is false', () => {
+ render(
+ Hidden} when={false}>
+ Visible
+ ,
+ );
+
+ expect(screen.getByText('Hidden')).toBeInTheDocument();
+ expect(screen.queryByText('Visible')).not.toBeInTheDocument();
+ });
+
+ it('supports render-prop children', () => {
+ render({(name) => {name} } );
+
+ expect(screen.getByText('Ada')).toBeInTheDocument();
+ });
+});
+
+describe('Notice', () => {
+ it('renders as a note by default', () => {
+ render(Fallback message );
+
+ expect(screen.getByText('Fallback message')).toHaveAttribute('role', 'note');
+ expect(screen.getByText('Fallback message')).toHaveAttribute('data-tone', 'info');
+ });
+
+ it('supports tone overrides', () => {
+ render(
+
+ Provider fallback
+ ,
+ );
+
+ expect(screen.getByRole('note')).toHaveAttribute('data-tone', 'warning');
+ });
+});
+
+describe('Badge', () => {
+ it('renders with neutral styling by default', () => {
+ render(12 );
+
+ expect(screen.getByText('12')).toHaveAttribute('data-slot', 'badge');
+ expect(screen.getByText('12')).toHaveAttribute('data-tone', 'neutral');
+ });
+
+ it('supports tone overrides', () => {
+ render(12 );
+
+ expect(screen.getByText('12')).toHaveAttribute('data-tone', 'warning');
+ });
+});
diff --git a/webui/src/components/primitives/primitives.tsx b/webui/src/components/primitives/primitives.tsx
new file mode 100644
index 00000000..8972632e
--- /dev/null
+++ b/webui/src/components/primitives/primitives.tsx
@@ -0,0 +1,70 @@
+import clsx from 'clsx';
+import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
+
+import styles from './primitives.module.css';
+
+type ShowChildren = ReactNode | ((value: NonNullable) => ReactNode);
+
+export interface ShowProps {
+ children: ShowChildren;
+ fallback?: ReactNode;
+ when: T;
+}
+
+export function Show({ fallback = null, children, when }: ShowProps) {
+ if (!when) {
+ return <>{fallback}>;
+ }
+
+ if (typeof children === 'function') {
+ return <>{(children as (value: NonNullable) => ReactNode)(when as NonNullable)}>;
+ }
+
+ return <>{children}>;
+}
+
+type BaseBadgeProps = ComponentPropsWithoutRef<'span'>;
+
+export type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
+
+export type BadgeProps = Omit & {
+ className?: string;
+ tone?: BadgeTone;
+};
+
+export const Badge = forwardRef(function Badge(
+ { className, tone = 'neutral', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
+
+export type NoticeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
+
+export type NoticeProps = Omit, 'className'> & {
+ className?: string;
+ tone?: NoticeTone;
+};
+
+export const Notice = forwardRef(function Notice(
+ { className, tone = 'info', role = 'note', ...props },
+ ref,
+) {
+ return (
+
+ );
+});
diff --git a/webui/src/components/primitives/show.test.tsx b/webui/src/components/primitives/show.test.tsx
deleted file mode 100644
index ec090d2c..00000000
--- a/webui/src/components/primitives/show.test.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { render, screen } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
-
-import { Show } from './show';
-
-describe('Show', () => {
- it('renders children when the condition is true', () => {
- render(
-
- Visible
- ,
- );
-
- expect(screen.getByText('Visible')).toBeInTheDocument();
- });
-
- it('renders fallback when the condition is false', () => {
- render(
- Hidden} when={false}>
- Visible
- ,
- );
-
- expect(screen.getByText('Hidden')).toBeInTheDocument();
- expect(screen.queryByText('Visible')).not.toBeInTheDocument();
- });
-
- it('supports render-prop children', () => {
- render({(name) => {name} } );
-
- expect(screen.getByText('Ada')).toBeInTheDocument();
- });
-});
diff --git a/webui/src/components/primitives/show.tsx b/webui/src/components/primitives/show.tsx
deleted file mode 100644
index 2265131a..00000000
--- a/webui/src/components/primitives/show.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-import type { ReactNode } from 'react';
-
-type ShowChildren = ReactNode | ((value: NonNullable) => ReactNode);
-
-export function Show({
- fallback = null,
- children,
- when,
-}: {
- children: ShowChildren;
- fallback?: ReactNode;
- when: T;
-}) {
- if (!when) {
- return <>{fallback}>;
- }
-
- if (typeof children === 'function') {
- return <>{(children as (value: NonNullable) => ReactNode)(when as NonNullable)}>;
- }
-
- return <>{children}>;
-}
diff --git a/webui/src/platform/shell/globals.d.ts b/webui/src/platform/shell/globals.d.ts
index 8b043665..dbc51571 100644
--- a/webui/src/platform/shell/globals.d.ts
+++ b/webui/src/platform/shell/globals.d.ts
@@ -9,6 +9,13 @@ import type { ShellProfileContext, ShellRouteDefinition, ShellPageId } from './b
declare global {
interface Window {
showToast?: (message: string, type?: string, durationOrContext?: number | string) => void;
+ showConfirmDialog?: (options?: {
+ title?: string;
+ message?: string;
+ confirmText?: string;
+ cancelText?: string;
+ destructive?: boolean;
+ }) => Promise;
SoulSyncIssueDomain?: IssueDomainBridge;
SoulSyncWorkflowActions?: {
openDownloadMissingAlbum: (input: DownloadMissingAlbumWorkflowInput) => void | Promise;
diff --git a/webui/src/platform/shell/route-manifest.test.ts b/webui/src/platform/shell/route-manifest.test.ts
index 2727c255..f3a77d45 100644
--- a/webui/src/platform/shell/route-manifest.test.ts
+++ b/webui/src/platform/shell/route-manifest.test.ts
@@ -18,7 +18,9 @@ describe('shellRouteManifest', () => {
expect(resolveShellPageFromPath('/watchlist')).toBe('watchlist');
expect(resolveShellPageFromPath('/active-downloads')).toBe('active-downloads');
expect(resolveShellPageFromPath('/artist-detail')).toBeNull();
- expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe('artist-detail');
+ expect(resolveShellPageFromPath('/artist-detail/spotify/2YZyLoL8N0Wb9xBt1NhZWg')).toBe(
+ 'artist-detail',
+ );
expect(resolveShellPageFromPath('/artists')).toBeNull();
});
@@ -31,6 +33,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 +52,9 @@ describe('shellRouteManifest', () => {
it('tracks whether a route is rendered by React or the legacy shell', () => {
expect(getShellRouteByPageId('issues')?.kind).toBe('react');
expect(getShellRouteByPageId('stats')?.kind).toBe('react');
+ expect(getShellRouteByPageId('import')?.kind).toBe('react');
expect(getShellRouteByPageId('discover')?.kind).toBe('legacy');
- expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['stats', 'issues']);
+ expect(reactShellRoutes.map((route) => route.pageId)).toEqual(['import', 'stats', 'issues']);
expect(legacyShellRoutes.some((route) => route.pageId === 'dashboard')).toBe(true);
});
diff --git a/webui/src/platform/shell/route-manifest.ts b/webui/src/platform/shell/route-manifest.ts
index bbcd7d09..c09a18b6 100644
--- a/webui/src/platform/shell/route-manifest.ts
+++ b/webui/src/platform/shell/route-manifest.ts
@@ -38,7 +38,7 @@ export const shellRouteManifest: readonly ShellRouteDefinition[] = [
{ pageId: 'wishlist', path: '/wishlist', kind: 'legacy' },
{ pageId: 'automations', path: '/automations', kind: 'legacy' },
{ pageId: 'active-downloads', path: '/active-downloads', kind: 'legacy' },
- { pageId: 'import', path: '/import', kind: 'legacy' },
+ { pageId: 'import', path: '/import', kind: 'react' },
{ pageId: 'library', path: '/library', kind: 'legacy' },
{ pageId: 'tools', path: '/tools', kind: 'legacy' },
{ pageId: 'artist-detail', path: '/artist-detail', kind: 'legacy' },
@@ -67,7 +67,11 @@ export function getShellRouteByPageId(pageId: ShellPageId): ShellRouteDefinition
}
export function getShellRouteByPath(pathname: string): ShellRouteDefinition | undefined {
- return routeByPath.get(normalizeShellPath(pathname) as `/${string}`);
+ const normalized = normalizeShellPath(pathname);
+ const exactRoute = routeByPath.get(normalized as `/${string}`);
+ if (exactRoute) return exactRoute;
+
+ return reactShellRoutes.find((route) => normalized.startsWith(`${route.path}/`));
}
export function resolveShellPageFromPath(pathname: string): ShellPageId | null {
diff --git a/webui/src/routeTree.gen.ts b/webui/src/routeTree.gen.ts
index addc76fb..7f935df1 100644
--- a/webui/src/routeTree.gen.ts
+++ b/webui/src/routeTree.gen.ts
@@ -12,7 +12,12 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SplatRouteImport } from './routes/$'
import { Route as StatsRouteRouteImport } from './routes/stats/route'
import { Route as IssuesRouteRouteImport } from './routes/issues/route'
+import { Route as ImportRouteRouteImport } from './routes/import/route'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as ImportIndexRouteImport } from './routes/import/index'
+import { Route as ImportSinglesRouteImport } from './routes/import/singles'
+import { Route as ImportAutoRouteImport } from './routes/import/auto'
+import { Route as ImportAlbumRouteImport } from './routes/import/album'
import { Route as ArtistDetailSourceIdRouteImport } from './routes/artist-detail/$source/$id'
const SplatRoute = SplatRouteImport.update({
@@ -30,11 +35,36 @@ const IssuesRouteRoute = IssuesRouteRouteImport.update({
path: '/issues',
getParentRoute: () => rootRouteImport,
} as any)
+const ImportRouteRoute = ImportRouteRouteImport.update({
+ id: '/import',
+ path: '/import',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const ImportIndexRoute = ImportIndexRouteImport.update({
+ id: '/',
+ path: '/',
+ getParentRoute: () => ImportRouteRoute,
+} as any)
+const ImportSinglesRoute = ImportSinglesRouteImport.update({
+ id: '/singles',
+ path: '/singles',
+ getParentRoute: () => ImportRouteRoute,
+} as any)
+const ImportAutoRoute = ImportAutoRouteImport.update({
+ id: '/auto',
+ path: '/auto',
+ getParentRoute: () => ImportRouteRoute,
+} as any)
+const ImportAlbumRoute = ImportAlbumRouteImport.update({
+ id: '/album',
+ path: '/album',
+ getParentRoute: () => ImportRouteRoute,
+} as any)
const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
id: '/artist-detail/$source/$id',
path: '/artist-detail/$source/$id',
@@ -43,9 +73,14 @@ const ArtistDetailSourceIdRoute = ArtistDetailSourceIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/import': typeof ImportRouteRouteWithChildren
'/issues': typeof IssuesRouteRoute
'/stats': typeof StatsRouteRoute
'/$': typeof SplatRoute
+ '/import/album': typeof ImportAlbumRoute
+ '/import/auto': typeof ImportAutoRoute
+ '/import/singles': typeof ImportSinglesRoute
+ '/import/': typeof ImportIndexRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRoutesByTo {
@@ -53,32 +88,66 @@ export interface FileRoutesByTo {
'/issues': typeof IssuesRouteRoute
'/stats': typeof StatsRouteRoute
'/$': typeof SplatRoute
+ '/import/album': typeof ImportAlbumRoute
+ '/import/auto': typeof ImportAutoRoute
+ '/import/singles': typeof ImportSinglesRoute
+ '/import': typeof ImportIndexRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/import': typeof ImportRouteRouteWithChildren
'/issues': typeof IssuesRouteRoute
'/stats': typeof StatsRouteRoute
'/$': typeof SplatRoute
+ '/import/album': typeof ImportAlbumRoute
+ '/import/auto': typeof ImportAutoRoute
+ '/import/singles': typeof ImportSinglesRoute
+ '/import/': typeof ImportIndexRoute
'/artist-detail/$source/$id': typeof ArtistDetailSourceIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
+ fullPaths:
+ | '/'
+ | '/import'
+ | '/issues'
+ | '/stats'
+ | '/$'
+ | '/import/album'
+ | '/import/auto'
+ | '/import/singles'
+ | '/import/'
+ | '/artist-detail/$source/$id'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/issues' | '/stats' | '/$' | '/artist-detail/$source/$id'
- id:
- | '__root__'
+ to:
| '/'
| '/issues'
| '/stats'
| '/$'
+ | '/import/album'
+ | '/import/auto'
+ | '/import/singles'
+ | '/import'
+ | '/artist-detail/$source/$id'
+ id:
+ | '__root__'
+ | '/'
+ | '/import'
+ | '/issues'
+ | '/stats'
+ | '/$'
+ | '/import/album'
+ | '/import/auto'
+ | '/import/singles'
+ | '/import/'
| '/artist-detail/$source/$id'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ ImportRouteRoute: typeof ImportRouteRouteWithChildren
IssuesRouteRoute: typeof IssuesRouteRoute
StatsRouteRoute: typeof StatsRouteRoute
SplatRoute: typeof SplatRoute
@@ -108,6 +177,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IssuesRouteRouteImport
parentRoute: typeof rootRouteImport
}
+ '/import': {
+ id: '/import'
+ path: '/import'
+ fullPath: '/import'
+ preLoaderRoute: typeof ImportRouteRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -115,6 +191,34 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/import/': {
+ id: '/import/'
+ path: '/'
+ fullPath: '/import/'
+ preLoaderRoute: typeof ImportIndexRouteImport
+ parentRoute: typeof ImportRouteRoute
+ }
+ '/import/singles': {
+ id: '/import/singles'
+ path: '/singles'
+ fullPath: '/import/singles'
+ preLoaderRoute: typeof ImportSinglesRouteImport
+ parentRoute: typeof ImportRouteRoute
+ }
+ '/import/auto': {
+ id: '/import/auto'
+ path: '/auto'
+ fullPath: '/import/auto'
+ preLoaderRoute: typeof ImportAutoRouteImport
+ parentRoute: typeof ImportRouteRoute
+ }
+ '/import/album': {
+ id: '/import/album'
+ path: '/album'
+ fullPath: '/import/album'
+ preLoaderRoute: typeof ImportAlbumRouteImport
+ parentRoute: typeof ImportRouteRoute
+ }
'/artist-detail/$source/$id': {
id: '/artist-detail/$source/$id'
path: '/artist-detail/$source/$id'
@@ -125,8 +229,27 @@ declare module '@tanstack/react-router' {
}
}
+interface ImportRouteRouteChildren {
+ ImportAlbumRoute: typeof ImportAlbumRoute
+ ImportAutoRoute: typeof ImportAutoRoute
+ ImportSinglesRoute: typeof ImportSinglesRoute
+ ImportIndexRoute: typeof ImportIndexRoute
+}
+
+const ImportRouteRouteChildren: ImportRouteRouteChildren = {
+ ImportAlbumRoute: ImportAlbumRoute,
+ ImportAutoRoute: ImportAutoRoute,
+ ImportSinglesRoute: ImportSinglesRoute,
+ ImportIndexRoute: ImportIndexRoute,
+}
+
+const ImportRouteRouteWithChildren = ImportRouteRoute._addFileChildren(
+ ImportRouteRouteChildren,
+)
+
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ ImportRouteRoute: ImportRouteRouteWithChildren,
IssuesRouteRoute: IssuesRouteRoute,
StatsRouteRoute: StatsRouteRoute,
SplatRoute: SplatRoute,
diff --git a/webui/src/routes/import/-import.api.test.ts b/webui/src/routes/import/-import.api.test.ts
new file mode 100644
index 00000000..1963b1f7
--- /dev/null
+++ b/webui/src/routes/import/-import.api.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+
+import { HttpResponse, http, server } from '@/test/msw';
+
+import { approveAutoImportResult, rejectAutoImportResult } from './-import.api';
+
+const softFailureMessage = 'Item not found or not pending review';
+
+describe('import api', () => {
+ it('surfaces soft failures from auto-import approval endpoints', async () => {
+ server.use(
+ http.post('/api/auto-import/approve/17', () =>
+ HttpResponse.json({
+ success: false,
+ error: softFailureMessage,
+ }),
+ ),
+ http.post('/api/auto-import/reject/18', () =>
+ HttpResponse.json({
+ success: false,
+ error: softFailureMessage,
+ }),
+ ),
+ );
+
+ await expect(approveAutoImportResult(17)).rejects.toThrow(softFailureMessage);
+ await expect(rejectAutoImportResult(18)).rejects.toThrow(softFailureMessage);
+ });
+});
diff --git a/webui/src/routes/import/-import.api.ts b/webui/src/routes/import/-import.api.ts
new file mode 100644
index 00000000..8de7234d
--- /dev/null
+++ b/webui/src/routes/import/-import.api.ts
@@ -0,0 +1,235 @@
+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;
+
+export async function fetchImportStagingFiles(): Promise {
+ return readJson(apiClient.get('import/staging/files'));
+}
+
+export async function fetchImportStagingGroups(): Promise {
+ return readJson(apiClient.get('import/staging/groups'));
+}
+
+export async function fetchImportStagingSuggestions(): Promise {
+ return readJson(apiClient.get('import/staging/suggestions'));
+}
+
+export async function searchImportAlbums(query: string): Promise {
+ return readJson(
+ apiClient.get('import/search/albums', {
+ searchParams: {
+ q: query,
+ limit: '12',
+ },
+ }),
+ );
+}
+
+export async function matchImportAlbum(input: {
+ albumId: string;
+ source?: string | null;
+ albumName?: string | null;
+ albumArtist?: string | null;
+ filePaths?: string[] | null;
+}): Promise {
+ return readJson(
+ apiClient.post('import/album/match', {
+ json: {
+ album_id: input.albumId,
+ source: input.source || '',
+ album_name: input.albumName || '',
+ album_artist: input.albumArtist || '',
+ ...(input.filePaths?.length ? { file_paths: input.filePaths } : {}),
+ },
+ }),
+ );
+}
+
+export async function processImportAlbumTrack(input: {
+ album: ImportAlbum;
+ match: ImportAlbumMatch;
+}): Promise {
+ return readJson(
+ apiClient.post('import/album/process', {
+ json: {
+ album: input.album,
+ matches: [input.match],
+ },
+ }),
+ );
+}
+
+export async function searchImportTracks(query: string): Promise {
+ return readJson(
+ apiClient.get('import/search/tracks', {
+ searchParams: {
+ q: query,
+ limit: '6',
+ },
+ }),
+ );
+}
+
+export async function processImportSingleFile(file: unknown): Promise {
+ return readJson(
+ apiClient.post('import/singles/process', {
+ json: {
+ files: [file],
+ },
+ }),
+ );
+}
+
+export async function fetchAutoImportStatus(): Promise {
+ return readJson(apiClient.get('auto-import/status'));
+}
+
+export async function fetchAutoImportSettings(): Promise {
+ return readJson(apiClient.get('auto-import/settings'));
+}
+
+export async function saveAutoImportSettings(input: {
+ confidenceThreshold: number;
+ scanInterval: number;
+}): Promise {
+ await readJson<{ success: boolean; error?: string }>(
+ apiClient.post('auto-import/settings', {
+ json: {
+ confidence_threshold: input.confidenceThreshold,
+ scan_interval: input.scanInterval,
+ },
+ }),
+ );
+}
+
+export async function fetchAutoImportResults(): Promise {
+ return readJson(
+ apiClient.get('auto-import/results', {
+ searchParams: {
+ limit: '100',
+ },
+ }),
+ );
+}
+
+export async function toggleAutoImport(enabled: boolean): Promise {
+ await readJson<{ success: boolean; error?: string }>(
+ apiClient.post('auto-import/toggle', {
+ json: { enabled },
+ }),
+ );
+}
+
+export async function triggerAutoImportScan(): Promise {
+ await readJson<{ success: boolean; error?: string }>(apiClient.post('auto-import/scan-now'));
+}
+
+export async function approveAutoImportResult(id: number): Promise {
+ const payload = await readJson<{ success: boolean; error?: string }>(
+ apiClient.post(`auto-import/approve/${id}`),
+ );
+ if (!payload.success) {
+ throw new Error(payload.error || 'Failed to approve import');
+ }
+}
+
+export async function rejectAutoImportResult(id: number): Promise {
+ const payload = await readJson<{ success: boolean; error?: string }>(
+ apiClient.post(`auto-import/reject/${id}`),
+ );
+ if (!payload.success) {
+ throw new Error(payload.error || 'Failed to dismiss import');
+ }
+}
+
+export async function approveAllAutoImportResults(): Promise {
+ const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
+ apiClient.post('auto-import/approve-all'),
+ );
+ return payload.count ?? 0;
+}
+
+export async function clearCompletedAutoImportResults(): Promise {
+ const payload = await readJson<{ success: boolean; count?: number; error?: string }>(
+ apiClient.post('auto-import/clear-completed'),
+ );
+ return payload.count ?? 0;
+}
+
+export function importStagingFilesQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'staging-files'],
+ queryFn: fetchImportStagingFiles,
+ });
+}
+
+export function importStagingGroupsQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'],
+ queryFn: fetchImportStagingGroups,
+ });
+}
+
+export function importStagingSuggestionsQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'],
+ queryFn: fetchImportStagingSuggestions,
+ });
+}
+
+export function autoImportStatusQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'],
+ queryFn: fetchAutoImportStatus,
+ });
+}
+
+export function autoImportSettingsQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'],
+ queryFn: fetchAutoImportSettings,
+ });
+}
+
+export function autoImportResultsQueryOptions() {
+ return queryOptions({
+ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'],
+ queryFn: fetchAutoImportResults,
+ });
+}
+
+export function invalidateImportQueries(queryClient: QueryClient) {
+ return queryClient.invalidateQueries({ queryKey: IMPORT_QUERY_KEY });
+}
+
+export function invalidateImportStagingQueries(queryClient: QueryClient) {
+ return Promise.all([
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-files'] }),
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-groups'] }),
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'staging-suggestions'] }),
+ ]);
+}
+
+export function invalidateAutoImportQueries(queryClient: QueryClient) {
+ return Promise.all([
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-status'] }),
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-settings'] }),
+ queryClient.invalidateQueries({ queryKey: [...IMPORT_QUERY_KEY, 'auto-import-results'] }),
+ ]);
+}
diff --git a/webui/src/routes/import/-import.helpers.ts b/webui/src/routes/import/-import.helpers.ts
new file mode 100644
index 00000000..6f4f8003
--- /dev/null
+++ b/webui/src/routes/import/-import.helpers.ts
@@ -0,0 +1,344 @@
+import type {
+ ImportAlbumMatch,
+ ImportAutoFilter,
+ ImportAutoImportActiveItem,
+ ImportAutoImportMatchData,
+ ImportAutoImportResult,
+ ImportAutoImportStatusPayload,
+ ImportQueueEntry,
+ ImportStagingFile,
+} from './-import.types';
+
+export const IMPORT_PLACEHOLDER_IMAGE = '/static/placeholder.png';
+
+const IMPORT_SOURCE_LABELS: Record = {
+ amazon: 'Amazon Music',
+ deezer: 'Deezer',
+ discogs: 'Discogs',
+ hydrabase: 'Hydrabase',
+ itunes: 'Apple Music',
+ musicbrainz: 'MusicBrainz',
+ playlist: 'Playlist',
+ soulseek: 'Basic Search',
+ spotify: 'Spotify',
+ youtube_videos: 'Music Videos',
+};
+
+export function getStagingFileKey(file: ImportStagingFile): string {
+ return file.full_path;
+}
+
+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 getImportSourceLabel(source: string | null | undefined): string {
+ if (!source) return '';
+ return IMPORT_SOURCE_LABELS[source.toLowerCase()] || source;
+}
+
+/**
+ * Label a fallback result row so it is obvious which provider actually returned it.
+ */
+export function getImportSourceBadgeText(
+ resultSource: string | null | undefined,
+ lookupSource: string | null | undefined,
+): string {
+ if (!resultSource || !lookupSource) return '';
+ if (resultSource.toLowerCase() === lookupSource.toLowerCase()) return '';
+ return `via ${getImportSourceLabel(resultSource)}`;
+}
+
+/**
+ * Banner for a whole result set that came from a fallback provider rather than the lookup source.
+ */
+export function getImportSourceFallbackBanner(
+ results: Array<{ source: string }> | null | undefined,
+ lookupSource: string | null | undefined,
+): string {
+ if (!lookupSource || !results?.length) return '';
+ const normalizedLookupSource = lookupSource.toLowerCase();
+ if (
+ !results.every(
+ (result) => result.source && result.source.toLowerCase() !== normalizedLookupSource,
+ )
+ ) {
+ return '';
+ }
+
+ const resultSource = results[0]?.source;
+ if (!resultSource) return '';
+
+ return `Showing ${getImportSourceLabel(resultSource)} results - not from your primary source (${getImportSourceLabel(lookupSource)}).`;
+}
+
+export function getTrackDisplayInfo(match: ImportAlbumMatch, index: number) {
+ const track = match.track || match.spotify_track || {};
+ const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
+ const trackNumber =
+ rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === ''
+ ? null
+ : String(rawTrackNumber).split('/')[0]?.trim() || null;
+
+ return {
+ track,
+ name: track.name || track.title || `Track ${index + 1}`,
+ trackNumber,
+ displayTrackNumber: trackNumber || String(index + 1),
+ };
+}
+
+export function getEffectiveAlbumMatches(
+ matches: ImportAlbumMatch[],
+ stagingFiles: ImportStagingFile[],
+ overrides: Record,
+): ImportAlbumMatch[] {
+ return matches.flatMap((match, index) => {
+ if (Object.hasOwn(overrides, index)) {
+ const override = overrides[index];
+ if (override === -1) return [];
+ const stagingFile = stagingFiles[override];
+ return stagingFile ? [{ ...match, staging_file: stagingFile, confidence: 1 }] : [];
+ }
+ return match.staging_file ? [match] : [];
+ });
+}
+
+export function getDisplayedMatchFile(
+ match: ImportAlbumMatch,
+ index: number,
+ stagingFiles: ImportStagingFile[],
+ overrides: Record,
+): { file: ImportStagingFile | null; confidence: number; isOverride: boolean } {
+ if (Object.hasOwn(overrides, index)) {
+ const override = overrides[index];
+ if (override === -1) return { file: null, confidence: match.confidence, isOverride: false };
+ return {
+ file: stagingFiles[override] ?? null,
+ confidence: 1,
+ isOverride: true,
+ };
+ }
+
+ if (!match.staging_file) {
+ return { file: null, confidence: match.confidence, isOverride: false };
+ }
+
+ const autoFileName = match.staging_file.filename;
+ const reassigned = Object.entries(overrides).some(([trackIndex, stagingFileIndex]) => {
+ const file = stagingFiles[stagingFileIndex];
+ return file && file.filename === autoFileName && Number(trackIndex) !== index;
+ });
+
+ return {
+ file: reassigned ? null : match.staging_file,
+ confidence: match.confidence,
+ isOverride: false,
+ };
+}
+
+export function getUnmatchedStagingFiles(
+ matches: ImportAlbumMatch[],
+ stagingFiles: ImportStagingFile[],
+ overrides: Record,
+): Array<{ file: ImportStagingFile; index: number }> {
+ return stagingFiles.flatMap((file, index) => {
+ if (Object.values(overrides).includes(index)) return [];
+
+ const autoUsed = matches.some((match, matchIndex) => {
+ if (Object.hasOwn(overrides, matchIndex)) return false;
+ return match.staging_file?.filename === file.filename;
+ });
+
+ return autoUsed ? [] : [{ file, index }];
+ });
+}
+
+export function getAutoImportCounts(results: ImportAutoImportResult[]) {
+ return {
+ imported: results.filter(
+ (result) => result.status === 'completed' || result.status === 'approved',
+ ).length,
+ review: results.filter((result) => result.status === 'pending_review').length,
+ failed: results.filter(
+ (result) => result.status === 'failed' || result.status === 'needs_identification',
+ ).length,
+ };
+}
+
+export function filterAutoImportResults(
+ results: ImportAutoImportResult[],
+ filter: ImportAutoFilter,
+): ImportAutoImportResult[] {
+ if (filter === 'pending') return results.filter((result) => result.status === 'pending_review');
+ if (filter === 'imported') {
+ return results.filter(
+ (result) => result.status === 'completed' || result.status === 'approved',
+ );
+ }
+ if (filter === 'failed') {
+ return results.filter(
+ (result) => result.status === 'failed' || result.status === 'needs_identification',
+ );
+ }
+ return results;
+}
+
+export function getAutoImportStatusText(status: ImportAutoImportStatusPayload | undefined): string {
+ if (!status) return 'Loading...';
+ if (status.paused) return 'Paused';
+ if (status.current_status === 'processing') return 'Processing...';
+ if (status.current_status === 'scanning') return 'Scanning...';
+ if (!status.running) return 'Disabled';
+
+ if (status.last_scan_time) {
+ const lastScan = new Date(status.last_scan_time);
+ const diffSeconds = Math.floor((Date.now() - lastScan.getTime()) / 1000);
+ if (Number.isFinite(diffSeconds) && diffSeconds >= 0 && diffSeconds < 60) {
+ return `Watching (scanned ${diffSeconds}s ago)`;
+ }
+ if (Number.isFinite(diffSeconds) && diffSeconds < 3600) {
+ return `Watching (scanned ${Math.floor(diffSeconds / 60)}m ago)`;
+ }
+ }
+
+ return 'Watching';
+}
+
+export type AutoImportStatusTone = 'neutral' | 'info' | 'success';
+
+export function getAutoImportStatusTone(
+ status: ImportAutoImportStatusPayload | undefined,
+): AutoImportStatusTone {
+ if (!status?.running || status.paused) return 'neutral';
+ if (status.current_status === 'scanning' || status.current_status === 'processing') return 'info';
+ return 'success';
+}
+
+export function getActiveImportLines(status: ImportAutoImportStatusPayload | undefined): string[] {
+ const active = Array.isArray(status?.active_imports) ? status.active_imports : [];
+ if (active.length > 0) return active.map(getActiveImportLine);
+ if (status?.current_status === 'scanning') {
+ return [`Scanning... (${status.stats?.scanned || 0} processed)`];
+ }
+ return [];
+}
+
+function getActiveImportLine(item: ImportAutoImportActiveItem): string {
+ const folder = item.folder_name || '...';
+ const trackIndex = item.track_index || 0;
+ const trackTotal = item.track_total || 0;
+ const trackName = item.track_name || '';
+ if (item.status === 'processing' && trackTotal > 0) {
+ return `${folder} - track ${trackIndex}/${trackTotal}: ${trackName}`;
+ }
+ if (item.status === 'matching') return `${folder} - matching tracks...`;
+ if (item.status === 'identifying') return `${folder} - identifying...`;
+ return `${folder} - queued`;
+}
+
+export function parseAutoImportMatchData(
+ matchData: ImportAutoImportResult['match_data'],
+): ImportAutoImportMatchData {
+ if (!matchData) return {};
+ if (typeof matchData === 'object') return matchData;
+ try {
+ const parsed = JSON.parse(matchData) as ImportAutoImportMatchData;
+ return parsed && typeof parsed === 'object' ? parsed : {};
+ } catch {
+ return {};
+ }
+}
+
+export function getAutoImportTimeAgo(createdAt: string | null | undefined): string {
+ if (!createdAt) return '';
+ const created = new Date(createdAt);
+ const diffMinutes = Math.floor((Date.now() - created.getTime()) / 60_000);
+ if (!Number.isFinite(diffMinutes) || diffMinutes < 0) return '';
+ if (diffMinutes < 1) return 'just now';
+ if (diffMinutes < 60) return `${diffMinutes}m ago`;
+ if (diffMinutes < 1440) return `${Math.floor(diffMinutes / 60)}h ago`;
+ return `${Math.floor(diffMinutes / 1440)}d ago`;
+}
+
+export function getConfidenceClass(confidencePercent: number): 'high' | 'medium' | 'low' {
+ if (confidencePercent >= 90) return 'high';
+ if (confidencePercent >= 70) return 'medium';
+ return 'low';
+}
+
+export function getAutoImportStatusMeta(status: string): {
+ label: string;
+ icon: string;
+ className: 'completed' | 'review' | 'failed' | 'processing' | 'neutral';
+} {
+ const labels: Record = {
+ completed: 'Imported',
+ pending_review: 'Needs Review',
+ needs_identification: 'Unidentified',
+ failed: 'Failed',
+ scanning: 'Scanning...',
+ matched: 'Matched',
+ rejected: 'Dismissed',
+ approved: 'Approved',
+ processing: 'Processing',
+ };
+
+ const icons: Record = {
+ completed: '✓',
+ pending_review: '⚠',
+ needs_identification: '✗',
+ failed: '✗',
+ scanning: '⌛',
+ matched: '✓',
+ rejected: '✕',
+ approved: '✓',
+ processing: '⧗',
+ };
+
+ return {
+ label: labels[status] || status,
+ icon: icons[status] || '',
+ className:
+ status === 'completed'
+ ? 'completed'
+ : status === 'pending_review'
+ ? 'review'
+ : status === 'failed' || status === 'needs_identification'
+ ? 'failed'
+ : status === 'processing'
+ ? 'processing'
+ : 'neutral',
+ };
+}
+
+export function getQueueProgressPercent(entry: ImportQueueEntry): number {
+ if (entry.status === 'done' || entry.status === 'error') return 100;
+ if (entry.total <= 0) return 0;
+ return Math.round((entry.processed / entry.total) * 100);
+}
+
+export function getQueueStatusText(entry: ImportQueueEntry): string {
+ if (entry.status === 'running') return `${entry.processed}/${entry.total}`;
+ if (entry.status === 'done') {
+ return entry.errors.length > 0
+ ? `${entry.processed}/${entry.total} (${entry.errors.length} err)`
+ : 'Done';
+ }
+ return 'Failed';
+}
+
+export function formatDuration(durationMs: number | null | undefined): string {
+ if (!durationMs) return '';
+ const minutes = Math.floor(durationMs / 60_000);
+ const seconds = String(Math.floor((durationMs % 60_000) / 1000)).padStart(2, '0');
+ return `${minutes}:${seconds}`;
+}
diff --git a/webui/src/routes/import/-import.store.ts b/webui/src/routes/import/-import.store.ts
new file mode 100644
index 00000000..f6bb892b
--- /dev/null
+++ b/webui/src/routes/import/-import.store.ts
@@ -0,0 +1,267 @@
+import { create } from 'zustand';
+import { combine } from 'zustand/middleware';
+import { useShallow } from 'zustand/react/shallow';
+
+import type {
+ ImportAlbumMatchPayload,
+ ImportAlbumResult,
+ ImportQueueEntry,
+ ImportQueueJob,
+ ImportStagingFile,
+ ImportTrackResult,
+} from './-import.types';
+
+import { getStagingFileKey } from './-import.helpers';
+
+export type SingleSearchState = {
+ query: string;
+ loading: boolean;
+ error: string | null;
+ results: ImportTrackResult[];
+};
+
+type StateUpdater = T | ((current: T) => T);
+
+function resolveState(current: T, updater: StateUpdater) {
+ return typeof updater === 'function' ? (updater as (value: T) => T)(current) : updater;
+}
+
+function createInitialWorkflowState() {
+ return {
+ queue: [] as ImportQueueEntry[],
+ nextQueueId: 0,
+ albumQuery: '',
+ albumResults: null as ImportAlbumResult[] | null,
+ albumSearchError: null as string | null,
+ albumSearchLoading: false,
+ // Lookup source used to seed the album search. Result rows still carry their own `source`.
+ albumSearchLookupSource: null as string | null,
+ autoGroupFilePaths: null as string[] | null,
+ selectedAlbum: null as ImportAlbumResult | null,
+ albumMatch: null as ImportAlbumMatchPayload | null,
+ albumMatchError: null as string | null,
+ albumMatchLoading: false,
+ matchOverrides: {} as Record,
+ selectedSingles: new Set(),
+ singlesManualMatches: {} as Record,
+ openSingleSearch: null as string | null,
+ singleSearches: {} as Record,
+ };
+}
+
+export const useImportWorkflowStore = create(
+ combine(createInitialWorkflowState(), (set, get) => ({
+ clearFinishedJobs: () => {
+ set((state) => ({ queue: state.queue.filter((entry) => entry.status === 'running') }));
+ },
+ enqueueQueueJob: (job: ImportQueueJob) => {
+ const id = get().nextQueueId + 1;
+ const entry: ImportQueueEntry = {
+ id,
+ type: job.type,
+ label: job.label,
+ sublabel: job.sublabel,
+ imageUrl: job.imageUrl,
+ status: 'running',
+ processed: 0,
+ total: job.items.length,
+ errors: [],
+ };
+
+ set((state) => ({
+ nextQueueId: id,
+ queue: [...state.queue, entry],
+ }));
+ return id;
+ },
+ updateQueueEntry: (entryId: number, patch: Partial) => {
+ set((state) => ({
+ queue: state.queue.map((entry) => (entry.id === entryId ? { ...entry, ...patch } : entry)),
+ }));
+ },
+ resetAlbumSearch: () => {
+ set({
+ albumQuery: '',
+ albumResults: null,
+ albumSearchError: null,
+ albumSearchLoading: false,
+ albumSearchLookupSource: null,
+ 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 }),
+ setAlbumSearchLookupSource: (albumSearchLookupSource: string | null) =>
+ set({ albumSearchLookupSource }),
+ setAlbumSearchContext: (albumQuery: string, autoGroupFilePaths: string[] | null) => {
+ set({
+ albumQuery,
+ albumSearchLoading: true,
+ albumSearchError: null,
+ albumResults: null,
+ albumSearchLookupSource: null,
+ selectedAlbum: null,
+ albumMatch: null,
+ autoGroupFilePaths,
+ });
+ },
+ setSelectedAlbum: (selectedAlbum: ImportAlbumResult | null) => set({ selectedAlbum }),
+ setAlbumMatch: (albumMatch: ImportAlbumMatchPayload | null) => set({ albumMatch }),
+ setAlbumMatchError: (albumMatchError: string | null) => set({ albumMatchError }),
+ setAlbumMatchLoading: (albumMatchLoading: boolean) => set({ albumMatchLoading }),
+ clearAutoGroupFilePaths: () => set({ autoGroupFilePaths: null }),
+ setMatchOverrides: (updater: StateUpdater>) => {
+ set((state) => ({ matchOverrides: resolveState(state.matchOverrides, updater) }));
+ },
+ toggleSingle: (fileKey: string) => {
+ set((state) => {
+ const selectedSingles = new Set(state.selectedSingles);
+ if (selectedSingles.has(fileKey)) selectedSingles.delete(fileKey);
+ else selectedSingles.add(fileKey);
+ return { selectedSingles };
+ });
+ },
+ toggleAllSingles: (stagingFiles: ImportStagingFile[]) => {
+ set((state) => ({
+ selectedSingles: (() => {
+ const fileKeys = stagingFiles.map(getStagingFileKey);
+ return state.selectedSingles.size === fileKeys.length &&
+ fileKeys.every((key) => state.selectedSingles.has(key))
+ ? new Set()
+ : new Set(fileKeys);
+ })(),
+ }));
+ },
+ clearSinglesSelection: () => {
+ set({
+ selectedSingles: new Set(),
+ singlesManualMatches: {},
+ });
+ },
+ syncSinglesWorkflow: (stagingFiles: ImportStagingFile[]) => {
+ const validKeys = new Set(stagingFiles.map(getStagingFileKey));
+ set((state) => ({
+ selectedSingles: new Set([...state.selectedSingles].filter((key) => validKeys.has(key))),
+ singlesManualMatches: Object.fromEntries(
+ Object.entries(state.singlesManualMatches).filter(([key]) => validKeys.has(key)),
+ ),
+ openSingleSearch:
+ state.openSingleSearch && validKeys.has(state.openSingleSearch)
+ ? state.openSingleSearch
+ : null,
+ singleSearches: Object.fromEntries(
+ Object.entries(state.singleSearches).filter(([key]) => validKeys.has(key)),
+ ),
+ }));
+ },
+ setOpenSingleSearch: (openSingleSearch: string | null) => set({ openSingleSearch }),
+ ensureSingleSearch: (fileKey: string, query: string) => {
+ set((state) => ({
+ singleSearches: {
+ ...state.singleSearches,
+ [fileKey]: state.singleSearches[fileKey] ?? {
+ query,
+ loading: false,
+ error: null,
+ results: [],
+ },
+ },
+ }));
+ },
+ setSingleSearch: (fileKey: string, updater: StateUpdater) => {
+ set((state) => {
+ const current = state.singleSearches[fileKey] ?? {
+ query: '',
+ loading: false,
+ error: null,
+ results: [],
+ };
+ return {
+ singleSearches: {
+ ...state.singleSearches,
+ [fileKey]: resolveState(current, updater),
+ },
+ };
+ });
+ },
+ selectSingleMatch: (fileKey: string, track: ImportTrackResult) => {
+ set((state) => ({
+ singlesManualMatches: { ...state.singlesManualMatches, [fileKey]: track },
+ selectedSingles: new Set(state.selectedSingles).add(fileKey),
+ 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,
+ albumSearchLookupSource: state.albumSearchLookupSource,
+ 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,
+ setAlbumSearchLookupSource: state.setAlbumSearchLookupSource,
+ 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,
+ syncSinglesWorkflow: state.syncSinglesWorkflow,
+ toggleAllSingles: state.toggleAllSingles,
+ toggleSingleInStore: state.toggleSingle,
+ })),
+ );
+}
diff --git a/webui/src/routes/import/-import.types.ts b/webui/src/routes/import/-import.types.ts
new file mode 100644
index 00000000..a6298593
--- /dev/null
+++ b/webui/src/routes/import/-import.types.ts
@@ -0,0 +1,243 @@
+import { z } from 'zod';
+
+export const IMPORT_AUTO_FILTER_VALUES = ['all', 'pending', 'imported', 'failed'] as const;
+export type ImportAutoFilter = (typeof IMPORT_AUTO_FILTER_VALUES)[number];
+
+export const importAutoSearchSchema = z.object({
+ autoFilter: z.enum(IMPORT_AUTO_FILTER_VALUES).default('all').catch('all'),
+});
+
+export type ImportAutoSearch = z.infer;
+
+export interface ImportStagingFile {
+ filename: string;
+ rel_path?: string;
+ full_path: string;
+ title?: string | null;
+ artist?: string | null;
+ album?: string | null;
+ track_number?: string | number | null;
+ disc_number?: string | number | null;
+ extension?: string | null;
+ size?: number | null;
+ manual_match?: ImportTrackResult;
+}
+
+export interface ImportStagingFilesPayload {
+ success: boolean;
+ files?: ImportStagingFile[];
+ staging_path?: string;
+ error?: string;
+}
+
+export interface ImportStagingGroup {
+ album: string;
+ artist: string;
+ file_count: number;
+ files?: Array<{
+ filename: string;
+ full_path: string;
+ title?: string | null;
+ track_number?: string | number | null;
+ }>;
+ file_paths: string[];
+}
+
+export interface ImportStagingGroupsPayload {
+ success: boolean;
+ groups?: ImportStagingGroup[];
+ error?: string;
+}
+
+export interface ImportAlbumResult {
+ id: string;
+ name: string;
+ artist: string;
+ /** Provider that returned this result row. */
+ source: string;
+ image_url?: string | null;
+ total_tracks?: number | null;
+ release_date?: string | null;
+ format?: string | null;
+ country?: string | null;
+ disambiguation?: string | null;
+ status?: string | null;
+ label?: string | null;
+}
+
+export interface ImportAlbumSearchPayload {
+ success: boolean;
+ albums?: ImportAlbumResult[];
+ suggestions?: ImportAlbumResult[];
+ /** Provider used to seed the lookup chain for this response. */
+ primary_source?: string | null;
+ ready?: boolean;
+ error?: string;
+}
+
+export interface ImportTrackResult {
+ id: string;
+ name: string;
+ artist: string;
+ album?: string | null;
+ /** Provider that returned this result row. */
+ source: string;
+ image_url?: string | null;
+ duration_ms?: number | null;
+}
+
+export interface ImportTrackSearchPayload {
+ success: boolean;
+ tracks?: ImportTrackResult[];
+ /** Provider used to seed the lookup chain for this response. */
+ primary_source?: string | null;
+ error?: string;
+}
+
+export interface ImportAlbum {
+ id?: string | number | null;
+ name: string;
+ artist: string;
+ /** Provider used to resolve this selected album. */
+ source: string;
+ image_url?: string | null;
+ total_tracks?: number | null;
+ release_date?: string | null;
+ format?: string | null;
+ country?: string | null;
+ disambiguation?: string | null;
+ status?: string | null;
+ label?: string | null;
+}
+
+export interface ImportAlbumTrack {
+ id?: string | number | null;
+ name?: string | null;
+ title?: string | null;
+ track_number?: string | number | null;
+ trackNumber?: string | number | null;
+ disc_number?: number | null;
+}
+
+export interface ImportAlbumMatch {
+ track?: ImportAlbumTrack | null;
+ spotify_track?: ImportAlbumTrack | null;
+ staging_file?: ImportStagingFile | null;
+ confidence: number;
+}
+
+export interface ImportAlbumMatchPayload {
+ success: boolean;
+ album?: ImportAlbum;
+ matches?: ImportAlbumMatch[];
+ error?: string;
+}
+
+export interface ImportProcessPayload {
+ success: boolean;
+ processed?: number;
+ total?: number;
+ errors?: string[];
+ error?: string;
+}
+
+export interface ImportAutoImportActiveItem {
+ folder_hash?: string | null;
+ folder_name?: string | null;
+ status?: string | null;
+ track_index?: number | null;
+ track_total?: number | null;
+ track_name?: string | null;
+}
+
+export interface ImportAutoImportStatusPayload {
+ success: boolean;
+ running?: boolean;
+ paused?: boolean;
+ current_status?: string | null;
+ last_scan_time?: string | null;
+ active_imports?: ImportAutoImportActiveItem[];
+ stats?: {
+ scanned?: number;
+ auto_processed?: number;
+ pending_review?: number;
+ failed?: number;
+ };
+ error?: string;
+}
+
+export interface ImportAutoImportSettingsPayload {
+ success: boolean;
+ enabled?: boolean;
+ scan_interval?: number;
+ confidence_threshold?: number;
+ auto_process?: boolean;
+ error?: string;
+}
+
+export interface ImportAutoImportMatchData {
+ matched_count?: number;
+ total_tracks?: number;
+ matches?: Array<{
+ track_name?: string | null;
+ track?: { name?: string | null };
+ file?: string | null;
+ confidence?: number | null;
+ }>;
+}
+
+export interface ImportAutoImportResult {
+ id: number;
+ status: string;
+ folder_hash?: string | null;
+ folder_name: string;
+ album_name?: string | null;
+ artist_name?: string | null;
+ image_url?: string | null;
+ confidence?: number | null;
+ total_files?: number | null;
+ identification_method?: string | null;
+ match_data?: string | ImportAutoImportMatchData | null;
+ error_message?: string | null;
+ created_at?: string | null;
+}
+
+export interface ImportAutoImportResultsPayload {
+ success: boolean;
+ results?: ImportAutoImportResult[];
+ error?: string;
+}
+
+export type ImportQueueStatus = 'running' | 'done' | 'error';
+export type ImportQueueJobType = 'album' | 'singles';
+
+export interface ImportQueueEntry {
+ id: number;
+ type: ImportQueueJobType;
+ label: string;
+ sublabel: string;
+ imageUrl?: string | null;
+ status: ImportQueueStatus;
+ processed: number;
+ total: number;
+ errors: string[];
+}
+
+export interface ImportAlbumQueueJob {
+ type: 'album';
+ label: string;
+ sublabel: string;
+ imageUrl?: string | null;
+ items: ImportAlbumMatch[];
+ albumData: ImportAlbum;
+}
+
+export interface ImportSinglesQueueJob {
+ type: 'singles';
+ label: string;
+ sublabel: string;
+ imageUrl?: string | null;
+ items: ImportStagingFile[];
+}
+
+export type ImportQueueJob = ImportAlbumQueueJob | ImportSinglesQueueJob;
diff --git a/webui/src/routes/import/-route.test.tsx b/webui/src/routes/import/-route.test.tsx
new file mode 100644
index 00000000..b8572b95
--- /dev/null
+++ b/webui/src/routes/import/-route.test.tsx
@@ -0,0 +1,414 @@
+import { createMemoryHistory } from '@tanstack/react-router';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { createAppQueryClient } from '@/app/query-client';
+import { AppRouterProvider, createAppRouter } from '@/app/router';
+import { HttpResponse, http, server } from '@/test/msw';
+import { createShellBridge } from '@/test/shell-bridge';
+
+import type { ImportStagingFile } from './-import.types';
+
+import { autoImportResultsQueryOptions, autoImportStatusQueryOptions } from './-import.api';
+import { resetImportWorkflowStore } from './-import.store';
+
+function renderImportRoute(initialEntries = ['/import']) {
+ const queryClient = createAppQueryClient();
+ const history = createMemoryHistory({ initialEntries });
+ const router = createAppRouter({ history, queryClient });
+
+ return {
+ history,
+ router,
+ queryClient,
+ ...render( ),
+ };
+}
+
+function getFetchUrls() {
+ return vi
+ .mocked(fetch)
+ .mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
+}
+
+describe('import route', () => {
+ let albumMatchBodies: Record[];
+ let stagingFilesPayload: ImportStagingFile[];
+
+ beforeEach(() => {
+ albumMatchBodies = [];
+ stagingFilesPayload = [
+ {
+ 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',
+ },
+ {
+ filename: '02-track.flac',
+ rel_path: 'Album/02-track.flac',
+ full_path: '/music/Staging/Album/02-track.flac',
+ title: 'Track Two',
+ artist: 'Artist A',
+ album: 'Album A',
+ extension: '.flac',
+ },
+ ];
+ resetImportWorkflowStore();
+ window.SoulSyncWebShellBridge = createShellBridge();
+ window.showToast = vi.fn();
+ window.showConfirmDialog = vi.fn(async () => true);
+ vi.spyOn(globalThis, 'fetch');
+
+ server.use(
+ http.get('/api/import/staging/files', () => {
+ return HttpResponse.json({
+ success: true,
+ staging_path: '/music/Staging',
+ files: stagingFilesPayload,
+ });
+ }),
+ http.get('/api/import/staging/groups', () => {
+ return HttpResponse.json({
+ success: true,
+ groups: [
+ {
+ album: 'Album A',
+ artist: 'Artist A',
+ file_count: 2,
+ file_paths: ['/music/Staging/Album/01-track.flac'],
+ },
+ ],
+ });
+ }),
+ http.get('/api/import/staging/suggestions', () => {
+ return HttpResponse.json({
+ success: true,
+ ready: true,
+ primary_source: 'spotify',
+ suggestions: [
+ {
+ id: 'album-1',
+ name: 'Album A',
+ artist: 'Artist A',
+ source: 'deezer',
+ total_tracks: 1,
+ release_date: '2026-01-01',
+ format: 'CD',
+ country: 'US',
+ disambiguation: '25th Anniversary Edition',
+ status: 'official',
+ label: 'MusicBrainz',
+ },
+ ],
+ });
+ }),
+ http.get('/api/import/search/albums', () => {
+ return HttpResponse.json({
+ success: true,
+ primary_source: 'spotify',
+ albums: [
+ {
+ id: 'album-1',
+ name: 'Album A',
+ artist: 'Artist A',
+ source: 'deezer',
+ total_tracks: 1,
+ release_date: '2026-01-01',
+ format: 'CD',
+ country: 'US',
+ disambiguation: '25th Anniversary Edition',
+ status: 'official',
+ label: 'MusicBrainz',
+ },
+ ],
+ });
+ }),
+ http.post('/api/import/album/match', async ({ request }) => {
+ const body = (await request.json()) as Record;
+ albumMatchBodies.push(body);
+ return HttpResponse.json({
+ success: true,
+ received: body,
+ album: {
+ id: 'album-1',
+ name: 'Album A',
+ artist: 'Artist A',
+ source: 'deezer',
+ total_tracks: 1,
+ release_date: '2026-01-01',
+ format: 'CD',
+ country: 'US',
+ disambiguation: '25th Anniversary Edition',
+ status: 'official',
+ label: 'MusicBrainz',
+ },
+ 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,
+ },
+ ],
+ });
+ }),
+ http.get('/api/auto-import/status', () => {
+ return HttpResponse.json({
+ success: true,
+ running: true,
+ current_status: 'idle',
+ active_imports: [],
+ });
+ }),
+ http.get('/api/auto-import/settings', () => {
+ return HttpResponse.json({
+ success: true,
+ scan_interval: 60,
+ confidence_threshold: 0.9,
+ });
+ }),
+ http.get('/api/auto-import/results', () => {
+ return HttpResponse.json({
+ 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,
+ },
+ ],
+ });
+ }),
+ http.get('/api/issues/counts', () => {
+ return HttpResponse.json({
+ success: true,
+ counts: {
+ open: 0,
+ in_progress: 0,
+ resolved: 0,
+ dismissed: 0,
+ total: 0,
+ },
+ });
+ }),
+ );
+ });
+
+ 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();
+ expect(
+ await screen.findByText('1 tracks · 2026 · CD · US · 25th Anniversary Edition'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('official · MusicBrainz')).toBeInTheDocument();
+ expect(
+ await screen.findByText('Showing Deezer results - not from your primary source (Spotify).'),
+ ).toBeInTheDocument();
+ expect(screen.getByText('via Deezer')).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('keeps the import page rendering when staging files fail to load', async () => {
+ server.use(
+ http.get('/api/import/staging/files', () =>
+ HttpResponse.json(
+ {
+ success: false,
+ error: 'Import folder unavailable',
+ },
+ { status: 500 },
+ ),
+ ),
+ );
+
+ renderImportRoute();
+
+ expect(await screen.findByTestId('import-page')).toBeInTheDocument();
+ expect(await screen.findByText('Import Music')).toBeInTheDocument();
+ expect(await screen.findByText('Import folder: error')).toBeInTheDocument();
+ });
+
+ 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.getByRole('button', { name: /Process Selected\s*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('keeps singles selection tied to file identity across refreshes', async () => {
+ renderImportRoute(['/import/singles']);
+
+ const secondTrack = await screen.findByLabelText('Select 02-track.flac');
+ fireEvent.click(secondTrack);
+
+ stagingFilesPayload = [
+ {
+ filename: '00-intro.flac',
+ rel_path: 'Album/00-intro.flac',
+ full_path: '/music/Staging/Album/00-intro.flac',
+ title: 'Intro',
+ artist: 'Artist A',
+ album: 'Album A',
+ extension: '.flac',
+ },
+ ...stagingFilesPayload,
+ ];
+
+ fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
+
+ await waitFor(() =>
+ expect(screen.getByRole('checkbox', { name: 'Select 02-track.flac' })).toBeChecked(),
+ );
+ expect(screen.getByRole('checkbox', { name: 'Select 01-track.flac' })).not.toBeChecked();
+ expect(screen.getByRole('button', { name: /Process Selected\s*1/ })).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('surfaces the served source when album search falls back', async () => {
+ server.use(
+ http.get('/api/import/search/albums', () => {
+ return HttpResponse.json({
+ success: true,
+ primary_source: 'spotify',
+ albums: [
+ {
+ id: 'album-2',
+ name: 'Album A',
+ artist: 'Artist A',
+ source: 'musicbrainz',
+ total_tracks: 1,
+ release_date: '2026-01-01',
+ },
+ ],
+ });
+ }),
+ );
+
+ renderImportRoute();
+
+ const searchInput = await screen.findByPlaceholderText('Search for an album...');
+ fireEvent.change(searchInput, { target: { value: 'Album A' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Search' }));
+
+ expect(
+ await screen.findByText(
+ 'Showing MusicBrainz results - not from your primary source (Spotify).',
+ ),
+ ).toBeInTheDocument();
+ expect(screen.getByText('via MusicBrainz')).toBeInTheDocument();
+ });
+
+ it('renders auto-import results from route search state', async () => {
+ renderImportRoute(['/import/auto?autoFilter=pending']);
+
+ expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
+ expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
+ expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success');
+ expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false);
+ expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
+ false,
+ );
+ });
+
+ it('keeps cached auto-import status visible when a refetch fails', async () => {
+ const { queryClient } = renderImportRoute(['/import/auto']);
+
+ expect(await screen.findByText('Watching')).toBeInTheDocument();
+
+ server.use(
+ http.get('/api/auto-import/status', () =>
+ HttpResponse.json(
+ {
+ success: false,
+ error: 'Auto-import unavailable',
+ },
+ { status: 500 },
+ ),
+ ),
+ );
+
+ await queryClient.refetchQueries({
+ queryKey: autoImportStatusQueryOptions().queryKey,
+ });
+
+ expect(screen.getByText('Watching')).toBeInTheDocument();
+ expect(screen.queryByText(/Auto-import is unavailable:/)).not.toBeInTheDocument();
+ });
+
+ it('keeps cached auto-import results visible when a refetch fails', async () => {
+ const { queryClient } = renderImportRoute(['/import/auto?autoFilter=pending']);
+
+ expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
+ expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
+
+ server.use(
+ http.get('/api/auto-import/results', () =>
+ HttpResponse.json(
+ {
+ success: false,
+ error: 'Auto-import results unavailable',
+ },
+ { status: 500 },
+ ),
+ ),
+ );
+
+ await queryClient.refetchQueries({
+ queryKey: autoImportResultsQueryOptions().queryKey,
+ });
+
+ expect(screen.getByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
+ expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
+ expect(screen.queryByText(/Failed to load imports:/)).not.toBeInTheDocument();
+ });
+});
diff --git a/webui/src/routes/import/-ui/album-import-tab.tsx b/webui/src/routes/import/-ui/album-import-tab.tsx
new file mode 100644
index 00000000..631907e6
--- /dev/null
+++ b/webui/src/routes/import/-ui/album-import-tab.tsx
@@ -0,0 +1,644 @@
+import { useQuery } from '@tanstack/react-query';
+import clsx from 'clsx';
+import { type DragEvent, type KeyboardEvent, useState } from 'react';
+
+import { Button, TextInput } from '@/components/form/form';
+import { Notice } from '@/components/primitives';
+
+import type { ImportAlbumResult } from '../-import.types';
+
+import {
+ importStagingGroupsQueryOptions,
+ importStagingSuggestionsQueryOptions,
+ matchImportAlbum,
+ searchImportAlbums,
+} from '../-import.api';
+import {
+ getDisplayedMatchFile,
+ getEffectiveAlbumMatches,
+ getImportSourceBadgeText,
+ getImportSourceFallbackBanner,
+ getTrackDisplayInfo,
+ getUnmatchedStagingFiles,
+ IMPORT_PLACEHOLDER_IMAGE,
+} from '../-import.helpers';
+import { useAlbumImportWorkflow } from '../-import.store';
+import styles from './import-page.module.css';
+import {
+ fallbackImage,
+ getErrorMessage,
+ useImportQueueActions,
+ useImportStaging,
+} from './import-shared';
+
+function useAlbumImportViewModel() {
+ const { refreshStaging, stagingFiles } = useImportStaging();
+ const [dragOverTrack, setDragOverTrack] = useState(null);
+ const [tapSelectedChip, setTapSelectedChip] = useState(null);
+ const groupsQuery = useQuery({
+ ...importStagingGroupsQueryOptions(),
+ });
+ const suggestionsQuery = useQuery({
+ ...importStagingSuggestionsQueryOptions(),
+ });
+ const { addQueueJob } = useImportQueueActions();
+ const {
+ albumMatch,
+ albumMatchError,
+ albumMatchLoading,
+ albumQuery,
+ albumResults,
+ albumSearchError,
+ albumSearchLoading,
+ albumSearchLookupSource,
+ autoGroupFilePaths,
+ clearAutoGroupFilePaths,
+ matchOverrides,
+ resetAlbumWorkflow,
+ selectedAlbum,
+ setAlbumMatch,
+ setAlbumMatchError,
+ setAlbumMatchLoading,
+ setAlbumQuery,
+ setAlbumResults,
+ setAlbumSearchContext,
+ setAlbumSearchError,
+ setAlbumSearchLoading,
+ setAlbumSearchLookupSource,
+ setMatchOverrides,
+ setSelectedAlbum,
+ } = useAlbumImportWorkflow();
+
+ const resetAlbumSearch = () => {
+ setDragOverTrack(null);
+ setTapSelectedChip(null);
+ resetAlbumWorkflow();
+ void 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 ?? []);
+ setAlbumSearchLookupSource(payload.primary_source ?? null);
+ } catch (error) {
+ setAlbumSearchError(getErrorMessage(error));
+ } finally {
+ setAlbumSearchLoading(false);
+ }
+ };
+
+ const selectAlbum = async (album: ImportAlbumResult) => {
+ setSelectedAlbum(album);
+ setAlbumMatch(null);
+ setAlbumMatchError(null);
+ setAlbumMatchLoading(true);
+
+ try {
+ // Pass the source that returned this result row so matching keeps using the
+ // same provider even if the search fell back from the lookup source.
+ 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,
+ albumSearchLookupSource,
+ 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,
+ suggestionsLookupSource: suggestionsQuery.data?.primary_source ?? null,
+ tapSelectedChip,
+ };
+}
+
+type AlbumImportViewModel = ReturnType;
+
+type AlbumMetaFields = {
+ total_tracks?: number | null;
+ release_date?: string | null;
+ format?: string | null;
+ country?: string | null;
+ disambiguation?: string | null;
+ status?: string | null;
+ label?: string | null;
+};
+
+export function AlbumImportTab() {
+ const viewModel = useAlbumImportViewModel();
+
+ return ;
+}
+
+function AlbumImportPanelContent({ viewModel }: { viewModel: AlbumImportViewModel }) {
+ const {
+ albumMatch,
+ albumMatchError,
+ albumMatchLoading,
+ albumQuery,
+ albumResults,
+ albumSearchError,
+ albumSearchLoading,
+ albumSearchLookupSource,
+ groups,
+ onAlbumQueryChange,
+ onBackToSearch,
+ onRunGroupSearch,
+ onRunSearch,
+ onSelectAlbum,
+ selectedAlbum,
+ suggestions,
+ suggestionsReady,
+ suggestionsLookupSource,
+ } = viewModel;
+
+ const showingMatch = selectedAlbum || albumMatchLoading || albumMatchError || albumMatch;
+ const suggestionsFallbackBanner = getImportSourceFallbackBanner(
+ suggestions,
+ suggestionsLookupSource,
+ );
+ const albumResultsFallbackBanner = getImportSourceFallbackBanner(
+ albumResults,
+ albumSearchLookupSource,
+ );
+
+ return (
+ <>
+
+ {albumResults === null && (
+ <>
+ {groups.length > 0 && (
+
+
Auto-Detected Albums
+
+ {groups.map((group, index) => (
+
onRunGroupSearch(group)}
+ >
+ {group.file_count}
+
+
+ {group.album}
+
+
+ {group.artist} · {group.file_count} tracks
+
+
+
+ ))}
+
+
+ )}
+
+
+
Suggested from your import folder
+ {suggestionsFallbackBanner ? (
+
{suggestionsFallbackBanner}
+ ) : null}
+
+ {suggestions.length > 0 ? (
+ suggestions.map((album) => (
+
+ ))
+ ) : suggestionsReady ? null : (
+
Loading suggestions...
+ )}
+
+
+ >
+ )}
+
+
+ onAlbumQueryChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') onRunSearch();
+ }}
+ />
+
+ x
+
+
+ Search
+
+
+
+ {albumResultsFallbackBanner ? (
+
{albumResultsFallbackBanner}
+ ) : null}
+
+ {albumSearchLoading ? (
+
Searching...
+ ) : albumSearchError ? (
+
+ Error: {albumSearchError}
+
+ ) : albumResults?.length === 0 ? (
+
No albums found
+ ) : (
+ albumResults?.map((album) => (
+
+ ))
+ )}
+
+
+
+
+ {albumMatchLoading ? (
+
Matching files to tracklist...
+ ) : albumMatchError ? (
+
+ Error: {albumMatchError}
+
+ ) : albumMatch?.album ? (
+
+ ) : (
+
+ Select an album to start matching files.
+
+ )}
+
+ >
+ );
+}
+
+function AlbumCard({
+ album,
+ lookupSource,
+ onSelect,
+}: {
+ album: ImportAlbumResult;
+ lookupSource: string | null;
+ onSelect: (album: ImportAlbumResult) => void;
+}) {
+ const resultSourceBadge = getImportSourceBadgeText(album.source, lookupSource);
+ const metaParts = getAlbumMetaParts(album);
+ const detailParts = getAlbumDetailParts(album);
+
+ return (
+ onSelect(album)}>
+
+
+ {album.name}
+
+
+ {album.artist}
+
+ {metaParts.join(' · ')}
+ {detailParts.length > 0 ? (
+ {detailParts.join(' · ')}
+ ) : null}
+ {resultSourceBadge ? (
+ {resultSourceBadge}
+ ) : null}
+
+ );
+}
+
+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;
+ const heroMetaParts = albumMatch?.album ? getAlbumMetaParts(albumMatch.album) : [];
+
+ return albumMatchLoading ? (
+ Matching files to tracklist...
+ ) : albumMatchError ? (
+
+ Error: {albumMatchError}
+
+ ) : albumMatch?.album ? (
+ <>
+
+
+
+
{albumMatch.album.name}
+
{albumMatch.album.artist}
+
{heroMetaParts.join(' · ')}
+
+
+
+
+
Track Matching
+
+
+ Re-match Automatically
+
+
+ Back to Search
+
+
+
+
+
+ {(albumMatch.matches ?? []).map((match, index) => {
+ const trackInfo = getTrackDisplayInfo(match, index);
+ const { confidence, file } = getDisplayedMatchFile(
+ match,
+ index,
+ stagingFiles,
+ matchOverrides,
+ );
+ const confidencePercent = Math.round(confidence * 100);
+ return (
+
{
+ if (tapSelectedChip !== null) onTapAssign(index, tapSelectedChip);
+ }}
+ onDragOver={(event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ onDragOverTrack(index);
+ }}
+ onDragLeave={() => onDragOverTrack(null)}
+ onDrop={(event) => {
+ event.preventDefault();
+ onDragOverTrack(null);
+ const stagingFileIndex = Number(event.dataTransfer.getData('text/plain'));
+ if (Number.isFinite(stagingFileIndex)) onTapAssign(index, stagingFileIndex);
+ }}
+ >
+ {trackInfo.displayTrackNumber}
+ {trackInfo.name}
+
+ {file ? (
+ <>
+ {file.filename}
+
+ {confidencePercent}%
+
+ >
+ ) : (
+ Drop a file here
+ )}
+
+
+ {file ? (
+ {
+ event.stopPropagation();
+ onUnmatchTrack(index);
+ }}
+ >
+ x
+
+ ) : null}
+
+
+ );
+ })}
+
+
+
+
+ Unmatched Files ({unmatchedFiles.length} )
+
+
+ {unmatchedFiles.length === 0 ? (
+ All files matched
+ ) : (
+ unmatchedFiles.map(({ file, index }) => (
+ {
+ event.stopPropagation();
+ onTapSelectChip(index);
+ }}
+ onDragStart={(event: DragEvent) => {
+ event.dataTransfer.setData('text/plain', String(index));
+ event.dataTransfer.effectAllowed = 'move';
+ }}
+ onKeyDown={(event: KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onTapSelectChip(index);
+ }
+ }}
+ >
+ {file.filename}
+
+ ))
+ )}
+
+
+
+
+
+ {matchedCount} of {albumMatch.matches?.length ?? 0} tracks matched
+
+
+ Process {matchedCount} Track{matchedCount === 1 ? '' : 's'}
+
+
+ >
+ ) : (
+ Select an album to start matching files.
+ );
+}
+
+function getAlbumMetaParts(album: AlbumMetaFields) {
+ return [
+ `${album.total_tracks || 0} tracks`,
+ album.release_date?.substring(0, 4) || '',
+ album.format || '',
+ album.country || '',
+ album.disambiguation || '',
+ ].filter(Boolean);
+}
+
+function getAlbumDetailParts(album: AlbumMetaFields) {
+ return [album.status || '', album.label || ''].filter(Boolean);
+}
diff --git a/webui/src/routes/import/-ui/auto-import-tab.tsx b/webui/src/routes/import/-ui/auto-import-tab.tsx
new file mode 100644
index 00000000..245861e3
--- /dev/null
+++ b/webui/src/routes/import/-ui/auto-import-tab.tsx
@@ -0,0 +1,614 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import clsx from 'clsx';
+import { useEffect, useState } from 'react';
+
+import {
+ Button,
+ OptionButton,
+ OptionButtonGroup,
+ RangeInput,
+ Select,
+ Switch,
+} from '@/components/form/form';
+import { Badge, Notice } from '@/components/primitives';
+
+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,
+ getAutoImportStatusMeta,
+ getAutoImportStatusText,
+ getAutoImportStatusTone,
+ getAutoImportTimeAgo,
+ getConfidenceClass,
+ parseAutoImportMatchData,
+} from '../-import.helpers';
+import styles from './import-page.module.css';
+import { fallbackImage, getErrorMessage, RefreshIcon } from './import-shared';
+
+export function AutoImportPanel({
+ autoFilter,
+ onFilterChange,
+}: {
+ autoFilter: ImportAutoFilter;
+ onFilterChange: (filter: ImportAutoFilter) => void;
+}) {
+ const queryClient = useQueryClient();
+ const [confidence, setConfidence] = useState(90);
+ const [interval, setInterval] = useState(60);
+ const [expandedRows, setExpandedRows] = useState>(() => new Set());
+
+ const statusQuery = useQuery({
+ ...autoImportStatusQueryOptions(),
+ refetchInterval: 5000,
+ });
+ const settingsQuery = useQuery({
+ ...autoImportSettingsQueryOptions(),
+ });
+ const resultsQuery = useQuery({
+ ...autoImportResultsQueryOptions(),
+ refetchInterval: 5000,
+ });
+
+ useEffect(() => {
+ const settings = settingsQuery.data;
+ if (!settings) return;
+ setConfidence(Math.round((settings.confidence_threshold ?? 0.9) * 100));
+ setInterval(settings.scan_interval ?? 60);
+ }, [settingsQuery.data]);
+
+ const invalidateAutoImport = () => {
+ void invalidateAutoImportQueries(queryClient);
+ };
+
+ const toggleMutation = useMutation({
+ mutationFn: toggleAutoImport,
+ onSuccess: (_, enabled) => {
+ window.showToast?.(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const saveSettingsMutation = useMutation({
+ mutationFn: saveAutoImportSettings,
+ onSuccess: () => {
+ window.showToast?.('Settings saved', 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const scanMutation = useMutation({
+ mutationFn: triggerAutoImportScan,
+ onSuccess: () => {
+ window.showToast?.('Scan triggered', 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const approveMutation = useMutation({
+ mutationFn: approveAutoImportResult,
+ onSuccess: () => {
+ window.showToast?.('Approved', 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const rejectMutation = useMutation({
+ mutationFn: rejectAutoImportResult,
+ onSuccess: () => {
+ window.showToast?.('Dismissed', 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const approveAllMutation = useMutation({
+ mutationFn: async () => {
+ const confirmed = await confirmAction({
+ title: 'Approve All',
+ message: 'Approve and import all pending review items?',
+ confirmText: 'Approve All',
+ });
+ if (!confirmed) return null;
+ return await approveAllAutoImportResults();
+ },
+ onSuccess: (count) => {
+ if (count === null) return;
+ window.showToast?.(`Approved ${count} items`, 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+ const clearMutation = useMutation({
+ mutationFn: clearCompletedAutoImportResults,
+ onSuccess: (count) => {
+ window.showToast?.(`Cleared ${count} imported items`, 'success');
+ invalidateAutoImport();
+ },
+ onError: showMutationError,
+ });
+
+ const allResults = resultsQuery.data?.results ?? [];
+ const results = filterAutoImportResults(allResults, autoFilter);
+ const counts = getAutoImportCounts(allResults);
+ const activeLines = getActiveImportLines(statusQuery.data);
+ const statusTone = getAutoImportStatusTone(statusQuery.data);
+ const statusError =
+ statusQuery.error && !statusQuery.data ? getErrorMessage(statusQuery.error) : '';
+ const resultsError =
+ resultsQuery.error && !resultsQuery.data ? getErrorMessage(resultsQuery.error) : '';
+
+ return (
+ <>
+ {statusError ? (
+
+ Auto-import is unavailable: {statusError}
+
+ ) : null}
+
+
+
+ toggleMutation.mutate(checked)}
+ />
+ Auto-Import
+
+
+ {getAutoImportStatusText(statusQuery.data)}
+
+
+ {statusQuery.data?.running ? (
+
scanMutation.mutate()}
+ >
+
+ Scan Now
+
+ ) : null}
+
+ {statusQuery.data?.running ? (
+
+
+ Confidence:
+
+
+ {confidence}%
+
+
+
+ Interval:
+ setInterval(Number(event.target.value))}
+ >
+ 30s
+ 60s
+ 2m
+ 5m
+
+
+
+ saveSettingsMutation.mutate({
+ confidenceThreshold: confidence / 100,
+ scanInterval: interval,
+ })
+ }
+ >
+ Save
+
+
+ ) : null}
+ {activeLines.length > 0 ? (
+
+
+ {activeLines.length === 1
+ ? `Processing ${activeLines[0]}`
+ : `Processing ${activeLines.length} imports:`}
+ {activeLines.length > 1
+ ? activeLines.map((line) =>
{line}
)
+ : null}
+
+
+
+ ) : null}
+
+
+ {allResults.length > 0 ? (
+
+ {(['all', 'pending', 'imported', 'failed'] as const).map((filter) => (
+ onFilterChange(filter)}
+ >
+ {getAutoImportFilterLabel(filter)}
+
+ {getAutoImportFilterCount(filter, counts, allResults.length)}
+
+
+ ))}
+
+ {counts.review > 0 ? (
+ approveAllMutation.mutate()}
+ >
+ Approve All
+
+ ) : null}
+ {counts.imported + counts.failed > 0 ? (
+ clearMutation.mutate()}
+ >
+ Clear History
+
+ ) : null}
+
+ ) : null}
+
+
+ {resultsError ? (
+
+ Failed to load imports: {resultsError}
+
+ ) : allResults.length === 0 ? (
+
+
No imports yet. Drop album folders or single tracks into your import folder.
+
+ ) : results.length === 0 ? (
+
+
No {autoFilter === 'pending' ? 'pending review' : autoFilter} items.
+
+ ) : (
+ results.map((result, index) => (
+
approveMutation.mutate(result.id)}
+ onReject={() => rejectMutation.mutate(result.id)}
+ onToggle={() => {
+ setExpandedRows((current) => {
+ const next = new Set(current);
+ if (next.has(result.id)) next.delete(result.id);
+ else next.add(result.id);
+ return next;
+ });
+ }}
+ />
+ ))
+ )}
+
+ >
+ );
+}
+
+function AutoImportResultCard({
+ approvePending,
+ expanded,
+ index,
+ rejectPending,
+ result,
+ status,
+ onApprove,
+ onReject,
+ onToggle,
+}: {
+ approvePending: boolean;
+ expanded: boolean;
+ index: number;
+ rejectPending: boolean;
+ result: ImportAutoImportResult;
+ status: ImportAutoImportStatusPayload | undefined;
+ onApprove: () => void;
+ onReject: () => void;
+ onToggle: () => void;
+}) {
+ const confidencePercent = Math.round((result.confidence || 0) * 100);
+ const confidenceClass = getConfidenceClass(confidencePercent);
+ const statusMeta = getAutoImportStatusMeta(result.status);
+ const liveActive = status?.active_imports?.find(
+ (item) => item.folder_hash === result.folder_hash,
+ );
+ const isLiveProcessing = result.status === 'processing' && liveActive?.status === 'processing';
+ const liveTrackIndex = isLiveProcessing ? liveActive?.track_index || 0 : 0;
+ const liveTrackTotal = isLiveProcessing ? liveActive?.track_total || 0 : 0;
+ const liveTrackName = isLiveProcessing ? liveActive?.track_name || '' : '';
+ const matchData = parseAutoImportMatchData(result.match_data);
+ const trackDetails =
+ matchData.matches?.map((match) => ({
+ name: match.track_name || match.track?.name || 'Unknown',
+ file: match.file ? match.file.split(/[/\\]/).pop() || '?' : '?',
+ confidence: Math.round((match.confidence || 0) * 100),
+ })) ?? [];
+ const matchSummary =
+ isLiveProcessing && liveTrackTotal > 0
+ ? `track ${liveTrackIndex}/${liveTrackTotal}: ${liveTrackName}`
+ : matchData.total_tracks && matchData.total_tracks > 0
+ ? `${matchData.matched_count || 0}/${matchData.total_tracks} tracks`
+ : `${result.total_files || 0} files`;
+ const methodLabel = getMethodLabel(result.identification_method);
+ const timeAgo = getAutoImportTimeAgo(result.created_at);
+ const statusCardClass = getAutoImportCardClass(statusMeta.className);
+ const statusBadgeClass = getAutoImportBadgeClass(statusMeta.className);
+ const confidenceFillClass = getAutoImportConfidenceClass(confidenceClass);
+
+ return (
+ {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ onToggle();
+ }
+ }}
+ >
+
+
+ {result.image_url ? (
+
+ ) : (
+
💿
+ )}
+
+
+
+ {result.album_name || result.folder_name}
+
+
+ {result.artist_name || 'Unknown Artist'}
+
+
+ {matchSummary}
+ {methodLabel ? (
+ {methodLabel}
+ ) : null}
+ {timeAgo ? {timeAgo} : null}
+
+ {result.error_message ? (
+
{result.error_message}
+ ) : null}
+
+
+
+ {statusMeta.icon} {statusMeta.label}
+
+
+
{confidencePercent}% confidence
+ {result.status === 'pending_review' ? (
+
+ {
+ event.stopPropagation();
+ onApprove();
+ }}
+ >
+ Approve & Import
+
+ {
+ event.stopPropagation();
+ onReject();
+ }}
+ >
+ Dismiss
+
+
+ ) : null}
+
+
+
{result.folder_name}
+ {trackDetails.length > 0 ? (
+
+
+ Track
+ Matched File
+ Conf
+
+ {trackDetails.map((track, trackIndex) => {
+ const rowClassName = clsx(styles.autoImportTrackRow, {
+ [styles.autoImportTrackRowActive]:
+ isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 === liveTrackIndex,
+ [styles.autoImportTrackRowDone]:
+ isLiveProcessing && liveTrackIndex > 0 && trackIndex + 1 < liveTrackIndex,
+ });
+ return (
+
+ {track.name}
+ {track.file}
+
+ {track.confidence}%
+
+
+ );
+ })}
+
+ ) : null}
+
+ );
+}
+
+function showMutationError(error: unknown) {
+ window.showToast?.(getErrorMessage(error), 'error');
+}
+
+function getAutoImportCardClass(status: string): string {
+ const classes: Record = {
+ completed: styles.autoImportCompleted,
+ review: styles.autoImportReview,
+ failed: styles.autoImportFailed,
+ processing: styles.autoImportProcessing,
+ };
+
+ return classes[status] ?? '';
+}
+
+function getAutoImportBadgeClass(status: string): string {
+ const classes: Record = {
+ completed: styles.autoImportBadgeCompleted,
+ review: styles.autoImportBadgeReview,
+ failed: styles.autoImportBadgeFailed,
+ neutral: styles.autoImportBadgeNeutral,
+ processing: styles.autoImportBadgeProcessing,
+ };
+
+ return classes[status] ?? '';
+}
+
+function getAutoImportConfidenceClass(status: string): string {
+ const classes: Record = {
+ high: styles.autoImportConfHigh,
+ medium: styles.autoImportConfMedium,
+ low: styles.autoImportConfLow,
+ };
+
+ return classes[status] ?? '';
+}
+
+function getAutoImportFilterLabel(filter: ImportAutoFilter): string {
+ switch (filter) {
+ case 'all':
+ return 'All';
+ case 'pending':
+ return 'Needs Review';
+ case 'imported':
+ return 'Imported';
+ case 'failed':
+ return 'Failed';
+ }
+}
+
+function getAutoImportFilterCount(
+ filter: ImportAutoFilter,
+ counts: ReturnType,
+ totalCount: number,
+): number {
+ switch (filter) {
+ case 'all':
+ return totalCount;
+ case 'pending':
+ return counts.review;
+ case 'imported':
+ return counts.imported;
+ case 'failed':
+ return counts.failed;
+ }
+}
+
+function getAutoImportFilterTone(
+ filter: ImportAutoFilter,
+): 'neutral' | 'warning' | 'success' | 'danger' {
+ switch (filter) {
+ case 'pending':
+ return 'warning';
+ case 'imported':
+ return 'success';
+ case 'failed':
+ return 'danger';
+ case 'all':
+ return 'neutral';
+ }
+}
+
+function getMethodLabel(method: string | null | undefined): string {
+ const labels: Record = {
+ tags: 'Tags',
+ folder_name: 'Folder Name',
+ acoustid: 'AcoustID',
+ filename: 'Filename',
+ };
+ return method ? labels[method] || method : '';
+}
+
+async function confirmAction({
+ title,
+ message,
+ confirmText,
+}: {
+ title: string;
+ message: string;
+ confirmText: string;
+}): Promise {
+ if (window.showConfirmDialog) {
+ return await window.showConfirmDialog({ title, message, confirmText });
+ }
+ return window.confirm(message);
+}
diff --git a/webui/src/routes/import/-ui/import-page.module.css b/webui/src/routes/import/-ui/import-page.module.css
new file mode 100644
index 00000000..d7fd667d
--- /dev/null
+++ b/webui/src/routes/import/-ui/import-page.module.css
@@ -0,0 +1,1825 @@
+.importPageContainer {
+ max-width: 1200px;
+ margin: 0 auto;
+ padding: 24px 32px;
+}
+
+.importPageHeader {
+ margin-bottom: 20px;
+}
+
+.importPageTitleRow {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 12px;
+}
+
+.importPageTitle {
+ font-size: 28px;
+ font-weight: 700;
+ margin: 0;
+ letter-spacing: -0.3px;
+ font-family:
+ 'SF Pro Display',
+ -apple-system,
+ BlinkMacSystemFont,
+ sans-serif;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+}
+
+.importPageTitle > span {
+ background: linear-gradient(
+ 90deg,
+ #ffffff 0%,
+ rgb(var(--accent-light-rgb)) 50%,
+ rgb(var(--accent-rgb)) 100%
+ );
+ background-size: 200% 100%;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ background-clip: text;
+ animation: page-title-shimmer 6s ease-in-out infinite;
+}
+
+.importPageStagingBar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 10px;
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.importStagingPath {
+ flex: 1;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.importStagingStats {
+ color: rgb(var(--accent-light-rgb));
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.importStagingRefreshAt {
+ white-space: nowrap;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+/* Tab Bar */
+.importPageTabBar {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 24px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ padding-bottom: 0;
+}
+
+.importPageTab {
+ padding: 10px 24px;
+ background: none;
+ border: none;
+ border-bottom: 2px solid transparent;
+ color: rgba(255, 255, 255, 0.5);
+ font-size: 14px;
+ font-weight: 500;
+ text-decoration: none;
+ cursor: pointer;
+ transition: all 0.2s;
+ margin-bottom: -1px;
+}
+
+.importPageTab:hover {
+ color: rgba(255, 255, 255, 0.8);
+}
+
+.importPageTab.active {
+ color: rgb(var(--accent-light-rgb));
+ border-bottom-color: rgb(var(--accent-light-rgb));
+}
+
+.importPageTabContent {
+ display: none;
+}
+
+.importPageTabContent.active {
+ display: block;
+}
+
+.hidden {
+ display: none;
+}
+
+/* Section labels */
+.importPageSectionLabel {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.4);
+ margin-bottom: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 500;
+}
+
+/* Search bar */
+.importPageSearchBar {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 20px;
+}
+
+.importPageSearchInput {
+ flex: 1;
+ padding: 10px 16px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 10px;
+ color: #fff;
+ font-size: 14px;
+ outline: none;
+ transition: border-color 0.2s;
+}
+
+.importPageSearchInput:focus {
+ border-color: rgba(var(--accent-light-rgb), 0.5);
+}
+
+/* Album grid */
+.importPageAlbumGrid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+ gap: 16px;
+ margin-bottom: 24px;
+}
+
+.importPageAlbumCard {
+ background: rgba(255, 255, 255, 0.05);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ padding: 12px;
+ cursor: pointer;
+ transition: all 0.2s;
+ text-align: center;
+ color: inherit;
+ font: inherit;
+}
+
+.importPageAutoGroups .importPageAlbumGrid {
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 10px;
+}
+
+.importPageAutoGroupCard {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ text-align: left;
+}
+
+.importPageAutoGroupCount {
+ width: 48px;
+ height: 48px;
+ border-radius: 8px;
+ background: rgba(var(--accent-rgb, 29, 185, 84), 0.15);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 1.2em;
+}
+
+.importPageAutoGroupInfo {
+ min-width: 0;
+}
+
+.importPageAlbumCard:hover {
+ background: rgba(255, 255, 255, 0.1);
+ border-color: rgba(var(--accent-light-rgb), 0.3);
+ transform: translateY(-2px);
+}
+
+.importPageAlbumCard img {
+ width: 100%;
+ aspect-ratio: 1;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-bottom: 8px;
+}
+
+.importPageAlbumCardTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-bottom: 2px;
+}
+
+.importPageAlbumCardArtist {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.5);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageAlbumCardMeta {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.3);
+ margin-top: 4px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageAlbumCardDetail {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.36);
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageAlbumCardSource {
+ font-size: 10px;
+ color: rgba(255, 200, 100, 0.75);
+ margin-top: 2px;
+ font-style: italic;
+}
+
+/* Album hero (selected album) */
+.importPageAlbumHero {
+ display: flex;
+ gap: 20px;
+ padding: 20px;
+ background: rgba(255, 255, 255, 0.04);
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 14px;
+ margin-bottom: 20px;
+ align-items: center;
+}
+
+.importPageAlbumHero img {
+ width: 120px;
+ height: 120px;
+ border-radius: 10px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.importPageAlbumHeroInfo {
+ flex: 1;
+ min-width: 0;
+}
+
+.importPageAlbumHeroTitle {
+ font-size: 22px;
+ font-weight: 700;
+ color: #fff;
+ margin-bottom: 4px;
+}
+
+.importPageAlbumHeroArtist {
+ font-size: 15px;
+ color: rgba(255, 255, 255, 0.6);
+ margin-bottom: 4px;
+}
+
+.importPageAlbumHeroMeta {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.35);
+}
+
+/* Match header */
+.importPageMatchHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+
+.importPageMatchHeader h3 {
+ font-size: 16px;
+ font-weight: 600;
+ color: #fff;
+ margin: 0;
+}
+
+.importPageMatchActions {
+ display: flex;
+ gap: 8px;
+}
+
+/* Match list */
+.importPageMatchList {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ margin-bottom: 16px;
+}
+
+.importPageMatchRow {
+ display: grid;
+ grid-template-columns: 36px 1fr 1fr 36px;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ min-height: 44px;
+ transition: all 0.2s;
+}
+
+.importPageMatchRow.dragOver {
+ background: rgba(var(--accent-light-rgb), 0.08);
+ border-color: rgba(var(--accent-light-rgb), 0.4);
+ box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15);
+}
+
+.importPageMatchRow.matched {
+ border-color: rgba(var(--accent-light-rgb), 0.2);
+}
+
+.importPageMatchNum {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.3);
+ text-align: center;
+ font-weight: 500;
+}
+
+.importPageMatchTrack {
+ font-size: 13px;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageMatchFile {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.4);
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ overflow: hidden;
+}
+
+.importPageMatchFile.hasFile {
+ color: rgb(var(--accent-light-rgb));
+}
+
+.importPageMatchFileName {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.importPageMatchConfidence {
+ font-size: 10px;
+ padding: 2px 6px;
+ border-radius: 4px;
+ background: rgba(var(--accent-light-rgb), 0.15);
+ color: rgb(var(--accent-light-rgb));
+ font-weight: 500;
+ white-space: nowrap;
+}
+
+.importPageMatchConfidence.low {
+ background: rgba(255, 165, 0, 0.15);
+ color: #ffa500;
+}
+
+.importPageMatchDropZone {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.25);
+ font-style: italic;
+}
+
+/* Unmatched file pool */
+.importPageUnmatchedPool {
+ padding: 16px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px dashed rgba(255, 255, 255, 0.1);
+ border-radius: 12px;
+ margin-bottom: 16px;
+}
+
+.importPagePoolLabel {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.4);
+ margin-bottom: 10px;
+ font-weight: 500;
+}
+
+.importPagePoolChips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.importPageFileChip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 6px 12px;
+ background: rgba(255, 255, 255, 0.08);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 20px;
+ font-size: 12px;
+ color: #ccc;
+ cursor: grab;
+ transition: all 0.2s;
+ user-select: none;
+}
+
+.importPageFileChip:active {
+ cursor: grabbing;
+}
+
+.importPageFileChip:hover {
+ background: rgba(255, 255, 255, 0.14);
+ border-color: rgba(var(--accent-light-rgb), 0.3);
+ color: #fff;
+}
+
+.importPageFileChip.selected {
+ background: rgba(var(--accent-light-rgb), 0.15);
+ border-color: rgba(var(--accent-light-rgb), 0.4);
+ color: rgb(var(--accent-light-rgb));
+}
+
+.importPagePoolEmpty {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.2);
+ font-style: italic;
+}
+
+/* Match footer */
+.importPageMatchFooter {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding-top: 16px;
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.importPageMatchStats {
+ font-size: 13px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+/* Singles */
+.importPageSinglesHeader {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ margin-bottom: 16px;
+}
+
+.importPageSinglesActions {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.importPageSinglesList {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.importPageSingleItem {
+ display: grid;
+ grid-template-columns: 32px 1fr auto;
+ align-items: center;
+ gap: 12px;
+ padding: 10px 14px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 10px;
+ transition: all 0.2s;
+}
+
+.importPageSingleItem.matched {
+ border-color: rgba(var(--accent-light-rgb), 0.2);
+}
+
+.importPageSingleCheckboxWrap {
+ grid-column: 1;
+ position: relative;
+ width: 18px;
+ height: 18px;
+ flex-shrink: 0;
+ margin-top: 2px;
+}
+
+.importPageSingleInfo {
+ grid-column: 2;
+ min-width: 0;
+}
+
+.importPageSingleFilename {
+ font-size: 13px;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageSingleMeta {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.35);
+ margin-top: 2px;
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.importPageSingleMatchedInfo {
+ font-size: 12px;
+ color: rgb(var(--accent-light-rgb));
+ margin-top: 4px;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.importPageSingleMatchedChange {
+ background: transparent;
+ border: 0;
+ padding: 0;
+ color: rgba(255, 255, 255, 0.4);
+ cursor: pointer;
+ font-size: 11px;
+ text-decoration: underline;
+}
+
+.importPageSingleMatchedChange:hover {
+ color: #fff;
+}
+
+.importPageSingleActions {
+ grid-column: 3;
+ justify-self: end;
+ display: flex;
+ gap: 6px;
+ align-items: center;
+ flex-shrink: 0;
+}
+
+/* Inline search panel for singles */
+.importPageSingleSearchPanel {
+ grid-column: 1 / -1;
+ padding: 12px 0 4px 44px;
+}
+
+.importPageSingleSearchBar {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+}
+
+.importPageSingleSearchInput {
+ flex: 1;
+ padding: 8px 12px;
+ background: rgba(255, 255, 255, 0.06);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ color: #fff;
+ font-size: 13px;
+ outline: none;
+}
+
+.importPageSingleSearchInput:focus {
+ border-color: rgba(var(--accent-light-rgb), 0.4);
+}
+
+.importPageSingleSearchResults {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ max-height: 200px;
+ overflow-y: auto;
+}
+
+.importPageSingleResultItem {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 10px;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 8px;
+ cursor: pointer;
+ transition: all 0.15s;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+}
+
+.importPageSingleResultItem:hover {
+ background: rgba(var(--accent-light-rgb), 0.08);
+ border-color: rgba(var(--accent-light-rgb), 0.25);
+}
+
+.importPageSingleResultImg {
+ width: 36px;
+ height: 36px;
+ border-radius: 4px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.importPageSingleResultInfo {
+ flex: 1;
+ min-width: 0;
+}
+
+.importPageSingleResultName {
+ font-size: 13px;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageSingleResultDetail {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+}
+
+.importPageSingleResultSelect {
+ padding: 4px 12px;
+ background: rgba(var(--accent-light-rgb), 0.15);
+ border: 1px solid rgba(var(--accent-light-rgb), 0.3);
+ border-radius: 6px;
+ color: rgb(var(--accent-light-rgb));
+ font-size: 11px;
+ font-weight: 600;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.importPageSingleResultSelect:hover {
+ background: rgba(var(--accent-light-rgb), 0.25);
+}
+
+/* Empty state */
+.importPageEmptyState {
+ text-align: center;
+ padding: 60px 20px;
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 14px;
+}
+
+/* Suggestions */
+.importPageSuggestions {
+ margin-bottom: 24px;
+}
+
+/* Processing Queue */
+.importPageQueue {
+ margin-bottom: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.02);
+ overflow: hidden;
+}
+
+.importPageQueueHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 10px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
+}
+
+.importPageQueueTitle {
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.6);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.importPageQueueList {
+ display: flex;
+ flex-direction: column;
+}
+
+.importPageQueueItem {
+ display: grid;
+ grid-template-columns: 44px 1fr auto;
+ gap: 12px;
+ align-items: center;
+ padding: 10px 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.importPageQueueItem:last-child {
+ border-bottom: none;
+}
+
+.importPageQueueArt {
+ width: 44px;
+ height: 44px;
+ border-radius: 6px;
+ object-fit: cover;
+}
+
+.importPageQueueArtEmpty {
+ background: rgba(255, 255, 255, 0.06);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ color: rgba(255, 255, 255, 0.3);
+}
+
+.importPageFlexSpacer {
+ flex: 1;
+}
+
+.importPageQueueInfo {
+ min-width: 0;
+}
+
+.importPageQueueName {
+ font-size: 13px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.importPageQueueDetail {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+ margin-top: 2px;
+}
+
+.importPageQueueProgress {
+ width: 120px;
+ flex-shrink: 0;
+}
+
+.importPageQueueBar {
+ height: 4px;
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 2px;
+ overflow: hidden;
+ margin-bottom: 4px;
+}
+
+.importPageQueueFill {
+ height: 100%;
+ background: rgb(var(--accent-light-rgb));
+ border-radius: 2px;
+ width: 0%;
+ transition: width 0.3s ease;
+}
+
+.importPageQueueFill.error {
+ background: #ef4444;
+}
+
+.importPageQueueStatus {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.4);
+ text-align: right;
+}
+
+.importPageQueueStatus.done {
+ color: rgb(var(--accent-light-rgb));
+}
+
+.importPageQueueStatus.error {
+ color: #ef4444;
+}
+
+@media (max-width: 768px) {
+ .importPageFileChip,
+ .importPageMatchRow {
+ cursor: pointer;
+ }
+
+ .importPageContainer {
+ padding: 16px;
+ }
+
+ .importPageTitle {
+ font-size: 22px;
+ }
+
+ .importPageAlbumGrid {
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 10px;
+ }
+
+ .importPageAlbumHero {
+ flex-direction: column;
+ text-align: center;
+ }
+
+ .importPageAlbumHero img {
+ width: 100px;
+ height: 100px;
+ }
+
+ .importPageMatchRow {
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ grid-template-areas:
+ 'num track track'
+ 'num file unmatch';
+ gap: 6px 8px;
+ align-items: center;
+ }
+
+ .importPageMatchNum {
+ grid-area: num;
+ }
+
+ .importPageMatchTrack {
+ grid-area: track;
+ }
+
+ .importPageMatchFile {
+ grid-area: file;
+ min-width: 0;
+ }
+
+ .importPageSinglesHeader {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .importPageSinglesActions {
+ width: 100%;
+ flex-wrap: wrap;
+ }
+
+ .importPageSingleItem {
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ grid-template-areas:
+ 'checkbox info actions'
+ 'search search search';
+ align-items: start;
+ gap: 8px;
+ }
+
+ .importPageSingleCheckboxWrap {
+ grid-area: checkbox;
+ }
+
+ .importPageSingleInfo {
+ grid-area: info;
+ }
+
+ .importPageSingleActions {
+ grid-area: actions;
+ justify-self: end;
+ align-self: start;
+ }
+
+ .importPageSingleSearchPanel {
+ grid-area: search;
+ }
+
+ .importPageSingleSearchPanel {
+ padding-left: 0;
+ }
+
+ .importPageStagingBar {
+ flex-direction: column;
+ gap: 4px;
+ align-items: flex-start;
+ }
+}
+.autoImportControls {
+ padding: 12px 0 16px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ margin-bottom: 16px;
+}
+
+.autoImportToggleRow {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.autoImportToggleLabel {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.autoImportSettingsRow {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ margin-top: 10px;
+ flex-wrap: wrap;
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+.autoImportSetting {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.autoImportConfidenceValue {
+ display: inline-block;
+ flex: 0 0 5ch;
+ width: 5ch;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.autoImportEmpty {
+ text-align: center;
+ padding: 40px 20px;
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 13px;
+}
+
+/* Result cards */
+.autoImportCard {
+ display: flex;
+ flex-direction: column;
+ padding: 14px 16px;
+ background: rgba(255, 255, 255, 0.02);
+ border: 1px solid rgba(255, 255, 255, 0.06);
+ border-radius: 12px;
+ margin-bottom: 8px;
+ transition: all 0.2s;
+}
+
+.autoImportCardTop {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+}
+
+.autoImportCard:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.autoImportCompleted {
+ border-left: 3px solid #4ade80;
+}
+.autoImportReview {
+ border-left: 3px solid #fbbf24;
+}
+.autoImportFailed {
+ border-left: 3px solid #f87171;
+}
+.autoImportProcessing {
+ border-left: 3px solid #60a5fa;
+}
+
+.autoImportCardArt {
+ width: 56px;
+ height: 56px;
+ border-radius: 8px;
+ object-fit: cover;
+ flex-shrink: 0;
+}
+
+.autoImportCardArtFallback {
+ width: 56px;
+ height: 56px;
+ border-radius: 8px;
+ background: rgba(255, 255, 255, 0.05);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ opacity: 0.3;
+ flex-shrink: 0;
+}
+
+.autoImportCardCenter {
+ flex: 1;
+ min-width: 0;
+}
+
+.autoImportCardAlbum {
+ font-size: 14px;
+ font-weight: 600;
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.autoImportCardArtist {
+ font-size: 12px;
+ color: rgba(255, 255, 255, 0.45);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.autoImportCardFolder {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.25);
+ margin-top: 2px;
+}
+
+.autoImportMatchInfo {
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.35);
+ margin-top: 2px;
+}
+
+.autoImportCardError {
+ font-size: 10px;
+ color: #f87171;
+ margin-top: 2px;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.autoImportCardRight {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 4px;
+ flex-shrink: 0;
+ min-width: 80px;
+}
+
+.autoImportConfidenceBar {
+ width: 60px;
+ height: 4px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.autoImportConfidenceFill {
+ height: 100%;
+ border-radius: 2px;
+}
+.autoImportConfHigh {
+ background: #4ade80;
+}
+.autoImportConfMedium {
+ background: #fbbf24;
+}
+.autoImportConfLow {
+ background: #f87171;
+}
+
+.autoImportConfidenceText {
+ font-size: 10px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.5);
+}
+
+/* Live progress */
+.autoImportProgress {
+ margin-top: 8px;
+ padding: 8px 12px;
+ background: rgba(var(--accent-rgb), 0.04);
+ border: 1px solid rgba(var(--accent-rgb), 0.1);
+ border-radius: 8px;
+}
+
+.autoImportProgressText {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.6);
+ margin-bottom: 4px;
+}
+
+.autoImportProgressBar {
+ height: 3px;
+ background: rgba(255, 255, 255, 0.06);
+ border-radius: 2px;
+ overflow: hidden;
+}
+
+.autoImportProgressFill {
+ height: 100%;
+ background: rgba(var(--accent-rgb), 0.6);
+ border-radius: 2px;
+ width: 100%;
+ animation: adlPulse 1.5s ease-in-out infinite;
+}
+
+/* Filter pills */
+.autoImportFilters {
+ padding: 6px 0;
+ margin-bottom: 8px;
+}
+
+.autoImportCardMeta {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ font-size: 10px;
+ color: rgba(255, 255, 255, 0.3);
+ margin-top: 3px;
+}
+
+.autoImportMethodBadge {
+ background: rgba(255, 255, 255, 0.06);
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.45);
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+}
+
+.autoImportCardFolderPath {
+ font-size: 9px;
+ color: rgba(255, 255, 255, 0.15);
+ margin-top: 6px;
+ padding-top: 6px;
+ border-top: 1px solid rgba(255, 255, 255, 0.03);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* Expandable track list */
+.autoImportTrackList {
+ display: none;
+ margin-top: 8px;
+ padding-top: 8px;
+ border-top: 1px solid rgba(255, 255, 255, 0.04);
+}
+
+.autoImportTrackList.expanded {
+ display: block;
+}
+
+.autoImportTrackListHeader {
+ display: grid;
+ grid-template-columns: 1fr 1fr 50px;
+ gap: 8px;
+ font-size: 9px;
+ font-weight: 600;
+ color: rgba(255, 255, 255, 0.3);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ padding-bottom: 4px;
+ margin-bottom: 4px;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.03);
+}
+
+.autoImportTrackRow {
+ display: grid;
+ grid-template-columns: 1fr 1fr 50px;
+ gap: 8px;
+ padding: 3px 0;
+ font-size: 11px;
+}
+
+.autoImportTrackName {
+ color: rgba(255, 255, 255, 0.7);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.autoImportTrackFile {
+ color: rgba(255, 255, 255, 0.3);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.autoImportTrackConf {
+ text-align: right;
+ font-weight: 600;
+ font-size: 10px;
+}
+
+.autoImportTrackConf.autoImportConfHigh {
+ color: #4ade80;
+}
+.autoImportTrackConf.autoImportConfMedium {
+ color: #fbbf24;
+}
+.autoImportTrackConf.autoImportConfLow {
+ color: #f87171;
+}
+
+.autoImportStatusBadge {
+ font-size: 9px;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 6px;
+ white-space: nowrap;
+}
+.autoImportBadgeCompleted {
+ background: rgba(74, 222, 128, 0.1);
+ color: #4ade80;
+}
+.autoImportBadgeReview {
+ background: rgba(251, 191, 36, 0.1);
+ color: #fbbf24;
+}
+.autoImportBadgeFailed {
+ background: rgba(248, 113, 113, 0.1);
+ color: #f87171;
+}
+.autoImportBadgeNeutral {
+ background: rgba(255, 255, 255, 0.05);
+ color: rgba(255, 255, 255, 0.4);
+}
+.autoImportBadgeProcessing {
+ background: rgba(96, 165, 250, 0.12);
+ color: #60a5fa;
+ animation: auto-import-badge-pulse 1.6s ease-in-out infinite;
+}
+@keyframes auto-import-badge-pulse {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.55;
+ }
+}
+
+.autoImportTrackRowActive {
+ background: rgba(96, 165, 250, 0.08);
+ border-left: 2px solid #60a5fa;
+ padding-left: 6px;
+ margin-left: -8px;
+ border-radius: 3px;
+}
+.autoImportTrackRowActive .autoImportTrackName {
+ color: #60a5fa;
+ font-weight: 600;
+}
+.autoImportTrackRowDone .autoImportTrackName,
+.autoImportTrackRowDone .autoImportTrackFile {
+ opacity: 0.4;
+}
+
+.autoImportActions {
+ display: flex;
+ gap: 4px;
+ margin-top: 4px;
+}
+
+@media (max-width: 768px) {
+ .autoImportCard {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ .autoImportCardRight {
+ flex-direction: row;
+ width: 100%;
+ justify-content: space-between;
+ }
+}
+
+/* Mobile import layout:
+ keep the page feeling like the legacy shell on phones by stacking
+ high-friction controls and letting cards breathe vertically. */
+@media (max-width: 768px) {
+ .importPageContainer {
+ padding: 16px 16px 20px;
+ }
+
+ .importPageHeader {
+ margin-bottom: 16px;
+ }
+
+ .importPageTitleRow {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 10px;
+ }
+
+ .importPageTitle {
+ gap: 10px;
+ font-size: 22px;
+ }
+
+ .importPageTitle > img {
+ width: 24px;
+ height: 24px;
+ }
+
+ .importPageTitleRow > button,
+ .importPageSearchBar > button,
+ .importPageMatchActions > button,
+ .importPageMatchFooter > button,
+ .importPageQueueHeader > button,
+ .importPageSinglesActions > button,
+ .autoImportToggleRow > button {
+ width: 100%;
+ justify-content: center;
+ }
+
+ .importPageStagingBar {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+ padding: 10px 12px;
+ }
+
+ .importStagingPath {
+ white-space: normal;
+ overflow: visible;
+ text-overflow: clip;
+ word-break: break-word;
+ }
+
+ .importStagingStats,
+ .importStagingRefreshAt {
+ white-space: normal;
+ }
+
+ .importPageTabBar {
+ overflow-x: auto;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ }
+
+ .importPageTabBar::-webkit-scrollbar {
+ display: none;
+ }
+
+ .importPageTab {
+ padding: 8px 14px;
+ white-space: nowrap;
+ }
+
+ .importPageSearchBar {
+ flex-wrap: wrap;
+ align-items: center;
+ }
+
+ #import-page-album-search-input {
+ width: auto;
+ min-width: 0;
+ flex: 1 1 0;
+ }
+
+ #import-page-album-clear-btn {
+ flex: 0 0 auto;
+ min-height: 36px;
+ }
+
+ #import-page-album-clear-btn + button {
+ flex: 1 0 100%;
+ width: 100%;
+ }
+
+ .importPageAlbumGrid {
+ grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
+ gap: 10px;
+ margin-bottom: 18px;
+ }
+
+ .importPageAutoGroups .importPageAlbumGrid {
+ grid-template-columns: 1fr;
+ }
+
+ .importPageAlbumCard {
+ padding: 10px;
+ }
+
+ .importPageAlbumCard img {
+ margin-bottom: 6px;
+ }
+
+ .importPageAlbumCardTitle {
+ font-size: 12px;
+ }
+
+ .importPageAlbumCardArtist {
+ font-size: 10px;
+ }
+
+ .importPageAutoGroupCard {
+ gap: 10px;
+ }
+
+ .importPageAlbumHero {
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 14px;
+ padding: 16px;
+ }
+
+ .importPageAlbumHero img {
+ width: 96px;
+ height: 96px;
+ }
+
+ .importPageAlbumHeroTitle {
+ font-size: 20px;
+ }
+
+ .importPageAlbumHeroArtist {
+ font-size: 14px;
+ }
+
+ .importPageAlbumHeroInfo {
+ width: 100%;
+ }
+
+ .importPageMatchHeader {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .importPageMatchHeader h3 {
+ font-size: 15px;
+ }
+
+ .importPageMatchActions {
+ flex-direction: column;
+ }
+
+ .importPageMatchRow {
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ grid-template-areas:
+ 'num track track'
+ 'num file unmatch';
+ gap: 6px 8px;
+ align-items: center;
+ padding: 10px 12px;
+ }
+
+ .importPageMatchNum {
+ grid-area: num;
+ }
+
+ .importPageMatchTrack {
+ grid-area: track;
+ white-space: normal;
+ }
+
+ .importPageMatchFile {
+ grid-area: file;
+ min-width: 0;
+ flex-wrap: wrap;
+ row-gap: 4px;
+ }
+
+ .importPageMatchRow > :last-child {
+ grid-area: unmatch;
+ justify-self: end;
+ }
+
+ .importPageUnmatchedPool {
+ padding: 12px;
+ }
+
+ .importPagePoolChips {
+ gap: 6px;
+ }
+
+ .importPageMatchFooter {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 10px;
+ }
+
+ .importPageMatchStats {
+ text-align: center;
+ }
+
+ .importPageSinglesHeader {
+ align-items: stretch;
+ }
+
+ .importPageSinglesActions {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .importPageSingleItem {
+ grid-template-columns: 28px minmax(0, 1fr) auto;
+ grid-template-areas:
+ 'checkbox info actions'
+ 'search search search';
+ align-items: start;
+ gap: 8px;
+ padding: 10px 12px;
+ }
+
+ .importPageSingleCheckboxWrap {
+ margin-top: 0;
+ }
+
+ .importPageSingleActions {
+ grid-area: actions;
+ justify-self: end;
+ align-self: start;
+ }
+
+ .importPageSingleSearchPanel {
+ grid-area: search;
+ padding-left: 0;
+ }
+
+ .importPageSingleSearchBar {
+ flex-direction: column;
+ }
+
+ .importPageSingleSearchResults {
+ max-height: 260px;
+ }
+
+ .importPageSingleResultItem {
+ gap: 12px;
+ padding: 12px;
+ }
+
+ .importPageQueueHeader {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .importPageQueueItem {
+ grid-template-columns: 40px minmax(0, 1fr);
+ grid-template-areas:
+ 'art info'
+ 'art progress';
+ gap: 8px 10px;
+ align-items: start;
+ padding: 10px 12px;
+ }
+
+ .importPageQueueArt {
+ grid-area: art;
+ }
+
+ .importPageQueueInfo {
+ grid-area: info;
+ }
+
+ .importPageQueueProgress {
+ grid-area: progress;
+ width: 100%;
+ }
+
+ .importPageQueueStatus {
+ text-align: left;
+ }
+
+ .autoImportControls {
+ padding: 8px 0 12px;
+ }
+
+ .autoImportToggleRow {
+ align-items: flex-start;
+ flex-wrap: wrap;
+ gap: 8px;
+ }
+
+ .autoImportSettingsRow {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .autoImportSetting {
+ width: 100%;
+ justify-content: space-between;
+ }
+
+ .autoImportConfidenceValue {
+ flex: 0 0 auto;
+ }
+
+ .autoImportFilters {
+ gap: 6px;
+ margin-bottom: 10px;
+ }
+
+ .autoImportFilters .importPageFlexSpacer {
+ display: none;
+ }
+
+ .autoImportFilters > button {
+ flex: 1 1 calc(50% - 6px);
+ min-width: 0;
+ }
+
+ .autoImportCard {
+ padding: 12px;
+ }
+
+ .autoImportCardTop {
+ display: grid;
+ grid-template-columns: 56px minmax(0, 1fr);
+ grid-template-areas:
+ 'art body'
+ 'right right';
+ align-items: start;
+ gap: 10px 12px;
+ }
+
+ .autoImportCardLeft {
+ grid-area: art;
+ }
+
+ .autoImportCardCenter {
+ grid-area: body;
+ width: auto;
+ }
+
+ .autoImportCardAlbum,
+ .autoImportCardArtist,
+ .autoImportCardError,
+ .autoImportCardFolderPath {
+ white-space: normal;
+ }
+
+ .autoImportCardRight {
+ grid-area: right;
+ width: 100%;
+ min-width: 0;
+ flex-direction: column;
+ align-items: stretch;
+ justify-content: flex-start;
+ gap: 8px;
+ }
+
+ .autoImportStatusBadge {
+ align-self: flex-start;
+ }
+
+ .autoImportConfidenceBar {
+ width: 100%;
+ }
+
+ .autoImportConfidenceText {
+ align-self: flex-start;
+ }
+
+ .autoImportActions {
+ width: 100%;
+ flex-direction: column;
+ }
+
+ .autoImportActions > button {
+ width: 100%;
+ }
+
+ .autoImportCardMeta {
+ flex-wrap: wrap;
+ row-gap: 4px;
+ }
+
+ .autoImportCardFolderPath {
+ line-height: 1.35;
+ }
+
+ .autoImportTrackListHeader {
+ display: none;
+ }
+
+ .autoImportTrackList.expanded {
+ padding-top: 6px;
+ }
+
+ .autoImportTrackRow {
+ grid-template-columns: minmax(0, 1fr) auto;
+ grid-template-areas:
+ 'name conf'
+ 'file conf';
+ gap: 2px 8px;
+ padding: 6px 0;
+ }
+
+ .autoImportTrackName {
+ grid-area: name;
+ white-space: normal;
+ }
+
+ .autoImportTrackFile {
+ grid-area: file;
+ white-space: normal;
+ }
+
+ .autoImportTrackConf {
+ grid-area: conf;
+ align-self: center;
+ }
+}
+
+@media (max-width: 480px) {
+ .importPageContainer {
+ padding: 12px 12px 16px;
+ }
+
+ .importPageTitle {
+ font-size: 20px;
+ }
+
+ .importPageTitleRow > button,
+ .importPageMatchActions > button,
+ .importPageMatchFooter > button,
+ .importPageQueueHeader > button,
+ .importPageSinglesActions > button,
+ .autoImportToggleRow > button,
+ .autoImportFilters > button {
+ width: 100%;
+ }
+
+ #import-page-album-clear-btn + button {
+ width: 100%;
+ }
+
+ .autoImportFilters > button {
+ flex: 1 1 100%;
+ }
+
+ .importPageAlbumGrid {
+ gap: 8px;
+ }
+
+ .importPageAlbumHero {
+ padding: 14px;
+ }
+
+ .importPageAlbumHero img {
+ width: 88px;
+ height: 88px;
+ }
+
+ .importPageMatchRow,
+ .importPageSingleItem,
+ .importPageQueueItem {
+ padding: 10px;
+ }
+
+ .importPageSingleSearchResults {
+ max-height: 220px;
+ }
+
+ .importPageSingleResultItem {
+ gap: 10px;
+ padding: 10px;
+ }
+
+ .importPageSingleResultImg {
+ width: 40px;
+ height: 40px;
+ }
+
+ .importPageSingleResultName {
+ font-size: 13px;
+ white-space: normal;
+ }
+
+ .importPageSingleResultDetail {
+ font-size: 11px;
+ white-space: normal;
+ }
+
+ .importPageSingleResultSelect {
+ padding: 4px 10px;
+ font-size: 11px;
+ }
+
+ .autoImportCard {
+ padding: 10px;
+ }
+
+ .autoImportCardTop {
+ grid-template-columns: 48px minmax(0, 1fr);
+ gap: 8px 10px;
+ }
+
+ .autoImportCardArt,
+ .autoImportCardArtFallback {
+ width: 48px;
+ height: 48px;
+ }
+
+ .autoImportConfidenceBar {
+ width: 100%;
+ }
+}
diff --git a/webui/src/routes/import/-ui/import-page.tsx b/webui/src/routes/import/-ui/import-page.tsx
new file mode 100644
index 00000000..a9483c3e
--- /dev/null
+++ b/webui/src/routes/import/-ui/import-page.tsx
@@ -0,0 +1,208 @@
+import { Link, Outlet } from '@tanstack/react-router';
+import clsx from 'clsx';
+
+import { Button } from '@/components/form/form';
+import { Show } from '@/components/primitives';
+import { useReactPageShell } from '@/platform/shell/route-controllers';
+
+import type { ImportQueueEntry } from '../-import.types';
+
+import {
+ getQueueProgressPercent,
+ getQueueStatusText,
+ getStagingStatsText,
+} from '../-import.helpers';
+import { useImportQueueWorkflow } from '../-import.store';
+import styles from './import-page.module.css';
+import { fallbackImage, RefreshIcon, useImportStaging } from './import-shared';
+
+export function ImportPage() {
+ useReactPageShell('import');
+
+ const { refreshStaging, stagingFiles, stagingPath, stagingQuery } = useImportStaging();
+ const isRefreshing = stagingQuery.isRefetching;
+ const lastRefreshedAt =
+ stagingQuery.dataUpdatedAt > 0 ? formatShortTime(stagingQuery.dataUpdatedAt) : null;
+
+ return (
+
+ );
+}
+
+function formatShortTime(timestamp: number) {
+ return new Date(timestamp).toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ });
+}
+
+function ImportHeader({
+ error,
+ fileCountText,
+ loading,
+ stagingPath,
+ refreshing,
+ lastRefreshedAt,
+ onRefresh,
+}: {
+ error: unknown;
+ fileCountText: string;
+ loading: boolean;
+ stagingPath: string;
+ refreshing: boolean;
+ lastRefreshedAt: string | null;
+ onRefresh: () => void;
+}) {
+ return (
+
+ );
+}
+
+function ImportProcessingQueue() {
+ const { clearFinishedJobs, queue } = useImportQueueWorkflow();
+ const hasFinished = queue.some((entry) => entry.status !== 'running');
+
+ return (
+
+
+ Processing
+
+ Clear finished
+
+
+
+ {queue.map((entry) => (
+
+ ))}
+
+
+ );
+}
+
+function ImportQueueItem({ entry }: { entry: ImportQueueEntry }) {
+ const statusText = getQueueStatusText(entry);
+ const statusClass = clsx({
+ [styles.error]:
+ entry.status === 'error' || (entry.status === 'done' && entry.errors.length > 0),
+ [styles.done]: entry.status === 'done',
+ });
+
+ return (
+
+ {entry.imageUrl ? (
+
+ ) : (
+
♪
+ )}
+
+
{entry.label}
+
{entry.sublabel}
+
+
+
+ );
+}
+
+function ImportTabNav() {
+ return (
+
+
+ Auto
+
+
+ Albums
+
+
+ Singles
+
+
+ );
+}
diff --git a/webui/src/routes/import/-ui/import-shared.tsx b/webui/src/routes/import/-ui/import-shared.tsx
new file mode 100644
index 00000000..61b3b93e
--- /dev/null
+++ b/webui/src/routes/import/-ui/import-shared.tsx
@@ -0,0 +1,109 @@
+import { useQuery, useQueryClient } from '@tanstack/react-query';
+
+import type { ImportQueueJob, ImportStagingFile } 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';
+
+const EMPTY_STAGING_FILES: ImportStagingFile[] = [];
+
+export function useImportStaging() {
+ const queryClient = useQueryClient();
+ const clearFinishedJobs = useImportWorkflowStore((state) => state.clearFinishedJobs);
+ const stagingQuery = useQuery({
+ ...importStagingFilesQueryOptions(),
+ });
+
+ return {
+ refreshStaging: async () => {
+ clearFinishedJobs();
+ await invalidateImportStagingQueries(queryClient);
+ },
+ // Keep the empty fallback stable so staging-driven effects do not loop while loading.
+ stagingFiles: stagingQuery.data?.files ?? EMPTY_STAGING_FILES,
+ stagingPath: stagingQuery.data?.staging_path || 'Not configured',
+ stagingQuery,
+ };
+}
+
+export function useImportQueueActions() {
+ const queryClient = useQueryClient();
+ const { enqueueQueueJob, updateQueueEntry } = useImportQueueWorkflow();
+
+ const runQueueJob = async (entryId: number, job: ImportQueueJob) => {
+ let processed = 0;
+ const errors: string[] = [];
+
+ for (let index = 0; index < job.items.length; index += 1) {
+ const itemName =
+ job.type === 'album'
+ ? getTrackDisplayInfo(job.items[index], index).name
+ : job.items[index].title || job.items[index].filename || `File ${index + 1}`;
+
+ updateQueueEntry(entryId, {
+ sublabel: `Processing ${index + 1}/${job.items.length}: ${itemName}`,
+ processed,
+ errors: [...errors],
+ });
+
+ try {
+ const payload =
+ job.type === 'album'
+ ? await processImportAlbumTrack({
+ album: job.albumData,
+ match: job.items[index],
+ })
+ : await processImportSingleFile(job.items[index]);
+
+ processed += payload.processed || 0;
+ if (payload.errors?.length) {
+ errors.push(...payload.errors);
+ }
+ } catch (error) {
+ errors.push(`${itemName}: ${getErrorMessage(error)}`);
+ }
+
+ updateQueueEntry(entryId, {
+ processed,
+ errors: [...errors],
+ });
+ }
+
+ updateQueueEntry(entryId, {
+ status: errors.length > 0 && processed === 0 ? 'error' : 'done',
+ processed,
+ errors,
+ });
+ void invalidateImportStagingQueries(queryClient);
+ };
+
+ return {
+ addQueueJob: (job: ImportQueueJob) => {
+ const id = enqueueQueueJob(job);
+ void runQueueJob(id, job);
+ },
+ };
+}
+
+export function RefreshIcon() {
+ return (
+
+
+
+ );
+}
+
+export function fallbackImage(event: { currentTarget: HTMLImageElement }) {
+ if (event.currentTarget.src.endsWith(IMPORT_PLACEHOLDER_IMAGE)) return;
+ event.currentTarget.src = IMPORT_PLACEHOLDER_IMAGE;
+}
+
+export function getErrorMessage(error: unknown): string {
+ return error instanceof Error ? error.message : 'Unknown error';
+}
diff --git a/webui/src/routes/import/-ui/singles-import-tab.tsx b/webui/src/routes/import/-ui/singles-import-tab.tsx
new file mode 100644
index 00000000..7aac6728
--- /dev/null
+++ b/webui/src/routes/import/-ui/singles-import-tab.tsx
@@ -0,0 +1,336 @@
+import clsx from 'clsx';
+import { useEffect } from 'react';
+
+import { Button, Checkbox, TextInput } from '@/components/form/form';
+import { Badge, Notice } from '@/components/primitives';
+
+import type { SingleSearchState } from '../-import.store';
+import type { ImportTrackResult } from '../-import.types';
+import type { ImportStagingFile } from '../-import.types';
+
+import { searchImportTracks } from '../-import.api';
+import { formatDuration, getStagingFileKey } from '../-import.helpers';
+import { useSinglesImportWorkflow } from '../-import.store';
+import styles from './import-page.module.css';
+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,
+ syncSinglesWorkflow,
+ toggleAllSingles,
+ toggleSingleInStore,
+ } = useSinglesImportWorkflow();
+
+ useEffect(() => {
+ syncSinglesWorkflow(stagingFiles);
+ }, [stagingFiles, syncSinglesWorkflow]);
+
+ const openSingleSearchPanel = (file: ImportStagingFile) => {
+ const fileKey = getStagingFileKey(file);
+ if (openSingleSearch === fileKey) {
+ setOpenSingleSearch(null);
+ return;
+ }
+
+ setOpenSingleSearch(fileKey);
+ const defaultQuery =
+ [file?.artist, file?.title].filter(Boolean).join(' ') ||
+ (file?.filename || '').replace(/\.[^.]+$/, '');
+ ensureSingleSearch(fileKey, defaultQuery);
+ if (defaultQuery && !singleSearches[fileKey]?.results.length) {
+ void runSingleSearch(fileKey, defaultQuery);
+ }
+ };
+
+ const runSingleSearch = async (fileKey: string, query: string) => {
+ const trimmed = query.trim();
+ if (!trimmed) return;
+
+ setSingleSearch(fileKey, (current) => ({
+ query: trimmed,
+ loading: true,
+ error: null,
+ results: current.results,
+ }));
+
+ try {
+ const payload = await searchImportTracks(trimmed);
+ setSingleSearch(fileKey, {
+ query: trimmed,
+ loading: false,
+ error: null,
+ results: payload.tracks ?? [],
+ });
+ } catch (error) {
+ setSingleSearch(fileKey, {
+ query: trimmed,
+ loading: false,
+ error: getErrorMessage(error),
+ results: [],
+ });
+ }
+ };
+
+ const selectSingleMatch = (fileKey: string, track: ImportTrackResult) => {
+ selectSingleMatchInStore(fileKey, track);
+ };
+
+ const processSingles = () => {
+ const filesToProcess = stagingFiles.flatMap((file) => {
+ const fileKey = getStagingFileKey(file);
+ if (!selectedSingles.has(fileKey)) return [];
+ const manualMatch = singlesManualMatches[fileKey];
+ 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();
+ void refreshStaging();
+ };
+
+ return (
+ {
+ setSingleSearch(fileKey, (current) => ({
+ query,
+ loading: current.loading,
+ error: current.error,
+ results: current.results,
+ }));
+ }}
+ onSelectAll={() => toggleAllSingles(stagingFiles)}
+ onSelectMatch={selectSingleMatch}
+ onToggleSingle={toggleSingleInStore}
+ />
+ );
+}
+
+export function SinglesImportPanel({
+ files,
+ manualMatches,
+ openSearchKey,
+ searchStates,
+ selected,
+ onOpenSearch,
+ onProcessSingles,
+ onRunSearch,
+ onSearchQueryChange,
+ onSelectAll,
+ onSelectMatch,
+ onToggleSingle,
+}: {
+ files: ImportStagingFile[];
+ manualMatches: Record;
+ openSearchKey: string | null;
+ searchStates: Record;
+ selected: Set;
+ onOpenSearch: (file: ImportStagingFile) => void;
+ onProcessSingles: () => void;
+ onRunSearch: (fileKey: string, query: string) => void;
+ onSearchQueryChange: (fileKey: string, query: string) => void;
+ onSelectAll: () => void;
+ onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
+ onToggleSingle: (fileKey: string) => void;
+}) {
+ const selectedCount = files.filter((file) => selected.has(getStagingFileKey(file))).length;
+ const allSelected = files.length > 0 && selectedCount === files.length;
+ const processVariant = selectedCount > 0 ? 'primary' : 'secondary';
+
+ return (
+ <>
+
+
+
+
+ {allSelected ? 'Deselect All' : 'Select All'}
+
+
+
+ Process Selected
+ {selectedCount}
+
+
+
+
+ {files.length === 0 ? (
+
No audio files found in import folder
+ ) : (
+ files.map((file) => {
+ const fileKey = getStagingFileKey(file);
+ const manualMatch = manualMatches[fileKey];
+ const isSelected = selected.has(fileKey);
+ const searchState = searchStates[fileKey];
+ return (
+
+
+ onToggleSingle(fileKey)}
+ />
+
+
+
{file.filename}
+
+ {file.title ? {file.title} : null}
+ {file.artist ? {file.artist} : null}
+ {file.extension ? {file.extension} : null}
+
+ {manualMatch ? (
+
+ ✓ {manualMatch.name} - {manualMatch.artist}
+ onOpenSearch(file)}
+ >
+ change
+
+
+ ) : null}
+
+
+ onOpenSearch(file)}>
+ 🔍 Identify
+
+
+ {openSearchKey === fileKey ? (
+
+ ) : null}
+
+ );
+ })
+ )}
+
+ >
+ );
+}
+
+function SingleSearchPanel({
+ fileKey,
+ searchState,
+ onQueryChange,
+ onRunSearch,
+ onSelectMatch,
+}: {
+ fileKey: string;
+ searchState: SingleSearchState | undefined;
+ onQueryChange: (fileKey: string, query: string) => void;
+ onRunSearch: (fileKey: string, query: string) => void;
+ onSelectMatch: (fileKey: string, track: ImportTrackResult) => void;
+}) {
+ const query = searchState?.query ?? '';
+
+ return (
+
+
+ onQueryChange(fileKey, event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === 'Enter') onRunSearch(fileKey, query);
+ }}
+ />
+ onRunSearch(fileKey, query)}>
+ Search
+
+
+
+ {searchState?.loading ? (
+
Searching...
+ ) : searchState?.error ? (
+
+ Error: {searchState.error}
+
+ ) : searchState?.results.length === 0 ? (
+
No results found
+ ) : (
+ searchState?.results.map((track, index) => (
+
onSelectMatch(fileKey, track)}
+ >
+ {track.image_url ? (
+
+ ) : null}
+
+
+ {track.name} - {track.artist}
+
+
+ {track.album}
+ {track.duration_ms ? ` - ${formatDuration(track.duration_ms)}` : ''}
+
+
+ Select
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/webui/src/routes/import/album.tsx b/webui/src/routes/import/album.tsx
new file mode 100644
index 00000000..6c4a598a
--- /dev/null
+++ b/webui/src/routes/import/album.tsx
@@ -0,0 +1,15 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+import {
+ importStagingGroupsQueryOptions,
+ importStagingSuggestionsQueryOptions,
+} from './-import.api';
+import { AlbumImportTab } from './-ui/album-import-tab';
+
+export const Route = createFileRoute('/import/album')({
+ loader: async ({ context }) => {
+ void context.queryClient.prefetchQuery(importStagingGroupsQueryOptions());
+ void context.queryClient.prefetchQuery(importStagingSuggestionsQueryOptions());
+ },
+ component: AlbumImportTab,
+});
diff --git a/webui/src/routes/import/auto.tsx b/webui/src/routes/import/auto.tsx
new file mode 100644
index 00000000..f2b3a1c6
--- /dev/null
+++ b/webui/src/routes/import/auto.tsx
@@ -0,0 +1,27 @@
+import { useNavigate } from '@tanstack/react-router';
+import { createFileRoute } from '@tanstack/react-router';
+
+import type { ImportAutoFilter } from './-import.types';
+
+import { importAutoSearchSchema } from './-import.types';
+import { AutoImportPanel } from './-ui/auto-import-tab';
+
+export const Route = createFileRoute('/import/auto')({
+ validateSearch: importAutoSearchSchema,
+ component: AutoImportRoute,
+});
+
+function AutoImportRoute() {
+ const navigate = useNavigate({ from: Route.fullPath });
+ const { autoFilter } = Route.useSearch();
+
+ const setAutoFilter = (nextFilter: ImportAutoFilter) => {
+ void navigate({
+ to: Route.fullPath,
+ search: (prev) => ({ ...prev, autoFilter: nextFilter }),
+ replace: true,
+ });
+ };
+
+ return ;
+}
diff --git a/webui/src/routes/import/index.tsx b/webui/src/routes/import/index.tsx
new file mode 100644
index 00000000..72f25616
--- /dev/null
+++ b/webui/src/routes/import/index.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute, redirect } from '@tanstack/react-router';
+
+export const Route = createFileRoute('/import/')({
+ beforeLoad: () => {
+ throw redirect({ to: '/import/album', replace: true });
+ },
+});
diff --git a/webui/src/routes/import/route.tsx b/webui/src/routes/import/route.tsx
new file mode 100644
index 00000000..e91f9658
--- /dev/null
+++ b/webui/src/routes/import/route.tsx
@@ -0,0 +1,22 @@
+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: ({ context }) => {
+ // Warm the staging query if possible, but never block the route on a transient fetch
+ // failure. The page owns the in-place error state for that case.
+ void context.queryClient.prefetchQuery(importStagingFilesQueryOptions());
+ },
+ component: ImportPage,
+});
diff --git a/webui/src/routes/import/singles.tsx b/webui/src/routes/import/singles.tsx
new file mode 100644
index 00000000..89a4621b
--- /dev/null
+++ b/webui/src/routes/import/singles.tsx
@@ -0,0 +1,7 @@
+import { createFileRoute } from '@tanstack/react-router';
+
+import { SinglesImportTab } from './-ui/singles-import-tab';
+
+export const Route = createFileRoute('/import/singles')({
+ component: SinglesImportTab,
+});
diff --git a/webui/static/init.js b/webui/static/init.js
index 6b760b0c..10a047b4 100644
--- a/webui/static/init.js
+++ b/webui/static/init.js
@@ -2420,9 +2420,6 @@ async function loadPageData(pageId) {
loadApiKeys();
loadBlacklistCount();
break;
- case 'import':
- initializeImportPage();
- break;
case 'hydrabase':
// Check connection status and pre-fill saved credentials
try {
diff --git a/webui/static/mobile.css b/webui/static/mobile.css
index 043ea773..b920708f 100644
--- a/webui/static/mobile.css
+++ b/webui/static/mobile.css
@@ -1834,7 +1834,7 @@
/* --- Phase 9: Touch & Hover Adaptations --- */
/* Global touch targets - only standalone/action buttons, not inline */
- button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close) {
+ button:not(.watchlist-card-remove):not(.wishlist-delete-btn):not(.wishlist-delete-album-btn):not(.wishlist-delete-btn-small):not(.wishlist-back-btn):not(.alphabet-btn):not(.filter-btn):not(.playlist-modal-close):not([data-import-page-result-row]):not([data-import-page-inline-action]) {
min-height: 38px;
}
@@ -2312,86 +2312,9 @@
opacity: 0.7;
transition: opacity 0.1s ease;
}
-
- /* Import Page - Touch fallback */
- .import-page-file-chip {
- cursor: pointer;
- }
-
- .import-page-match-row {
- cursor: pointer;
- }
}
-/* Import Page — small screen */
@media (max-width: 768px) {
- .import-page-container {
- padding: 16px;
- }
-
- .import-page-title {
- font-size: 22px;
- }
-
- .import-page-album-grid {
- grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
- gap: 10px;
- }
-
- .import-page-album-hero {
- flex-direction: column;
- text-align: center;
- }
-
- .import-page-album-hero img {
- width: 100px;
- height: 100px;
- }
-
- .import-page-match-row {
- grid-template-columns: 28px 1fr;
- gap: 8px;
- }
-
- .import-page-match-file {
- grid-column: 1 / -1;
- padding-left: 28px;
- }
-
- .import-page-match-unmatch {
- grid-column: 1 / -1;
- justify-self: end;
- }
-
- .import-page-singles-header {
- flex-direction: column;
- align-items: flex-start;
- }
-
- .import-page-singles-actions {
- width: 100%;
- flex-wrap: wrap;
- }
-
- .import-page-single-item {
- grid-template-columns: 28px 1fr;
- }
-
- .import-page-single-actions {
- grid-column: 1 / -1;
- justify-self: end;
- }
-
- .import-page-single-search-panel {
- padding-left: 0;
- }
-
- .import-page-staging-bar {
- flex-direction: column;
- gap: 4px;
- align-items: flex-start;
- }
-
/* Profile Picker - Mobile */
.profile-picker-grid {
gap: 20px;
diff --git a/webui/static/search.js b/webui/static/search.js
index 7a2214fc..cd27d34e 100644
--- a/webui/static/search.js
+++ b/webui/static/search.js
@@ -1190,9 +1190,7 @@ async function loadInitialData() {
if (route?.kind === 'react') {
showReactHost(targetPage);
setActivePageChrome(targetPage);
- if (window.location.pathname !== route.path) {
- history.replaceState({ page: targetPage }, '', route.path);
- }
+ // Keep nested react-tab URLs like /import/auto or /import/singles intact.
return;
}
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js
index 68772b35..9723c549 100644
--- a/webui/static/stats-automations.js
+++ b/webui/static/stats-automations.js
@@ -1,1377 +1,3 @@
-// IMPORT PAGE (full page, replaces old modal)
-// ===================================================================
-
-let importJobIdCounter = 0;
-
-const importPageState = {
- stagingFiles: [],
- selectedSingles: new Set(),
- albumData: null, // response from /api/import/album/match
- matchOverrides: {}, // { trackIndex: stagingFileIndex } — manual drag-drop overrides
- singlesManualMatches: {}, // { stagingFileIndex: { id, name, artist, album, ... } }
- initialized: false,
- activeTab: 'album',
- tapSelectedChip: null, // for mobile tap-to-assign fallback
- // Album lookup cache for click handlers. Populated by suggestions /
- // search renderers; read by importPageSelectAlbum so the match POST
- // can include `source` + `name` + `artist` (without these, backend
- // can't look up cross-source IDs and falls back to a broken
- // "Unknown Artist / album_id as title / 0 tracks" placeholder —
- // github issue #524).
- _albumLookup: {}, // { albumId: { id, name, artist, source } }
-};
-
-// --- Initialization ---
-
-function initializeImportPage() {
- if (!importPageState.initialized) {
- importPageState.initialized = true;
- importPageRefreshStaging();
- importPageLoadAutoGroups();
- importPageLoadSuggestions();
- }
-}
-
-async function importPageRefreshStaging() {
- // Clear finished jobs from the queue
- importPageClearFinishedJobs();
-
- try {
- const resp = await fetch('/api/import/staging/files');
- const data = await resp.json();
- if (!data.success) {
- document.getElementById('import-page-staging-path').textContent = `Import folder: error`;
- return;
- }
-
- importPageState.stagingFiles = data.files || [];
- document.getElementById('import-page-staging-path').textContent = `Import: ${data.staging_path || 'Not configured'}`;
-
- const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0);
- const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB`
- : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB`
- : `${(totalSize / 1024).toFixed(0)} KB`;
- document.getElementById('import-page-staging-stats').textContent =
- `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`;
-
- // Refresh the current tab view after data is loaded
- if (importPageState.activeTab === 'singles') {
- importPageRenderSinglesList();
- } else if (importPageState.activeTab === 'album') {
- importPageLoadAutoGroups();
- }
- // Always refresh suggestions and groups in background
- importPageLoadSuggestions();
- } catch (err) {
- console.error('Failed to refresh staging:', err);
- }
-}
-
-function importPageSwitchTab(tab) {
- importPageState.activeTab = tab;
- document.getElementById('import-page-tab-album').classList.toggle('active', tab === 'album');
- document.getElementById('import-page-tab-singles').classList.toggle('active', tab === 'singles');
- document.getElementById('import-page-tab-auto')?.classList.toggle('active', tab === 'auto');
- document.getElementById('import-page-album-content').classList.toggle('active', tab === 'album');
- document.getElementById('import-page-singles-content')?.classList.toggle('active', tab === 'singles');
- document.getElementById('import-page-auto-content')?.classList.toggle('active', tab === 'auto');
-
- if (tab === 'singles' && importPageState.stagingFiles.length > 0) {
- importPageRenderSinglesList();
- }
- if (tab === 'auto') {
- _autoImportLoadStatus();
- _autoImportLoadResults();
- _autoImportStartPolling();
- } else {
- _autoImportStopPolling();
- }
-}
-
-// ── Auto-Import Tab ──
-let _autoImportPollInterval = null;
-let _autoImportFilter = 'all';
-let _autoImportLastStatus = null;
-
-function _autoImportStartPolling() {
- _autoImportStopPolling();
- _autoImportPollInterval = setInterval(async () => {
- if (importPageState.activeTab === 'auto') {
- await _autoImportLoadStatus();
- _autoImportLoadResults();
- }
- }, 5000);
-}
-
-function _autoImportStopPolling() {
- if (_autoImportPollInterval) { clearInterval(_autoImportPollInterval); _autoImportPollInterval = null; }
-}
-
-async function _autoImportToggle(enabled) {
- // Optimistically update toggle state so it doesn't flicker
- const toggle = document.getElementById('auto-import-enabled');
- if (toggle) toggle.checked = enabled;
- const statusText = document.getElementById('auto-import-status-text');
- if (statusText) statusText.textContent = enabled ? 'Starting...' : 'Stopping...';
-
- try {
- const res = await fetch('/api/auto-import/toggle', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ enabled })
- });
- const data = await res.json();
- if (data.success) {
- showToast(enabled ? 'Auto-import enabled' : 'Auto-import disabled', 'success');
- _autoImportLoadStatus();
- } else {
- // Revert on failure
- if (toggle) toggle.checked = !enabled;
- }
- } catch (e) {
- showToast('Error: ' + e.message, 'error');
- if (toggle) toggle.checked = !enabled;
- }
-}
-
-async function _autoImportLoadStatus() {
- try {
- const res = await fetch('/api/auto-import/status');
- const data = await res.json();
- if (!data.success) return;
- _autoImportLastStatus = data;
-
- const toggle = document.getElementById('auto-import-enabled');
- const statusText = document.getElementById('auto-import-status-text');
- const settingsRow = document.getElementById('auto-import-settings-row');
- const scanNowBtn = document.getElementById('auto-import-scan-now');
- const progressEl = document.getElementById('auto-import-progress');
- const progressText = document.getElementById('auto-import-progress-text');
-
- if (toggle) toggle.checked = data.running;
- if (settingsRow) settingsRow.style.display = data.running ? '' : 'none';
- if (scanNowBtn) scanNowBtn.style.display = data.running ? '' : 'none';
-
- // Live scan + per-track processing progress.
- // `active_imports` (added when the worker switched to a bounded
- // executor pool) is the source of truth; multiple albums can be
- // in flight at once. Render each one on its own line; fall back
- // to the legacy single-line summary for older backend payloads.
- if (progressEl) {
- const active = Array.isArray(data.active_imports) ? data.active_imports : [];
- if (active.length > 0) {
- progressEl.style.display = '';
- if (progressText) {
- const lines = active.map(a => {
- const folder = a.folder_name || '...';
- const idx = a.track_index || 0;
- const total = a.track_total || 0;
- const trackName = a.track_name || '';
- if (a.status === 'processing' && total > 0) {
- return `${folder} — track ${idx}/${total}: ${trackName}`;
- }
- if (a.status === 'matching') return `${folder} — matching tracks…`;
- if (a.status === 'identifying') return `${folder} — identifying…`;
- return `${folder} — queued`;
- });
- progressText.textContent = lines.length === 1
- ? `Processing ${lines[0]}`
- : `Processing ${lines.length} imports:\n${lines.join('\n')}`;
- }
- } else if (data.current_status === 'scanning') {
- progressEl.style.display = '';
- if (progressText) {
- const stats = data.stats || {};
- progressText.textContent = `Scanning… (${stats.scanned || 0} processed)`;
- }
- } else {
- progressEl.style.display = 'none';
- }
- }
-
- if (statusText) {
- if (data.paused) statusText.textContent = 'Paused';
- else if (data.current_status === 'processing') statusText.textContent = 'Processing...';
- else if (data.current_status === 'scanning') statusText.textContent = 'Scanning...';
- else if (data.running) {
- // Show last scan time
- let watchText = 'Watching';
- if (data.last_scan_time) {
- try {
- const lastScan = new Date(data.last_scan_time);
- const diffS = Math.floor((Date.now() - lastScan) / 1000);
- if (diffS < 60) watchText = `Watching (scanned ${diffS}s ago)`;
- else if (diffS < 3600) watchText = `Watching (scanned ${Math.floor(diffS / 60)}m ago)`;
- } catch (e) {}
- }
- statusText.textContent = watchText;
- } else statusText.textContent = 'Disabled';
- const _runningClass = data.current_status === 'scanning'
- ? 'scanning'
- : data.current_status === 'processing'
- ? 'processing'
- : 'active';
- statusText.className = 'auto-import-status ' + (data.running ? _runningClass : 'disabled');
- }
- } catch (e) {}
-}
-
-async function _autoImportLoadResults() {
- const container = document.getElementById('auto-import-results');
- if (!container) return;
- try {
- const res = await fetch('/api/auto-import/results?limit=100');
- const data = await res.json();
- if (!data.success || !data.results || data.results.length === 0) {
- if (!container.querySelector('.auto-import-card')) {
- container.innerHTML = `
-
No imports yet. Drop album folders or single tracks into your import folder.
-
`;
- }
- // Hide stats and filters
- const statsEl = document.getElementById('auto-import-stats');
- const filtersEl = document.getElementById('auto-import-filters');
- if (statsEl) statsEl.style.display = 'none';
- if (filtersEl) filtersEl.style.display = 'none';
- return;
- }
-
- // Compute stats
- const allResults = data.results;
- const importedCount = allResults.filter(r => r.status === 'completed' || r.status === 'approved').length;
- const reviewCount = allResults.filter(r => r.status === 'pending_review').length;
- const failedCount = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification').length;
-
- // Update stats
- const statsEl = document.getElementById('auto-import-stats');
- if (statsEl) {
- statsEl.style.display = '';
- document.getElementById('auto-import-stat-imported').textContent = `${importedCount} imported`;
- document.getElementById('auto-import-stat-review').textContent = `${reviewCount} review`;
- document.getElementById('auto-import-stat-failed').textContent = `${failedCount} failed`;
- }
-
- // Show filters
- const filtersEl = document.getElementById('auto-import-filters');
- if (filtersEl) {
- filtersEl.style.display = '';
- // Show batch action buttons when applicable
- const approveAllBtn = document.getElementById('auto-import-approve-all');
- const clearBtn = document.getElementById('auto-import-clear-completed');
- if (approveAllBtn) approveAllBtn.style.display = reviewCount > 0 ? '' : 'none';
- if (clearBtn) clearBtn.style.display = (importedCount + failedCount) > 0 ? '' : 'none';
- }
-
- // Apply filter
- let filtered = allResults;
- if (_autoImportFilter === 'pending') filtered = allResults.filter(r => r.status === 'pending_review');
- else if (_autoImportFilter === 'imported') filtered = allResults.filter(r => r.status === 'completed' || r.status === 'approved');
- else if (_autoImportFilter === 'failed') filtered = allResults.filter(r => r.status === 'failed' || r.status === 'needs_identification');
-
- if (filtered.length === 0) {
- const filterName = _autoImportFilter === 'pending' ? 'pending review' : _autoImportFilter;
- container.innerHTML = ``;
- return;
- }
-
- container.innerHTML = filtered.map((r, idx) => {
- const confPct = Math.round((r.confidence || 0) * 100);
- const confClass = confPct >= 90 ? 'high' : confPct >= 70 ? 'medium' : 'low';
- const statusLabels = {
- 'completed': 'Imported', 'pending_review': 'Needs Review',
- 'needs_identification': 'Unidentified', 'failed': 'Failed',
- 'scanning': 'Scanning...', 'matched': 'Matched',
- 'rejected': 'Dismissed', 'approved': 'Approved',
- 'processing': 'Processing',
- };
- const statusIcons = {
- 'completed': '\u2713', 'pending_review': '\u26A0',
- 'needs_identification': '\u2717', 'failed': '\u2717',
- 'scanning': '\u231B', 'matched': '\u2713',
- 'rejected': '\u2715', 'approved': '\u2713',
- 'processing': '\u29D7',
- };
- const statusLabel = statusLabels[r.status] || r.status;
- const statusIcon = statusIcons[r.status] || '';
- const statusClass = r.status === 'completed' ? 'completed' : r.status === 'pending_review' ? 'review' :
- r.status === 'failed' || r.status === 'needs_identification' ? 'failed' :
- r.status === 'processing' ? 'processing' : 'neutral';
-
- // Live per-track progress for the row currently being processed.
- // Match by folder_hash through the `active_imports` array
- // — the worker now runs multiple imports in parallel via a
- // bounded executor pool, so `current_folder` alone can't
- // identify a row's live state.
- const liveStatus = _autoImportLastStatus;
- const liveActive = (liveStatus && Array.isArray(liveStatus.active_imports))
- ? liveStatus.active_imports.find(a => a.folder_hash === r.folder_hash)
- : null;
- const isLiveProcessing = r.status === 'processing'
- && liveActive && liveActive.status === 'processing';
- const liveTrackIdx = isLiveProcessing ? (liveActive.track_index || 0) : 0;
- const liveTrackTotal = isLiveProcessing ? (liveActive.track_total || 0) : 0;
- const liveTrackName = isLiveProcessing ? (liveActive.track_name || '') : '';
-
- // Parse match data for track details
- let matchCount = 0, totalTracks = 0, trackDetails = [];
- if (r.match_data) {
- try {
- const md = typeof r.match_data === 'string' ? JSON.parse(r.match_data) : r.match_data;
- matchCount = md.matched_count || 0;
- totalTracks = md.total_tracks || 0;
- if (md.matches) {
- trackDetails = md.matches.map(m => ({
- name: m.track_name || m.track?.name || 'Unknown',
- file: m.file ? m.file.split(/[/\\]/).pop() : '?',
- confidence: Math.round((m.confidence || 0) * 100),
- }));
- }
- } catch (e) {}
- }
-
- let matchSummary = totalTracks > 0 ? `${matchCount}/${totalTracks} tracks` : `${r.total_files} files`;
- if (isLiveProcessing && liveTrackTotal > 0) {
- matchSummary = `track ${liveTrackIdx}/${liveTrackTotal}: ${liveTrackName}`;
- }
- const methodLabels = { tags: 'Tags', folder_name: 'Folder Name', acoustid: 'AcoustID', filename: 'Filename' };
- const methodLabel = methodLabels[r.identification_method] || r.identification_method || '';
-
- // Time ago
- let timeAgo = '';
- if (r.created_at) {
- try {
- const d = new Date(r.created_at);
- const diffM = Math.floor((Date.now() - d) / 60000);
- if (diffM < 1) timeAgo = 'just now';
- else if (diffM < 60) timeAgo = `${diffM}m ago`;
- else if (diffM < 1440) timeAgo = `${Math.floor(diffM / 60)}h ago`;
- else timeAgo = `${Math.floor(diffM / 1440)}d ago`;
- } catch (e) {}
- }
-
- let actions = '';
- if (r.status === 'pending_review') {
- actions = `
- Approve & Import
- Dismiss
-
`;
- }
-
- // Expanded track list (hidden by default)
- let trackListHtml = '';
- if (trackDetails.length > 0) {
- trackListHtml = `
-
- ${trackDetails.map((t, tIdx) => {
- const tConfClass = t.confidence >= 90 ? 'high' : t.confidence >= 70 ? 'medium' : 'low';
- // 1-based liveTrackIdx — current row glows, prior rows dim as "done".
- let rowState = '';
- if (isLiveProcessing && liveTrackIdx > 0) {
- if (tIdx + 1 === liveTrackIdx) rowState = ' auto-import-track-row-active';
- else if (tIdx + 1 < liveTrackIdx) rowState = ' auto-import-track-row-done';
- }
- return `
- ${escapeHtml(t.name)}
- ${escapeHtml(t.file)}
- ${t.confidence}%
-
`;
- }).join('')}
-
`;
- }
-
- return `
-
-
- ${r.image_url ? `
\uD83D\uDCBF
` : `
\uD83D\uDCBF
`}
-
-
-
${escapeHtml(r.album_name || r.folder_name)}
-
${escapeHtml(r.artist_name || 'Unknown Artist')}
-
- ${matchSummary}
- ${methodLabel ? `${methodLabel} ` : ''}
- ${timeAgo ? `${timeAgo} ` : ''}
-
- ${r.error_message ? `
${escapeHtml(r.error_message)}
` : ''}
-
-
-
${statusIcon} ${statusLabel}
-
-
${confPct}% confidence
- ${actions}
-
-
-
${escapeHtml(r.folder_name)}
- ${trackListHtml}
-
`;
- }).join('');
-
- } catch (e) {}
-}
-
-async function _autoImportSaveSettings() {
- const confidence = (document.getElementById('auto-import-confidence')?.value || 90) / 100;
- const interval = parseInt(document.getElementById('auto-import-interval')?.value || 60);
- try {
- await fetch('/api/auto-import/settings', {
- method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ confidence_threshold: confidence, scan_interval: interval })
- });
- showToast('Settings saved', 'success');
- } catch (e) { showToast('Error', 'error'); }
-}
-
-function _autoImportSetFilter(filter) {
- _autoImportFilter = filter;
- document.querySelectorAll('#auto-import-filters .adl-pill').forEach(p =>
- p.classList.toggle('active', p.dataset.filter === filter));
- _autoImportLoadResults();
-}
-
-async function _autoImportScanNow() {
- try {
- const res = await fetch('/api/auto-import/scan-now', { method: 'POST' });
- const data = await res.json();
- if (data.success) {
- showToast('Scan triggered', 'success');
- _autoImportLoadStatus();
- } else {
- showToast(data.error || 'Failed to trigger scan', 'error');
- }
- } catch (e) { showToast('Error: ' + e.message, 'error'); }
-}
-
-async function _autoImportApproveAll() {
- const confirmed = await showConfirmDialog({
- title: 'Approve All',
- message: 'Approve and import all pending review items?',
- confirmText: 'Approve All',
- });
- if (!confirmed) return;
- try {
- const res = await fetch('/api/auto-import/approve-all', { method: 'POST' });
- const data = await res.json();
- if (data.success) {
- showToast(`Approved ${data.count || 0} items`, 'success');
- _autoImportLoadResults();
- } else {
- showToast(data.error || 'Failed', 'error');
- }
- } catch (e) { showToast('Error: ' + e.message, 'error'); }
-}
-
-async function _autoImportClearCompleted() {
- try {
- const res = await fetch('/api/auto-import/clear-completed', { method: 'POST' });
- const data = await res.json();
- if (data.success) {
- showToast(`Cleared ${data.count || 0} imported items`, 'success');
- _autoImportLoadResults();
- } else {
- showToast(data.error || 'Failed', 'error');
- }
- } catch (e) { showToast('Error: ' + e.message, 'error'); }
-}
-
-function _autoImportToggleDetail(idx) {
- const trackList = document.getElementById(`auto-import-tracks-${idx}`);
- if (trackList) {
- trackList.classList.toggle('expanded');
- }
-}
-window._autoImportToggleDetail = _autoImportToggleDetail;
-window._autoImportSetFilter = _autoImportSetFilter;
-window._autoImportScanNow = _autoImportScanNow;
-window._autoImportApproveAll = _autoImportApproveAll;
-window._autoImportClearCompleted = _autoImportClearCompleted;
-
-async function _autoImportApprove(id) {
- try {
- const res = await fetch(`/api/auto-import/approve/${id}`, { method: 'POST' });
- const data = await res.json();
- if (data.success) { showToast('Approved', 'success'); _autoImportLoadResults(); }
- else showToast(data.error || 'Failed', 'error');
- } catch (e) { showToast('Error', 'error'); }
-}
-
-async function _autoImportReject(id) {
- try {
- const res = await fetch(`/api/auto-import/reject/${id}`, { method: 'POST' });
- const data = await res.json();
- if (data.success) { showToast('Dismissed', 'success'); _autoImportLoadResults(); }
- else showToast(data.error || 'Failed', 'error');
- } catch (e) { showToast('Error', 'error'); }
-}
-
-// --- Album Tab: Auto-Detected Groups (from file tags) ---
-
-async function importPageLoadAutoGroups() {
- const grid = document.getElementById('import-page-suggestions-grid');
- if (!grid) return;
-
- try {
- const resp = await fetch('/api/import/staging/groups');
- if (!resp.ok) return;
- const data = await resp.json();
-
- if (!data.success || !data.groups || data.groups.length === 0) return;
-
- // Build auto-groups section above suggestions
- let groupsContainer = document.getElementById('import-page-auto-groups');
- if (!groupsContainer) {
- groupsContainer = document.createElement('div');
- groupsContainer.id = 'import-page-auto-groups';
- groupsContainer.style.marginBottom = '16px';
- const suggestionsSection = document.getElementById('import-page-suggestions');
- if (suggestionsSection) {
- suggestionsSection.parentNode.insertBefore(groupsContainer, suggestionsSection);
- } else {
- grid.parentNode.insertBefore(groupsContainer, grid);
- }
- }
-
- groupsContainer.innerHTML = `
-
- Auto-Detected Albums
-
-
- ${data.groups.map((g, idx) => `
-
-
- ${g.file_count}
-
-
-
${_esc(g.album)}
-
${_esc(g.artist)} · ${g.file_count} tracks
-
-
- `).join('')}
-
- `;
-
- // Store groups for click handler
- importPageState._autoGroups = data.groups;
- } catch (err) {
- console.warn('Failed to load auto-groups:', err);
- }
-}
-
-async function importPageMatchAutoGroup(groupIdx) {
- const group = importPageState._autoGroups?.[groupIdx];
- if (!group) return;
-
- // Search for the album by name + artist
- const query = `${group.artist} ${group.album}`;
- const searchInput = document.getElementById('import-page-album-search-input');
- if (searchInput) searchInput.value = query;
-
- // Hide suggestions/groups, show search results
- const suggestionsEl = document.getElementById('import-page-suggestions');
- const groupsEl = document.getElementById('import-page-auto-groups');
- if (suggestionsEl) suggestionsEl.style.display = 'none';
- if (groupsEl) groupsEl.style.display = 'none';
-
- const grid = document.getElementById('import-page-album-results');
- if (grid) grid.innerHTML = 'Searching...
';
-
- try {
- const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`);
- const data = await resp.json();
-
- if (data.success && data.albums && data.albums.length > 0) {
- // Store file_paths filter so match only includes this group's files
- importPageState._autoGroupFilePaths = group.file_paths;
-
- // Render results — user picks the right album
- const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
- grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
- } else {
- grid.innerHTML = 'No albums found — try searching manually
';
- }
- } catch (err) {
- console.error('Auto-group search failed:', err);
- if (grid) grid.innerHTML = 'Search failed
';
- }
-}
-
-// --- Album Tab: Suggestions (server-side cache, just fetch and render) ---
-
-async function importPageLoadSuggestions() {
- const section = document.getElementById('import-page-suggestions');
- const grid = document.getElementById('import-page-suggestions-grid');
- if (!section || !grid) return;
-
- try {
- const resp = await fetch('/api/import/staging/suggestions');
- if (!resp.ok) return;
- const data = await resp.json();
-
- if (!data.success || !data.suggestions || data.suggestions.length === 0) {
- if (!data.ready) {
- // Server is still building cache — show placeholder, retry shortly
- section.style.display = '';
- grid.innerHTML = 'Loading suggestions...
';
- setTimeout(() => importPageLoadSuggestions(), 3000);
- } else {
- section.style.display = 'none';
- grid.innerHTML = '';
- }
- return;
- }
-
- section.style.display = '';
- const banner = _renderImportFallbackBanner(data.suggestions, data.primary_source);
- grid.innerHTML = banner + data.suggestions.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
- } catch (err) {
- // Network error or server not ready — fail silently
- console.warn('Failed to load import suggestions:', err);
- }
-}
-
-function _renderSuggestionCard(a, primarySource) {
- // Cache the album lookup so importPageSelectAlbum can pull source +
- // name + artist on click (the onclick can only carry the ID string
- // — see github issue #524 root cause).
- importPageState._albumLookup[a.id] = {
- id: a.id, name: a.name || '', artist: a.artist || '', source: a.source || '',
- };
- // Surface the served source when it differs from the user's configured
- // primary — the search route silently falls through to the next source
- // in METADATA_SOURCE_PRIORITY when the primary returns nothing
- // (intentional design, see core/auto_import_worker.py:1316). Without
- // this badge the user has no idea their MusicBrainz / Discogs choice
- // got bypassed (github issue #681).
- const sourceBadge = (a.source && primarySource && a.source !== primarySource)
- ? `via ${_esc((SOURCE_LABELS[a.source] || {}).text || a.source)}
`
- : '';
- const metaParts = [
- `${a.total_tracks || 0} tracks`,
- a.release_date ? a.release_date.substring(0, 4) : '',
- a.format || '',
- a.country || '',
- a.disambiguation || '',
- ].filter(Boolean);
- const details = [a.status || '', a.label || ''].filter(Boolean);
- const detailsLine = details.length
- ? `${_esc(details.join(' · '))}
`
- : '';
- return `
-
-
${_esc(a.name)}
-
${_esc(a.artist)}
-
${_esc(metaParts.join(' · '))}
- ${detailsLine}
- ${sourceBadge}
-
`;
-}
-
-function _renderImportFallbackBanner(albums, primarySource) {
- if (!primarySource || !albums || !albums.length) return '';
- const allFallback = albums.every(a => a.source && a.source !== primarySource);
- if (!allFallback) return '';
- const servedSource = albums[0].source;
- const primaryLabel = (SOURCE_LABELS[primarySource] || {}).text || primarySource;
- const servedLabel = (SOURCE_LABELS[servedSource] || {}).text || servedSource;
- // Neutral wording — covers both live-search fallback (primary returned 0)
- // and cache-stale suggestions (primary changed since cache was built).
- return `Showing ${_esc(servedLabel)} results — not from your primary source (${_esc(primaryLabel)}).
`;
-}
-
-// --- Album Tab: Search ---
-
-async function importPageSearchAlbum() {
- const query = document.getElementById('import-page-album-search-input').value.trim();
- if (!query) return;
-
- document.getElementById('import-page-suggestions').style.display = 'none';
- const groupsEl = document.getElementById('import-page-auto-groups');
- if (groupsEl) groupsEl.style.display = 'none';
- const grid = document.getElementById('import-page-album-results');
- grid.innerHTML = 'Searching...
';
-
- try {
- const resp = await fetch(`/api/import/search/albums?q=${encodeURIComponent(query)}&limit=12`);
- const data = await resp.json();
- if (!data.success || !data.albums.length) {
- grid.innerHTML = 'No albums found
';
- return;
- }
- const banner = _renderImportFallbackBanner(data.albums, data.primary_source);
- grid.innerHTML = banner + data.albums.map(a => _renderSuggestionCard(a, data.primary_source)).join('');
- document.getElementById('import-page-album-clear-btn').classList.remove('hidden');
- } catch (err) {
- grid.innerHTML = `Error: ${err.message}
`;
- }
-}
-
-// --- Album Tab: Select Album & Match ---
-
-async function importPageSelectAlbum(albumId) {
- document.getElementById('import-page-album-search-section').classList.add('hidden');
- document.getElementById('import-page-album-match-section').classList.remove('hidden');
-
- const matchList = document.getElementById('import-page-match-list');
- matchList.innerHTML = 'Matching files to tracklist...
';
-
- try {
- // Include file_paths filter if matching from an auto-group.
- // CRITICAL: include source + album_name + album_artist from the
- // search/suggestion result. Without `source`, the backend can't
- // route the lookup to the metadata source the album_id came
- // from — for example a Deezer album_id needs Deezer's get_album
- // call. Cross-source lookup fails silently, returns the
- // failure-fallback dict with album_id-as-name + Unknown Artist
- // + 0 tracks, then the import flow writes that broken metadata
- // to the library DB (github issue #524).
- const cached = importPageState._albumLookup[albumId] || {};
- const matchBody = {
- album_id: albumId,
- source: cached.source || '',
- album_name: cached.name || '',
- album_artist: cached.artist || '',
- };
- if (importPageState._autoGroupFilePaths) {
- matchBody.file_paths = importPageState._autoGroupFilePaths;
- importPageState._autoGroupFilePaths = null; // clear after use
- }
- const resp = await fetch('/api/import/album/match', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(matchBody)
- });
- const data = await resp.json();
- if (!data.success) {
- matchList.innerHTML = `Error: ${data.error}
`;
- return;
- }
-
- importPageState.albumData = data;
- importPageState.matchOverrides = {};
-
- // Render hero
- const album = data.album;
- const heroMetaParts = [
- `${album.total_tracks || 0} tracks`,
- album.release_date ? album.release_date.substring(0, 4) : '',
- album.format || '',
- album.country || '',
- album.disambiguation || '',
- ].filter(Boolean);
- document.getElementById('import-page-album-hero').innerHTML = `
-
-
-
${_esc(album.name)}
-
${_esc(album.artist)}
-
${_esc(heroMetaParts.join(' · '))}
-
- `;
-
- importPageRenderMatchList();
- } catch (err) {
- matchList.innerHTML = `Error: ${err.message}
`;
- }
-}
-
-function importPageRenderMatchList() {
- const data = importPageState.albumData;
- if (!data) return;
-
- const matchList = document.getElementById('import-page-match-list');
- const overrides = importPageState.matchOverrides;
-
- // Build effective matches: auto-match overridden by manual overrides
- // Also track which staging files are used (auto or override)
- const usedStagingFiles = new Set();
-
- // First pass: collect overridden indices
- Object.values(overrides).forEach(sfIdx => usedStagingFiles.add(sfIdx));
-
- // Build rows
- let matchedCount = 0;
- const rows = data.matches.map((m, idx) => {
- const trackInfo = _importPageGetTrackDisplayInfo(m, idx);
- let file = null;
- let confidence = m.confidence;
- let isOverride = false;
-
- if (overrides.hasOwnProperty(idx)) {
- const sfIdx = overrides[idx];
- if (sfIdx === -1) {
- // Forcibly unmatched — no file
- file = null;
- } else {
- // Manual override
- file = importPageState.stagingFiles[sfIdx] || null;
- confidence = 1.0;
- isOverride = true;
- usedStagingFiles.add(sfIdx);
- }
- } else if (m.staging_file) {
- file = m.staging_file;
- // Check if this file was reassigned to another track via override
- const autoFileName = m.staging_file.filename;
- const reassigned = Object.entries(overrides).some(([tIdx, sfIdx]) => {
- const sf = importPageState.stagingFiles[sfIdx];
- return sf && sf.filename === autoFileName && parseInt(tIdx) !== idx;
- });
- if (!reassigned) {
- usedStagingFiles.add(-1); // placeholder — auto-matched file
- } else {
- file = null; // file was reassigned elsewhere
- }
- }
-
- if (file) matchedCount++;
- const confPercent = Math.round(confidence * 100);
- const confClass = confidence >= 0.7 ? '' : 'low';
-
- return `
-
- ${trackInfo.displayTrackNumber}
- ${_esc(trackInfo.name)}
-
- ${file
- ? `${_esc(file.filename)}
- ${confPercent}% `
- : `Drop a file here `}
-
- ${file ? `✕ ` : ''}
-
- `;
- });
-
- matchList.innerHTML = rows.join('');
-
- // Unmatched file pool
- const unmatchedFiles = [];
- importPageState.stagingFiles.forEach((f, i) => {
- // Check if used by override
- if (Object.values(overrides).includes(i)) return;
- // Check if used by auto-match (not overridden away)
- const autoUsed = data.matches.some((m, mIdx) => {
- if (overrides.hasOwnProperty(mIdx)) return false;
- return m.staging_file && m.staging_file.filename === f.filename;
- });
- if (autoUsed) return;
- unmatchedFiles.push({ file: f, index: i });
- });
-
- const poolChips = document.getElementById('import-page-pool-chips');
- document.getElementById('import-page-unmatched-count').textContent = unmatchedFiles.length;
-
- if (unmatchedFiles.length === 0) {
- poolChips.innerHTML = 'All files matched ';
- } else {
- poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => `
-
- ${_esc(file.filename)}
-
- `).join('');
- }
-
- // Stats & button
- document.getElementById('import-page-match-stats').textContent = `${matchedCount} of ${data.matches.length} tracks matched`;
- const processBtn = document.getElementById('import-page-album-process-btn');
- processBtn.disabled = matchedCount === 0;
- processBtn.textContent = `Process ${matchedCount} Track${matchedCount !== 1 ? 's' : ''}`;
-}
-
-function _importPageGetTrackDisplayInfo(item, index) {
- const track = item?.track || item?.spotify_track || {};
- const rawTrackNumber = track.track_number ?? track.trackNumber ?? null;
- const trackNumber = rawTrackNumber === null || rawTrackNumber === undefined || rawTrackNumber === ''
- ? null
- : String(rawTrackNumber).split('/')[0].trim();
-
- return {
- track,
- name: track.name || track.title || `Track ${index + 1}`,
- trackNumber,
- displayTrackNumber: trackNumber || String(index + 1),
- };
-}
-
-// --- Album Tab: Drag and Drop ---
-
-function importPageStartDrag(event, stagingFileIndex) {
- event.dataTransfer.setData('text/plain', stagingFileIndex.toString());
- event.dataTransfer.effectAllowed = 'move';
-}
-
-function importPageHandleDragOver(event) {
- event.preventDefault();
- event.dataTransfer.dropEffect = 'move';
- event.currentTarget.classList.add('drag-over');
- // Remove drag-over from others
- document.querySelectorAll('.import-page-match-row.drag-over').forEach(el => {
- if (el !== event.currentTarget) el.classList.remove('drag-over');
- });
-}
-
-function importPageHandleDrop(event, trackIndex) {
- event.preventDefault();
- event.currentTarget.classList.remove('drag-over');
- const stagingFileIndex = parseInt(event.dataTransfer.getData('text/plain'));
- if (isNaN(stagingFileIndex)) return;
-
- // Remove this staging file from any other track it was assigned to
- Object.keys(importPageState.matchOverrides).forEach(k => {
- if (importPageState.matchOverrides[k] === stagingFileIndex) {
- delete importPageState.matchOverrides[k];
- }
- });
-
- importPageState.matchOverrides[trackIndex] = stagingFileIndex;
- importPageState.tapSelectedChip = null;
- importPageRenderMatchList();
-}
-
-// Mobile tap-to-assign fallback
-function importPageTapSelectChip(stagingFileIndex) {
- if (importPageState.tapSelectedChip === stagingFileIndex) {
- importPageState.tapSelectedChip = null;
- } else {
- importPageState.tapSelectedChip = stagingFileIndex;
- }
- importPageRenderMatchList();
-}
-
-function importPageTapAssign(trackIndex) {
- if (importPageState.tapSelectedChip === null) return;
- const stagingFileIndex = importPageState.tapSelectedChip;
-
- // Remove from any other track
- Object.keys(importPageState.matchOverrides).forEach(k => {
- if (importPageState.matchOverrides[k] === stagingFileIndex) {
- delete importPageState.matchOverrides[k];
- }
- });
-
- importPageState.matchOverrides[trackIndex] = stagingFileIndex;
- importPageState.tapSelectedChip = null;
- importPageRenderMatchList();
-}
-
-function importPageUnmatchTrack(trackIndex) {
- delete importPageState.matchOverrides[trackIndex];
- // Also remove auto-match by setting override to -1 special value? No — just delete override and let auto-match stay.
- // Actually, to truly unmatch: we need to suppress the auto-match too.
- // We'll use a sentinel: override = -1 means "forcibly unmatched"
- const m = importPageState.albumData?.matches[trackIndex];
- if (m && m.staging_file) {
- importPageState.matchOverrides[trackIndex] = -1; // sentinel: force no match
- }
- importPageRenderMatchList();
-}
-
-function importPageAutoRematch() {
- importPageState.matchOverrides = {};
- importPageState.tapSelectedChip = null;
- importPageRenderMatchList();
-}
-
-// --- Album Tab: Process ---
-
-function importPageProcessAlbum() {
- const data = importPageState.albumData;
- if (!data) return;
-
- // Build effective matches with overrides applied
- const overrides = importPageState.matchOverrides;
- const effectiveMatches = [];
- data.matches.forEach((m, idx) => {
- if (overrides.hasOwnProperty(idx)) {
- if (overrides[idx] === -1) return; // forcibly unmatched — skip
- const sf = importPageState.stagingFiles[overrides[idx]];
- effectiveMatches.push({ ...m, staging_file: sf, confidence: 1.0 });
- } else if (m.staging_file !== null) {
- effectiveMatches.push(m);
- }
- });
-
- if (effectiveMatches.length === 0) return;
-
- // Add to queue and reset search immediately so user can queue more
- const album = data.album;
- _importQueueAdd({
- type: 'album',
- label: album.name,
- sublabel: `${album.artist} · ${effectiveMatches.length} tracks`,
- imageUrl: album.image_url,
- items: effectiveMatches,
- albumData: album,
- });
-
- importPageResetAlbumSearch();
-}
-
-function importPageResetAlbumSearch() {
- importPageState.albumData = null;
- importPageState.matchOverrides = {};
- importPageState.tapSelectedChip = null;
- importPageState._autoGroupFilePaths = null;
-
- document.getElementById('import-page-album-search-section').classList.remove('hidden');
- document.getElementById('import-page-album-match-section').classList.add('hidden');
-
- // Clear search
- document.getElementById('import-page-album-results').innerHTML = '';
- document.getElementById('import-page-album-search-input').value = '';
- document.getElementById('import-page-album-clear-btn').classList.add('hidden');
-
- // Re-show auto-groups
- const groupsEl = document.getElementById('import-page-auto-groups');
- if (groupsEl) groupsEl.style.display = '';
-
- // Refresh suggestions & staging
- importPageLoadAutoGroups();
- importPageLoadSuggestions();
- importPageRefreshStaging();
-}
-
-// --- Singles Tab ---
-
-function importPageRenderSinglesList() {
- const list = document.getElementById('import-page-singles-list');
- const files = importPageState.stagingFiles;
-
- if (files.length === 0) {
- list.innerHTML = 'No audio files found in import folder
';
- return;
- }
-
- list.innerHTML = files.map((f, i) => {
- const isSelected = importPageState.selectedSingles.has(i);
- const manualMatch = importPageState.singlesManualMatches[i];
- const searchOpen = document.querySelector(`[data-singles-search="${i}"]`);
-
- let html = `
-
-
-
-
${_esc(f.filename)}
-
- ${f.title ? `${_esc(f.title)} ` : ''}
- ${f.artist ? `${_esc(f.artist)} ` : ''}
- ${f.extension ? `${f.extension} ` : ''}
-
- ${manualMatch ? `
-
- ✓ ${_esc(manualMatch.name)} - ${_esc(manualMatch.artist)}
- change
-
- ` : ''}
-
-
-
- 🔍 Identify
-
-
-
- `;
- return html;
- }).join('');
-
- importPageUpdateSinglesProcessButton();
-}
-
-function importPageToggleSingle(idx) {
- if (importPageState.selectedSingles.has(idx)) {
- importPageState.selectedSingles.delete(idx);
- } else {
- importPageState.selectedSingles.add(idx);
- }
- // Update checkbox UI without full re-render
- const item = document.querySelector(`[data-single-idx="${idx}"]`);
- if (item) {
- const cb = item.querySelector('.import-page-single-checkbox');
- if (cb) cb.classList.toggle('checked', importPageState.selectedSingles.has(idx));
- }
- importPageUpdateSinglesProcessButton();
-}
-
-function importPageSelectAllSingles() {
- const allSelected = importPageState.selectedSingles.size === importPageState.stagingFiles.length;
- if (allSelected) {
- importPageState.selectedSingles.clear();
- } else {
- importPageState.stagingFiles.forEach((_, i) => importPageState.selectedSingles.add(i));
- }
- document.getElementById('import-page-select-all-text').textContent = allSelected ? 'Select All' : 'Deselect All';
- // Update all checkboxes
- document.querySelectorAll('.import-page-single-checkbox').forEach((cb, i) => {
- cb.classList.toggle('checked', importPageState.selectedSingles.has(i));
- });
- importPageUpdateSinglesProcessButton();
-}
-
-function importPageUpdateSinglesProcessButton() {
- const btn = document.getElementById('import-page-singles-process-btn');
- const count = importPageState.selectedSingles.size;
- btn.textContent = `Process Selected (${count})`;
- btn.disabled = count === 0;
-}
-
-function importPageOpenSingleSearch(fileIdx) {
- const item = document.querySelector(`[data-single-idx="${fileIdx}"]`);
- if (!item) return;
-
- // Remove any existing search panel
- const existing = item.querySelector('.import-page-single-search-panel');
- if (existing) {
- existing.remove();
- return;
- }
-
- // Close other open panels
- document.querySelectorAll('.import-page-single-search-panel').forEach(p => p.remove());
-
- const f = importPageState.stagingFiles[fileIdx];
- const defaultQuery = [f.artist, f.title].filter(Boolean).join(' ') || f.filename.replace(/\.[^.]+$/, '');
-
- const panel = document.createElement('div');
- panel.className = 'import-page-single-search-panel';
- panel.innerHTML = `
-
-
- Search
-
-
- `;
- item.appendChild(panel);
-
- // Auto-search
- const input = panel.querySelector('input');
- input.focus();
- if (defaultQuery) {
- importPageSearchSingleTrack(fileIdx, defaultQuery);
- }
-}
-
-async function importPageSearchSingleTrack(fileIdx, query) {
- if (!query || !query.trim()) return;
-
- const resultsDiv = document.getElementById(`import-single-results-${fileIdx}`);
- if (!resultsDiv) return;
- resultsDiv.innerHTML = 'Searching...
';
-
- try {
- const resp = await fetch(`/api/import/search/tracks?q=${encodeURIComponent(query.trim())}&limit=6`);
- const data = await resp.json();
- if (!data.success || !data.tracks.length) {
- resultsDiv.innerHTML = 'No results found
';
- return;
- }
- // Store results in a temp cache so we can reference by index
- window._importSingleSearchResults = window._importSingleSearchResults || {};
- window._importSingleSearchResults[fileIdx] = data.tracks;
-
- resultsDiv.innerHTML = data.tracks.map((t, tIdx) => {
- const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
- return `
-
- ${t.image_url ? `
` : ''}
-
-
${_esc(t.name)} - ${_esc(t.artist)}
-
${_esc(t.album)}${dur ? ' · ' + dur : ''}
-
-
Select
-
- `;
- }).join('');
- } catch (err) {
- resultsDiv.innerHTML = `Error: ${err.message}
`;
- }
-}
-
-function importPageSelectSingleMatch(fileIdx, trackIdx) {
- const trackData = window._importSingleSearchResults?.[fileIdx]?.[trackIdx];
- if (!trackData) return;
- importPageState.singlesManualMatches[fileIdx] = trackData;
-
- // Auto-select this file
- importPageState.selectedSingles.add(fileIdx);
-
- // Close search panel and re-render this item
- importPageRenderSinglesList();
-}
-
-// --- Singles Tab: Process ---
-
-function importPageProcessSingles() {
- if (importPageState.selectedSingles.size === 0) return;
-
- const filesToProcess = Array.from(importPageState.selectedSingles).map(i => {
- const f = importPageState.stagingFiles[i];
- const manualMatch = importPageState.singlesManualMatches[i];
- if (manualMatch) {
- return { ...f, manual_match: manualMatch };
- }
- return f;
- });
-
- // Add to queue and reset immediately
- _importQueueAdd({
- type: 'singles',
- label: `${filesToProcess.length} Single${filesToProcess.length !== 1 ? 's' : ''}`,
- sublabel: filesToProcess.map(f => f.title || f.filename).slice(0, 3).join(', ') + (filesToProcess.length > 3 ? '...' : ''),
- imageUrl: null,
- items: filesToProcess,
- });
-
- importPageState.selectedSingles.clear();
- importPageState.singlesManualMatches = {};
- importPageUpdateSinglesProcessButton();
- importPageRefreshStaging();
-}
-
-// --- Processing Queue ---
-
-const _importQueue = []; // { id, type, label, sublabel, imageUrl, status, processed, total, errors }
-
-function _importQueueAdd(job) {
- const id = ++importJobIdCounter;
- const entry = {
- id,
- type: job.type,
- label: job.label,
- sublabel: job.sublabel,
- imageUrl: job.imageUrl,
- status: 'running', // running | done | error
- processed: 0,
- total: job.items.length,
- errors: [],
- };
- _importQueue.push(entry);
- _importQueueRender();
-
- // Fire and forget — runs in background
- _importQueueRunJob(entry, job);
-}
-
-async function _importQueueRunJob(entry, job) {
- for (let i = 0; i < job.items.length; i++) {
- const itemName = job.type === 'album'
- ? _importPageGetTrackDisplayInfo(job.items[i], i).name
- : (job.items[i].title || job.items[i].filename || `File ${i + 1}`);
-
- // Update status with current track info
- entry.sublabel = `Processing ${i + 1}/${job.items.length}: ${itemName}`;
- _importQueueRender();
-
- try {
- let resp;
- if (job.type === 'album') {
- resp = await fetch('/api/import/album/process', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- album: job.albumData,
- matches: [job.items[i]]
- })
- });
- } else {
- resp = await fetch('/api/import/singles/process', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ files: [job.items[i]] })
- });
- }
-
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
- const data = await resp.json();
- if (data.success) entry.processed += (data.processed || 0);
- if (data.errors && data.errors.length > 0) entry.errors.push(...data.errors);
- } catch (err) {
- entry.errors.push(`${itemName}: ${err.message}`);
- }
-
- _importQueueRender();
- }
-
- entry.status = entry.errors.length > 0 && entry.processed === 0 ? 'error' : 'done';
- _importQueueRender();
-
- // Refresh staging and suggestions since files moved
- importPageRefreshStaging();
- importPageLoadSuggestions();
-}
-
-function _importQueueRender() {
- const container = document.getElementById('import-page-queue');
- const list = document.getElementById('import-page-queue-list');
- const clearBtn = document.getElementById('import-page-queue-clear');
- if (!container || !list) return;
-
- if (_importQueue.length === 0) {
- container.classList.add('hidden');
- return;
- }
-
- container.classList.remove('hidden');
-
- // Show clear button only if there are finished jobs
- const hasFinished = _importQueue.some(j => j.status !== 'running');
- clearBtn.style.display = hasFinished ? '' : 'none';
-
- list.innerHTML = _importQueue.map(j => {
- const pct = j.total > 0 ? Math.round((j.processed / j.total) * 100) : 0;
- const fillClass = j.status === 'error' ? 'error' : '';
- let statusText, statusClass;
- if (j.status === 'running') {
- statusText = `${j.processed}/${j.total}`;
- statusClass = '';
- } else if (j.status === 'done') {
- statusText = j.errors.length > 0 ? `${j.processed}/${j.total} (${j.errors.length} err)` : 'Done';
- statusClass = j.errors.length > 0 ? 'error' : 'done';
- } else {
- statusText = 'Failed';
- statusClass = 'error';
- }
-
- return `
-
- ${j.imageUrl
- ? `
`
- : `
♪
`}
-
-
${_esc(j.label)}
-
${_esc(j.sublabel)}
-
-
-
- `;
- }).join('');
-}
-
-function importPageClearFinishedJobs() {
- for (let i = _importQueue.length - 1; i >= 0; i--) {
- if (_importQueue[i].status !== 'running') {
- _importQueue.splice(i, 1);
- }
- }
- _importQueueRender();
-}
-
// ── Import File Tab ──────────────────────────────────────────────────
let _importFileState = {
@@ -6056,14 +4682,15 @@ async function playArtistRadio() {
const albumArt = random.album.thumb_url || data.artist?.thumb_url || null;
// Clear existing queue and disable radio before starting fresh
- npSetRadioMode(false, { toast: false });
+ npRadioMode = false;
clearQueue();
if (audioPlayer && !audioPlayer.paused) {
audioPlayer.pause();
}
- // Play the track first so currentTrack is populated before radio seeds the queue.
- await playLibraryTrack({
+ // Play the track first, then enable radio mode after a short delay
+ // so currentTrack is set and the radio queue fill triggers
+ playLibraryTrack({
id: random.track.id,
title: random.track.title,
file_path: random.track.file_path,
@@ -6072,9 +4699,14 @@ async function playArtistRadio() {
album_id: random.album.id,
}, random.album.title || '', artistName);
- npSetRadioMode(true, { toast: false, fetchIfNeeded: true });
+ // Enable radio mode after track starts loading
+ setTimeout(() => {
+ npRadioMode = true;
+ const radioBtn = document.querySelector('.np-radio-btn');
+ if (radioBtn) radioBtn.classList.add('active');
+ }, 1000);
- showToast(`Playing ${artistName} radio - similar tracks will auto-queue`, 'success');
+ showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success');
} catch (e) {
showToast(`Failed to start artist radio: ${e.message}`, 'error');
}
diff --git a/webui/static/style.css b/webui/static/style.css
index ea65ca7f..7706efc0 100644
--- a/webui/static/style.css
+++ b/webui/static/style.css
@@ -10376,8 +10376,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
border-color: rgba(167, 139, 250, 0.25);
}
-.library-history-badge.download.source-staging,
-.library-history-badge.download.source-auto-import {
+.library-history-badge.download.source-staging {
color: rgba(255, 255, 255, 0.55);
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.12);
@@ -40072,956 +40071,6 @@ div.artist-hero-badge {
opacity: 1;
}
-/* ========================================
- IMPORT PAGE (full page, replaces modal)
- ======================================== */
-
-/* ============================================================================
- IMPORT PAGE
- ============================================================================ */
-
-.import-page-container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 24px 32px;
-}
-
-.import-page-header {
- margin-bottom: 20px;
-}
-
-.import-page-title-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 12px;
-}
-
-.import-page-title {
- font-size: 28px;
- font-weight: 700;
- margin: 0;
- letter-spacing: -0.3px;
- font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, sans-serif;
- display: flex;
- align-items: center;
- gap: 14px;
-}
-
-.import-page-title > span {
- background: linear-gradient(90deg, #ffffff 0%, rgb(var(--accent-light-rgb)) 50%, rgb(var(--accent-rgb)) 100%);
- background-size: 200% 100%;
- -webkit-background-clip: text;
- -webkit-text-fill-color: transparent;
- background-clip: text;
- animation: page-title-shimmer 6s ease-in-out infinite;
-}
-
-.import-page-refresh-btn {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 8px 16px;
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: #ccc;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.import-page-refresh-btn:hover {
- background: rgba(255, 255, 255, 0.14);
- color: #fff;
-}
-
-.import-page-staging-bar {
- display: flex;
- align-items: center;
- gap: 16px;
- padding: 10px 16px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 10px;
- font-size: 13px;
- color: rgba(255, 255, 255, 0.5);
-}
-
-.import-staging-path {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.import-staging-stats {
- color: rgb(var(--accent-light-rgb));
- font-weight: 500;
- white-space: nowrap;
-}
-
-/* Tab Bar */
-.import-page-tab-bar {
- display: flex;
- gap: 4px;
- margin-bottom: 24px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
- padding-bottom: 0;
-}
-
-.import-page-tab {
- padding: 10px 24px;
- background: none;
- border: none;
- border-bottom: 2px solid transparent;
- color: rgba(255, 255, 255, 0.5);
- font-size: 14px;
- font-weight: 500;
- cursor: pointer;
- transition: all 0.2s;
- margin-bottom: -1px;
-}
-
-.import-page-tab:hover {
- color: rgba(255, 255, 255, 0.8);
-}
-
-.import-page-tab.active {
- color: rgb(var(--accent-light-rgb));
- border-bottom-color: rgb(var(--accent-light-rgb));
-}
-
-.import-page-tab-content {
- display: none;
-}
-
-.import-page-tab-content.active {
- display: block;
-}
-
-/* Section labels */
-.import-page-section-label {
- font-size: 13px;
- color: rgba(255, 255, 255, 0.4);
- margin-bottom: 12px;
- text-transform: uppercase;
- letter-spacing: 0.5px;
- font-weight: 500;
-}
-
-/* Search bar */
-.import-page-search-bar {
- display: flex;
- gap: 8px;
- margin-bottom: 20px;
- position: relative;
-}
-
-.import-page-search-input {
- flex: 1;
- padding: 10px 16px;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 10px;
- color: #fff;
- font-size: 14px;
- outline: none;
- transition: border-color 0.2s;
-}
-
-.import-page-search-input:focus {
- border-color: rgba(var(--accent-light-rgb), 0.5);
-}
-
-.import-page-search-btn {
- padding: 10px 20px;
- background: rgb(var(--accent-light-rgb));
- border: none;
- border-radius: 10px;
- color: #000;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- transition: background 0.2s;
- white-space: nowrap;
-}
-
-.import-page-search-btn:hover {
- background: #1fdf64;
-}
-
-.import-page-clear-btn {
- position: absolute;
- right: 100px;
- top: 50%;
- transform: translateY(-50%);
- background: none;
- border: none;
- color: rgba(255, 255, 255, 0.4);
- font-size: 16px;
- cursor: pointer;
- padding: 4px 8px;
-}
-
-.import-page-clear-btn:hover {
- color: #fff;
-}
-
-/* Album grid */
-.import-page-album-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
- gap: 16px;
- margin-bottom: 24px;
-}
-
-.import-page-album-card {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- padding: 12px;
- cursor: pointer;
- transition: all 0.2s;
- text-align: center;
-}
-
-.import-page-album-card:hover {
- background: rgba(255, 255, 255, 0.1);
- border-color: rgba(var(--accent-light-rgb), 0.3);
- transform: translateY(-2px);
-}
-
-.import-page-album-card img {
- width: 100%;
- aspect-ratio: 1;
- object-fit: cover;
- border-radius: 8px;
- margin-bottom: 8px;
-}
-
-.import-page-album-card-title {
- font-size: 13px;
- font-weight: 600;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- margin-bottom: 2px;
-}
-
-.import-page-album-card-artist {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.5);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-album-card-meta {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.3);
- margin-top: 4px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-album-card-detail {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.36);
- margin-top: 2px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-album-card-source {
- font-size: 10px;
- color: rgba(255, 200, 100, 0.75);
- margin-top: 2px;
- font-style: italic;
-}
-
-.import-page-fallback-banner {
- grid-column: 1 / -1;
- padding: 10px 14px;
- margin-bottom: 12px;
- background: rgba(255, 200, 100, 0.08);
- border: 1px solid rgba(255, 200, 100, 0.25);
- border-radius: 8px;
- color: rgba(255, 220, 170, 0.9);
- font-size: 12px;
- line-height: 1.4;
-}
-
-/* Album hero (selected album) */
-.import-page-album-hero {
- display: flex;
- gap: 20px;
- padding: 20px;
- background: rgba(255, 255, 255, 0.04);
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 14px;
- margin-bottom: 20px;
- align-items: center;
-}
-
-.import-page-album-hero img {
- width: 120px;
- height: 120px;
- border-radius: 10px;
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.import-page-album-hero-info {
- flex: 1;
- min-width: 0;
-}
-
-.import-page-album-hero-title {
- font-size: 22px;
- font-weight: 700;
- color: #fff;
- margin-bottom: 4px;
-}
-
-.import-page-album-hero-artist {
- font-size: 15px;
- color: rgba(255, 255, 255, 0.6);
- margin-bottom: 4px;
-}
-
-.import-page-album-hero-meta {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.35);
-}
-
-/* Match header */
-.import-page-match-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 16px;
-}
-
-.import-page-match-header h3 {
- font-size: 16px;
- font-weight: 600;
- color: #fff;
- margin: 0;
-}
-
-.import-page-match-actions {
- display: flex;
- gap: 8px;
-}
-
-/* Match list */
-.import-page-match-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
- margin-bottom: 16px;
-}
-
-.import-page-match-row {
- display: grid;
- grid-template-columns: 36px 1fr 1fr 36px;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 10px;
- min-height: 44px;
- transition: all 0.2s;
-}
-
-.import-page-match-row.drag-over {
- background: rgba(var(--accent-light-rgb), 0.08);
- border-color: rgba(var(--accent-light-rgb), 0.4);
- box-shadow: 0 0 12px rgba(var(--accent-light-rgb), 0.15);
-}
-
-.import-page-match-row.matched {
- border-color: rgba(var(--accent-light-rgb), 0.2);
-}
-
-.import-page-match-num {
- font-size: 13px;
- color: rgba(255, 255, 255, 0.3);
- text-align: center;
- font-weight: 500;
-}
-
-.import-page-match-track {
- font-size: 13px;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-match-file {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.4);
- display: flex;
- align-items: center;
- gap: 6px;
- overflow: hidden;
-}
-
-.import-page-match-file.has-file {
- color: rgb(var(--accent-light-rgb));
-}
-
-.import-page-match-file-name {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.import-page-match-confidence {
- font-size: 10px;
- padding: 2px 6px;
- border-radius: 4px;
- background: rgba(var(--accent-light-rgb), 0.15);
- color: rgb(var(--accent-light-rgb));
- font-weight: 500;
- white-space: nowrap;
-}
-
-.import-page-match-confidence.low {
- background: rgba(255, 165, 0, 0.15);
- color: #ffa500;
-}
-
-.import-page-match-drop-zone {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.25);
- font-style: italic;
-}
-
-.import-page-match-unmatch {
- background: none;
- border: none;
- color: rgba(255, 255, 255, 0.3);
- font-size: 14px;
- cursor: pointer;
- padding: 4px;
- border-radius: 4px;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.import-page-match-unmatch:hover {
- color: #ff4444;
- background: rgba(255, 68, 68, 0.1);
-}
-
-/* Unmatched file pool */
-.import-page-unmatched-pool {
- padding: 16px;
- background: rgba(255, 255, 255, 0.02);
- border: 1px dashed rgba(255, 255, 255, 0.1);
- border-radius: 12px;
- margin-bottom: 16px;
-}
-
-.import-page-pool-label {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.4);
- margin-bottom: 10px;
- font-weight: 500;
-}
-
-.import-page-pool-chips {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
-}
-
-.import-page-file-chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 6px 12px;
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 20px;
- font-size: 12px;
- color: #ccc;
- cursor: grab;
- transition: all 0.2s;
- user-select: none;
-}
-
-.import-page-file-chip:active {
- cursor: grabbing;
-}
-
-.import-page-file-chip:hover {
- background: rgba(255, 255, 255, 0.14);
- border-color: rgba(var(--accent-light-rgb), 0.3);
- color: #fff;
-}
-
-.import-page-file-chip.selected {
- background: rgba(var(--accent-light-rgb), 0.15);
- border-color: rgba(var(--accent-light-rgb), 0.4);
- color: rgb(var(--accent-light-rgb));
-}
-
-.import-page-pool-empty {
- font-size: 12px;
- color: rgba(255, 255, 255, 0.2);
- font-style: italic;
-}
-
-/* Match footer */
-.import-page-match-footer {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding-top: 16px;
- border-top: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.import-page-match-stats {
- font-size: 13px;
- color: rgba(255, 255, 255, 0.5);
-}
-
-/* Buttons */
-.import-page-process-btn {
- padding: 10px 24px;
- background: rgb(var(--accent-light-rgb));
- border: none;
- border-radius: 10px;
- color: #000;
- font-size: 14px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.import-page-process-btn:hover {
- background: #1fdf64;
-}
-
-.import-page-process-btn:disabled {
- background: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.3);
- cursor: not-allowed;
-}
-
-.import-page-secondary-btn {
- padding: 8px 16px;
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: #ccc;
- font-size: 13px;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.import-page-secondary-btn:hover {
- background: rgba(255, 255, 255, 0.14);
- color: #fff;
-}
-
-.import-page-back-btn {
- padding: 8px 16px;
- background: none;
- border: 1px solid rgba(255, 255, 255, 0.1);
- border-radius: 8px;
- color: rgba(255, 255, 255, 0.5);
- font-size: 13px;
- cursor: pointer;
- transition: all 0.2s;
-}
-
-.import-page-back-btn:hover {
- color: #fff;
- border-color: rgba(255, 255, 255, 0.2);
-}
-
-/* Singles */
-.import-page-singles-header {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- margin-bottom: 16px;
-}
-
-.import-page-singles-actions {
- display: flex;
- gap: 8px;
- align-items: center;
-}
-
-.import-page-singles-list {
- display: flex;
- flex-direction: column;
- gap: 4px;
-}
-
-.import-page-single-item {
- display: grid;
- grid-template-columns: 32px 1fr auto;
- align-items: center;
- gap: 12px;
- padding: 10px 14px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 10px;
- transition: all 0.2s;
-}
-
-.import-page-single-item.matched {
- border-color: rgba(var(--accent-light-rgb), 0.2);
-}
-
-.import-page-single-checkbox {
- width: 18px;
- height: 18px;
- border: 2px solid rgba(255, 255, 255, 0.2);
- border-radius: 4px;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- transition: all 0.2s;
- flex-shrink: 0;
-}
-
-.import-page-single-checkbox.checked {
- background: rgb(var(--accent-light-rgb));
- border-color: rgb(var(--accent-light-rgb));
-}
-
-.import-page-single-checkbox.checked::after {
- content: '✓';
- color: #000;
- font-size: 12px;
- font-weight: 700;
-}
-
-.import-page-single-info {
- min-width: 0;
-}
-
-.import-page-single-filename {
- font-size: 13px;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-single-meta {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.35);
- margin-top: 2px;
- display: flex;
- gap: 8px;
- flex-wrap: wrap;
-}
-
-.import-page-single-matched-info {
- font-size: 12px;
- color: rgb(var(--accent-light-rgb));
- margin-top: 4px;
- display: flex;
- align-items: center;
- gap: 6px;
-}
-
-.import-page-single-matched-change {
- color: rgba(255, 255, 255, 0.4);
- cursor: pointer;
- font-size: 11px;
- text-decoration: underline;
-}
-
-.import-page-single-matched-change:hover {
- color: #fff;
-}
-
-.import-page-single-actions {
- display: flex;
- gap: 6px;
- align-items: center;
- flex-shrink: 0;
-}
-
-.import-page-identify-btn {
- padding: 6px 12px;
- background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 6px;
- color: #ccc;
- font-size: 12px;
- cursor: pointer;
- transition: all 0.2s;
- display: flex;
- align-items: center;
- gap: 4px;
-}
-
-.import-page-identify-btn:hover {
- background: rgba(255, 255, 255, 0.14);
- color: #fff;
-}
-
-/* Inline search panel for singles */
-.import-page-single-search-panel {
- grid-column: 1 / -1;
- padding: 12px 0 4px 44px;
-}
-
-.import-page-single-search-bar {
- display: flex;
- gap: 8px;
- margin-bottom: 10px;
-}
-
-.import-page-single-search-input {
- flex: 1;
- padding: 8px 12px;
- background: rgba(255, 255, 255, 0.06);
- border: 1px solid rgba(255, 255, 255, 0.12);
- border-radius: 8px;
- color: #fff;
- font-size: 13px;
- outline: none;
-}
-
-.import-page-single-search-input:focus {
- border-color: rgba(var(--accent-light-rgb), 0.4);
-}
-
-.import-page-single-search-go {
- padding: 8px 14px;
- background: rgb(var(--accent-light-rgb));
- border: none;
- border-radius: 8px;
- color: #000;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
-}
-
-.import-page-single-search-results {
- display: flex;
- flex-direction: column;
- gap: 4px;
- max-height: 200px;
- overflow-y: auto;
-}
-
-.import-page-single-result-item {
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 8px 10px;
- background: rgba(255, 255, 255, 0.03);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 8px;
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.import-page-single-result-item:hover {
- background: rgba(var(--accent-light-rgb), 0.08);
- border-color: rgba(var(--accent-light-rgb), 0.25);
-}
-
-.import-page-single-result-img {
- width: 36px;
- height: 36px;
- border-radius: 4px;
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.import-page-single-result-info {
- flex: 1;
- min-width: 0;
-}
-
-.import-page-single-result-name {
- font-size: 13px;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-single-result-detail {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.4);
-}
-
-.import-page-single-result-select {
- padding: 4px 12px;
- background: rgba(var(--accent-light-rgb), 0.15);
- border: 1px solid rgba(var(--accent-light-rgb), 0.3);
- border-radius: 6px;
- color: rgb(var(--accent-light-rgb));
- font-size: 11px;
- font-weight: 600;
- cursor: pointer;
- flex-shrink: 0;
-}
-
-.import-page-single-result-select:hover {
- background: rgba(var(--accent-light-rgb), 0.25);
-}
-
-/* Empty state */
-.import-page-empty-state {
- text-align: center;
- padding: 60px 20px;
- color: rgba(255, 255, 255, 0.3);
- font-size: 14px;
-}
-
-/* Suggestions */
-.import-page-suggestions {
- margin-bottom: 24px;
-}
-
-/* Processing Queue */
-.import-page-queue {
- margin-bottom: 20px;
- border: 1px solid rgba(255, 255, 255, 0.08);
- border-radius: 12px;
- background: rgba(255, 255, 255, 0.02);
- overflow: hidden;
-}
-
-.import-page-queue-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
-}
-
-.import-page-queue-title {
- font-size: 13px;
- font-weight: 600;
- color: rgba(255, 255, 255, 0.6);
- text-transform: uppercase;
- letter-spacing: 0.5px;
-}
-
-.import-page-queue-clear {
- background: none;
- border: none;
- color: rgba(255, 255, 255, 0.3);
- font-size: 12px;
- cursor: pointer;
- padding: 2px 8px;
-}
-
-.import-page-queue-clear:hover {
- color: #fff;
-}
-
-.import-page-queue-list {
- display: flex;
- flex-direction: column;
-}
-
-.import-page-queue-item {
- display: grid;
- grid-template-columns: 44px 1fr auto;
- gap: 12px;
- align-items: center;
- padding: 10px 16px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.04);
-}
-
-.import-page-queue-item:last-child {
- border-bottom: none;
-}
-
-.import-page-queue-art {
- width: 44px;
- height: 44px;
- border-radius: 6px;
- object-fit: cover;
-}
-
-.import-page-queue-info {
- min-width: 0;
-}
-
-.import-page-queue-name {
- font-size: 13px;
- font-weight: 600;
- color: #fff;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.import-page-queue-detail {
- font-size: 11px;
- color: rgba(255, 255, 255, 0.4);
- margin-top: 2px;
-}
-
-.import-page-queue-progress {
- width: 120px;
- flex-shrink: 0;
-}
-
-.import-page-queue-bar {
- height: 4px;
- background: rgba(255, 255, 255, 0.08);
- border-radius: 2px;
- overflow: hidden;
- margin-bottom: 4px;
-}
-
-.import-page-queue-fill {
- height: 100%;
- background: rgb(var(--accent-light-rgb));
- border-radius: 2px;
- width: 0%;
- transition: width 0.3s ease;
-}
-
-.import-page-queue-fill.error {
- background: #ef4444;
-}
-
-.import-page-queue-status {
- font-size: 10px;
- color: rgba(255, 255, 255, 0.4);
- text-align: right;
-}
-
-.import-page-queue-status.done {
- color: rgb(var(--accent-light-rgb));
-}
-
-.import-page-queue-status.error {
- color: #ef4444;
-}
-
-/* ========================================
- END IMPORT PAGE
- ======================================== */
-
/* ========================================
FAILED DOWNLOAD ERROR TOOLTIP
======================================== */
@@ -45706,10 +44755,6 @@ textarea.enhanced-meta-field-input {
.discog-download-wrap { margin: 12px 0 4px; }
-.artist-hero-actions .discog-download-wrap { margin: 0; }
-
-.artist-hero-actions .discog-btn-compact { margin-top: 0; }
-
.discog-download-btn {
position: relative;
display: flex;
@@ -45757,112 +44802,6 @@ textarea.enhanced-meta-field-input {
animation: discogShimmer 3s ease-in-out infinite;
}
-.artist-hero-actions {
- gap: 10px;
- align-items: center;
-}
-
-.artist-hero-actions .library-artist-radio-btn,
-.artist-hero-actions .library-artist-watchlist-btn,
-.artist-hero-actions .discog-download-btn,
-.artist-hero-actions .library-artist-enhance-btn {
- --artist-action-rgb: var(--accent-rgb);
- min-height: 38px;
- width: auto;
- max-width: none;
- padding: 8px 14px 8px 12px;
- gap: 8px;
- border-radius: 999px;
- color: rgba(255, 255, 255, 0.82);
- background:
- linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.13), rgba(var(--artist-action-rgb), 0.03)),
- rgba(255, 255, 255, 0.035);
- border: 1px solid rgba(var(--artist-action-rgb), 0.26);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.08),
- 0 8px 22px rgba(0, 0, 0, 0.22);
- font-size: 12px;
- font-weight: 650;
- letter-spacing: 0;
- line-height: 1;
- backdrop-filter: blur(14px) saturate(1.25);
- transform: none;
-}
-
-.artist-hero-actions .library-artist-radio-btn { --artist-action-rgb: 79, 195, 247; }
-.artist-hero-actions .library-artist-watchlist-btn { --artist-action-rgb: 255, 193, 7; }
-.artist-hero-actions .discog-download-btn { --artist-action-rgb: var(--accent-rgb); }
-.artist-hero-actions .library-artist-enhance-btn { --artist-action-rgb: 180, 132, 255; }
-
-.artist-hero-actions .library-artist-radio-btn::before,
-.artist-hero-actions .library-artist-watchlist-btn::before,
-.artist-hero-actions .library-artist-enhance-btn::before,
-.artist-hero-actions .discog-download-btn::before {
- content: '';
- width: 7px;
- height: 7px;
- border-radius: 999px;
- position: relative;
- inset: auto;
- flex: 0 0 auto;
- opacity: 1;
- background: rgb(var(--artist-action-rgb));
- box-shadow: 0 0 14px rgba(var(--artist-action-rgb), 0.85);
- transition: transform 0.2s ease, box-shadow 0.2s ease;
-}
-
-.artist-hero-actions .library-artist-radio-btn:hover,
-.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled),
-.artist-hero-actions .discog-download-btn:hover,
-.artist-hero-actions .library-artist-enhance-btn:hover {
- color: #ffffff;
- border-color: rgba(var(--artist-action-rgb), 0.5);
- background:
- linear-gradient(135deg, rgba(var(--artist-action-rgb), 0.22), rgba(var(--artist-action-rgb), 0.06)),
- rgba(255, 255, 255, 0.055);
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.12),
- 0 12px 28px rgba(0, 0, 0, 0.28),
- 0 0 0 1px rgba(var(--artist-action-rgb), 0.12);
- transform: translateY(-1px);
-}
-
-.artist-hero-actions .library-artist-radio-btn:hover::before,
-.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled)::before,
-.artist-hero-actions .discog-download-btn:hover::before,
-.artist-hero-actions .library-artist-enhance-btn:hover::before {
- transform: scale(1.25);
- box-shadow: 0 0 18px rgba(var(--artist-action-rgb), 1);
-}
-
-.artist-hero-actions .library-artist-watchlist-btn.watching {
- color: #ffd761;
- border-color: rgba(255, 193, 7, 0.52);
- background:
- linear-gradient(135deg, rgba(255, 193, 7, 0.24), rgba(255, 193, 7, 0.06)),
- rgba(255, 255, 255, 0.04);
-}
-
-.artist-hero-actions .library-artist-radio-btn .radio-icon,
-.artist-hero-actions .library-artist-watchlist-btn .watchlist-icon,
-.artist-hero-actions .discog-download-btn .discog-btn-icon,
-.artist-hero-actions .library-artist-enhance-btn .enhance-icon {
- font-size: 13px;
- position: relative;
- z-index: 1;
- transform: none;
-}
-
-.artist-hero-actions .library-artist-radio-btn:hover .radio-icon,
-.artist-hero-actions .library-artist-watchlist-btn:hover:not(:disabled) .watchlist-icon,
-.artist-hero-actions .library-artist-enhance-btn:hover .enhance-icon {
- transform: none;
-}
-
-.artist-hero-actions .discog-btn-shimmer {
- display: none;
-}
-
@keyframes discogShimmer {
0%, 100% { left: -100%; }
50% { left: 150%; }
@@ -47281,56 +46220,26 @@ textarea.enhanced-meta-field-input {
/* Radio mode button */
.np-radio-btn {
- --np-radio-rgb: 79, 195, 247;
- position: relative;
- overflow: hidden;
- background:
- linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.1), rgba(var(--np-radio-rgb), 0.025)),
- rgba(255, 255, 255, 0.035);
- border: 1px solid rgba(var(--np-radio-rgb), 0.22);
- color: rgba(255, 255, 255, 0.62);
+ background: none;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: rgba(255, 255, 255, 0.35);
font-size: 11px;
- font-weight: 650;
- line-height: 1;
- padding: 7px 10px;
- border-radius: 999px;
+ padding: 3px 8px;
+ border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
- gap: 6px;
- min-height: 30px;
- transition: all 0.18s ease;
+ gap: 4px;
+ transition: all 0.15s ease;
}
.np-radio-btn:hover {
- color: rgba(255, 255, 255, 0.9);
- border-color: rgba(var(--np-radio-rgb), 0.42);
- background:
- linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.16), rgba(var(--np-radio-rgb), 0.04)),
- rgba(255, 255, 255, 0.055);
+ color: rgba(255, 255, 255, 0.7);
+ border-color: rgba(255, 255, 255, 0.2);
}
.np-radio-btn.active {
- color: #ffffff;
- border-color: rgba(var(--np-radio-rgb), 0.55);
- background:
- linear-gradient(135deg, rgba(var(--np-radio-rgb), 0.28), rgba(var(--np-radio-rgb), 0.07)),
- rgba(255, 255, 255, 0.06);
- box-shadow: 0 0 0 1px rgba(var(--np-radio-rgb), 0.08), 0 0 18px rgba(var(--np-radio-rgb), 0.12);
-}
-.np-radio-label {
- position: relative;
- z-index: 1;
-}
-.np-radio-pulse {
- width: 6px;
- height: 6px;
- border-radius: 50%;
- background: rgba(255, 255, 255, 0.24);
- box-shadow: none;
- transition: all 0.18s ease;
-}
-.np-radio-btn.active .np-radio-pulse {
- background: rgb(var(--np-radio-rgb));
- box-shadow: 0 0 12px rgba(var(--np-radio-rgb), 0.95);
+ color: rgb(var(--accent-rgb));
+ border-color: rgba(var(--accent-rgb), 0.3);
+ background: rgba(var(--accent-rgb), 0.08);
}
.np-queue-header-actions {
display: flex;
@@ -50453,18 +49362,15 @@ tr.tag-diff-same {
transition: transform 0.2s ease;
}
-.repair-master-toggle input,
-.auto-import-toggle-label input {
+.repair-master-toggle input {
display: none;
}
-.repair-master-toggle input:checked + .repair-toggle-slider,
-.auto-import-toggle-label input:checked + .repair-toggle-slider {
+.repair-master-toggle input:checked + .repair-toggle-slider {
background: var(--accent-color, #6366f1);
}
-.repair-master-toggle input:checked + .repair-toggle-slider::after,
-.auto-import-toggle-label input:checked + .repair-toggle-slider::after {
+.repair-master-toggle input:checked + .repair-toggle-slider::after {
transform: translateX(20px);
}
@@ -59601,388 +58507,6 @@ body.reduce-effects *::after {
.wl-orb-group.expanded { max-width: 100%; }
}
-/* ═══════════════════════════════════════════════════════════════════
- AUTO-IMPORT TAB
- ═══════════════════════════════════════════════════════════════════ */
-
-.auto-import-controls {
- padding: 12px 0 16px;
- border-bottom: 1px solid rgba(255,255,255,0.05);
- margin-bottom: 16px;
-}
-
-.auto-import-toggle-row {
- display: flex;
- align-items: center;
- gap: 12px;
-}
-
-.auto-import-toggle-label {
- display: flex;
- align-items: center;
- gap: 8px;
- cursor: pointer;
- font-size: 13px;
- font-weight: 600;
- color: rgba(255,255,255,0.7);
-}
-
-.auto-import-toggle-label input { display: none; }
-
-.auto-import-status {
- font-size: 12px;
- font-weight: 500;
- padding: 2px 10px;
- border-radius: 6px;
-}
-.auto-import-status.active { color: #4ade80; background: rgba(74,222,128,0.1); }
-.auto-import-status.scanning { color: rgb(var(--accent-light-rgb)); background: rgba(var(--accent-rgb),0.1); }
-.auto-import-status.disabled { color: rgba(255,255,255,0.3); }
-
-.auto-import-settings-row {
- display: flex;
- align-items: center;
- gap: 16px;
- margin-top: 10px;
- flex-wrap: wrap;
- font-size: 12px;
- color: rgba(255,255,255,0.5);
-}
-
-.auto-import-settings-row label { display: flex; align-items: center; gap: 6px; }
-.auto-import-settings-row input[type="range"] { width: 100px; }
-.auto-import-settings-row select {
- background: rgba(255,255,255,0.05);
- border: 1px solid rgba(255,255,255,0.1);
- color: #fff;
- border-radius: 6px;
- padding: 3px 8px;
- font-size: 12px;
-}
-
-.auto-import-empty {
- text-align: center;
- padding: 40px 20px;
- color: rgba(255,255,255,0.3);
- font-size: 13px;
-}
-
-/* Result cards */
-.auto-import-card {
- display: flex;
- flex-direction: column;
- padding: 14px 16px;
- background: rgba(255,255,255,0.02);
- border: 1px solid rgba(255,255,255,0.06);
- border-radius: 12px;
- margin-bottom: 8px;
- transition: all 0.2s;
-}
-
-.auto-import-card-top {
- display: flex;
- gap: 14px;
- align-items: center;
-}
-
-.auto-import-card:hover {
- background: rgba(255,255,255,0.04);
- border-color: rgba(255,255,255,0.1);
-}
-
-.auto-import-completed { border-left: 3px solid #4ade80; }
-.auto-import-review { border-left: 3px solid #fbbf24; }
-.auto-import-failed { border-left: 3px solid #f87171; }
-.auto-import-processing { border-left: 3px solid #60a5fa; }
-
-.auto-import-card-art {
- width: 56px; height: 56px;
- border-radius: 8px;
- object-fit: cover;
- flex-shrink: 0;
-}
-
-.auto-import-card-art-fallback {
- width: 56px; height: 56px;
- border-radius: 8px;
- background: rgba(255,255,255,0.05);
- display: flex; align-items: center; justify-content: center;
- font-size: 22px; opacity: 0.3; flex-shrink: 0;
-}
-
-.auto-import-card-center { flex: 1; min-width: 0; }
-
-.auto-import-card-album {
- font-size: 14px; font-weight: 600; color: #fff;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-
-.auto-import-card-artist {
- font-size: 12px; color: rgba(255,255,255,0.45);
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-
-.auto-import-card-folder {
- font-size: 10px; color: rgba(255,255,255,0.25); margin-top: 2px;
-}
-
-.auto-import-match-info {
- font-size: 10px; color: rgba(255,255,255,0.35); margin-top: 2px;
-}
-
-.auto-import-card-error {
- font-size: 10px; color: #f87171; margin-top: 2px;
- white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
-}
-
-.auto-import-card-right {
- display: flex; flex-direction: column; align-items: flex-end; gap: 4px;
- flex-shrink: 0; min-width: 80px;
-}
-
-.auto-import-confidence-bar {
- width: 60px; height: 4px;
- background: rgba(255,255,255,0.06);
- border-radius: 2px; overflow: hidden;
-}
-
-.auto-import-confidence-fill { height: 100%; border-radius: 2px; }
-.auto-import-conf-high { background: #4ade80; }
-.auto-import-conf-medium { background: #fbbf24; }
-.auto-import-conf-low { background: #f87171; }
-
-.auto-import-confidence-text {
- font-size: 10px; font-weight: 600; color: rgba(255,255,255,0.5);
-}
-
-/* Scan Now button */
-.auto-import-scan-now-btn {
- background: rgba(255,255,255,0.06);
- border: 1px solid rgba(255,255,255,0.1);
- color: rgba(255,255,255,0.6);
- font-size: 11px;
- padding: 4px 12px;
- border-radius: 6px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 5px;
- transition: all 0.15s;
- margin-left: auto;
-}
-
-.auto-import-scan-now-btn:hover {
- background: rgba(255,255,255,0.1);
- color: rgba(255,255,255,0.9);
-}
-
-/* Live progress */
-.auto-import-progress {
- margin-top: 8px;
- padding: 8px 12px;
- background: rgba(var(--accent-rgb), 0.04);
- border: 1px solid rgba(var(--accent-rgb), 0.1);
- border-radius: 8px;
-}
-
-.auto-import-progress-text {
- font-size: 11px;
- color: rgba(255,255,255,0.6);
- margin-bottom: 4px;
-}
-
-.auto-import-progress-bar {
- height: 3px;
- background: rgba(255,255,255,0.06);
- border-radius: 2px;
- overflow: hidden;
-}
-
-.auto-import-progress-fill {
- height: 100%;
- background: rgba(var(--accent-rgb), 0.6);
- border-radius: 2px;
- width: 100%;
- animation: adlPulse 1.5s ease-in-out infinite;
-}
-
-/* Stats summary */
-.auto-import-stats {
- display: flex;
- gap: 16px;
- padding: 8px 0;
- margin-bottom: 4px;
-}
-
-.auto-import-stat {
- font-size: 12px;
- color: rgba(255,255,255,0.5);
- font-weight: 500;
-}
-
-.auto-import-stat-review { color: #fbbf24; }
-.auto-import-stat-failed { color: #f87171; }
-
-/* Filter pills */
-.auto-import-filters {
- display: flex;
- align-items: center;
- gap: 6px;
- padding: 6px 0;
- margin-bottom: 8px;
-}
-
-/* Batch action buttons */
-.auto-import-batch-btn {
- font-size: 11px;
- padding: 4px 12px;
- border-radius: 6px;
- border: 1px solid rgba(var(--accent-rgb), 0.3);
- background: rgba(var(--accent-rgb), 0.08);
- color: rgba(var(--accent-rgb), 1);
- cursor: pointer;
- transition: all 0.15s;
-}
-
-.auto-import-batch-btn:hover {
- background: rgba(var(--accent-rgb), 0.15);
-}
-
-.auto-import-clear-btn {
- border-color: rgba(255,255,255,0.1);
- background: rgba(255,255,255,0.04);
- color: rgba(255,255,255,0.5);
-}
-
-.auto-import-clear-btn:hover {
- background: rgba(255,255,255,0.08);
- color: rgba(255,255,255,0.8);
-}
-
-.auto-import-card-meta {
- display: flex; gap: 8px; align-items: center;
- font-size: 10px; color: rgba(255,255,255,0.3); margin-top: 3px;
-}
-
-.auto-import-method-badge {
- background: rgba(255,255,255,0.06);
- padding: 1px 6px;
- border-radius: 4px;
- font-size: 9px;
- color: rgba(255,255,255,0.45);
- text-transform: uppercase;
- letter-spacing: 0.3px;
-}
-
-.auto-import-card-folder-path {
- font-size: 9px;
- color: rgba(255,255,255,0.15);
- margin-top: 6px;
- padding-top: 6px;
- border-top: 1px solid rgba(255,255,255,0.03);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-/* Expandable track list */
-.auto-import-track-list {
- display: none;
- margin-top: 8px;
- padding-top: 8px;
- border-top: 1px solid rgba(255,255,255,0.04);
-}
-
-.auto-import-track-list.expanded {
- display: block;
-}
-
-.auto-import-track-list-header {
- display: grid;
- grid-template-columns: 1fr 1fr 50px;
- gap: 8px;
- font-size: 9px;
- font-weight: 600;
- color: rgba(255,255,255,0.3);
- text-transform: uppercase;
- letter-spacing: 0.5px;
- padding-bottom: 4px;
- margin-bottom: 4px;
- border-bottom: 1px solid rgba(255,255,255,0.03);
-}
-
-.auto-import-track-row {
- display: grid;
- grid-template-columns: 1fr 1fr 50px;
- gap: 8px;
- padding: 3px 0;
- font-size: 11px;
-}
-
-.auto-import-track-name {
- color: rgba(255,255,255,0.7);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.auto-import-track-file {
- color: rgba(255,255,255,0.3);
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
-}
-
-.auto-import-track-conf {
- text-align: right;
- font-weight: 600;
- font-size: 10px;
-}
-
-.auto-import-track-conf.auto-import-conf-high { color: #4ade80; }
-.auto-import-track-conf.auto-import-conf-medium { color: #fbbf24; }
-.auto-import-track-conf.auto-import-conf-low { color: #f87171; }
-
-.auto-import-status-badge {
- font-size: 9px; font-weight: 600; padding: 2px 8px;
- border-radius: 6px; white-space: nowrap;
-}
-.auto-import-badge-completed { background: rgba(74,222,128,0.1); color: #4ade80; }
-.auto-import-badge-review { background: rgba(251,191,36,0.1); color: #fbbf24; }
-.auto-import-badge-failed { background: rgba(248,113,113,0.1); color: #f87171; }
-.auto-import-badge-neutral { background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.4); }
-.auto-import-badge-processing {
- background: rgba(96,165,250,0.12);
- color: #60a5fa;
- animation: auto-import-badge-pulse 1.6s ease-in-out infinite;
-}
-@keyframes auto-import-badge-pulse {
- 0%, 100% { opacity: 1; }
- 50% { opacity: 0.55; }
-}
-
-.auto-import-track-row-active {
- background: rgba(96,165,250,0.08);
- border-left: 2px solid #60a5fa;
- padding-left: 6px;
- margin-left: -8px;
- border-radius: 3px;
-}
-.auto-import-track-row-active .auto-import-track-name { color: #60a5fa; font-weight: 600; }
-.auto-import-track-row-done .auto-import-track-name,
-.auto-import-track-row-done .auto-import-track-file { opacity: 0.4; }
-
-.auto-import-actions {
- display: flex; gap: 4px; margin-top: 4px;
-}
-
-.auto-import-actions button { font-size: 10px; padding: 3px 10px; }
-
-@media (max-width: 768px) {
- .auto-import-card { flex-direction: column; align-items: flex-start; }
- .auto-import-card-right { flex-direction: row; width: 100%; justify-content: space-between; }
-}
-
/* ── Legacy (hidden) ── */
#wishlist-page-categories { display: none;
margin-bottom: 24px;
diff --git a/webui/tests/shell-routes.smoke.spec.ts b/webui/tests/shell-routes.smoke.spec.ts
index f2854c0c..818dd925 100644
--- a/webui/tests/shell-routes.smoke.spec.ts
+++ b/webui/tests/shell-routes.smoke.spec.ts
@@ -60,6 +60,10 @@ function expectedUrlPattern(path: string, pageId: ShellPageId): RegExp {
return /\/stats(?:\?range=7d)?$/;
}
+ if (pageId === 'import') {
+ return /\/import\/album$/;
+ }
+
return new RegExp(`${path.replace('/', '\\/')}$`);
}