feat(device-sync): USB/SD card sync page (WIP)

Adds a new Device Sync page for transferring music from Navidrome to
USB drives or SD cards. Sidebar entry is hidden by default.

- Four new Tauri commands: sync_track_to_device, compute_sync_paths,
  list_device_dir_files, delete_device_file
- Filename template engine with cross-platform path sanitization
- 4-concurrent-worker download, live progress via device:sync:progress event
- Persistent source list (albums/playlists/artists) with checkbox deletion
- Expandable artist tree in browser panel (per-album selection)
- i18n: DE + EN complete; other locales have stub keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-14 00:02:13 +02:00
parent 58f99ae846
commit 17a5c92174
15 changed files with 1341 additions and 2 deletions
+89
View File
@@ -0,0 +1,89 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface DeviceSyncSource {
type: 'album' | 'playlist' | 'artist';
id: string;
name: string;
}
export interface DeviceSyncJob {
id: string;
total: number;
done: number;
skipped: number;
failed: number;
status: 'running' | 'done' | 'cancelled';
}
interface DeviceSyncState {
targetDir: string | null;
filenameTemplate: string;
sources: DeviceSyncSource[]; // persistent device content list
checkedIds: string[]; // currently checked for deletion (not persisted)
activeJob: DeviceSyncJob | null;
setTargetDir: (dir: string | null) => void;
setFilenameTemplate: (t: string) => void;
addSource: (source: DeviceSyncSource) => void;
removeSource: (id: string) => void;
clearSources: () => void;
toggleChecked: (id: string) => void;
setCheckedIds: (ids: string[]) => void;
setActiveJob: (job: DeviceSyncJob | null) => void;
updateJob: (update: Partial<DeviceSyncJob>) => void;
}
export const useDeviceSyncStore = create<DeviceSyncState>()(
persist(
(set) => ({
targetDir: null,
filenameTemplate: '{artist}/{album}/{track_number} - {title}',
sources: [],
checkedIds: [],
activeJob: null,
setTargetDir: (dir) => set({ targetDir: dir }),
setFilenameTemplate: (t) => set({ filenameTemplate: t }),
addSource: (source) =>
set((s) => ({
sources: s.sources.some((x) => x.id === source.id)
? s.sources
: [...s.sources, source],
})),
removeSource: (id) =>
set((s) => ({
sources: s.sources.filter((x) => x.id !== id),
checkedIds: s.checkedIds.filter((x) => x !== id),
})),
clearSources: () => set({ sources: [], checkedIds: [] }),
toggleChecked: (id) =>
set((s) => ({
checkedIds: s.checkedIds.includes(id)
? s.checkedIds.filter((x) => x !== id)
: [...s.checkedIds, id],
})),
setCheckedIds: (ids) => set({ checkedIds: ids }),
setActiveJob: (job) => set({ activeJob: job }),
updateJob: (update) =>
set((s) => ({
activeJob: s.activeJob ? { ...s.activeJob, ...update } : null,
})),
}),
{
name: 'psysonic_device_sync',
partialize: (s) => ({
targetDir: s.targetDir,
filenameTemplate: s.filenameTemplate,
sources: s.sources,
}),
}
)
);
+1
View File
@@ -20,6 +20,7 @@ export const DEFAULT_SIDEBAR_ITEMS: SidebarItemConfig[] = [
{ id: 'mostPlayed', visible: true },
{ id: 'radio', visible: true },
{ id: 'folderBrowser', visible: false },
{ id: 'deviceSync', visible: false },
{ id: 'statistics', visible: true },
{ id: 'help', visible: true },
];