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
+464
View File
@@ -0,0 +1,464 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import {
HardDriveUpload, FolderOpen, Loader2,
ListMusic, Disc3, Users, CheckCircle2, AlertCircle, SkipForward, Trash2,
ChevronRight, ChevronDown,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore';
import {
getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist,
buildDownloadUrl, SubsonicSong, SubsonicAlbum, SubsonicPlaylist, SubsonicArtist,
} from '../api/subsonic';
import { showToast } from '../utils/toast';
type SourceTab = 'playlists' | 'albums' | 'artists';
// ─── helpers ─────────────────────────────────────────────────────────────────
function uuid(): string { return crypto.randomUUID(); }
async function fetchTracksForSource(source: DeviceSyncSource): Promise<SubsonicSong[]> {
if (source.type === 'playlist') { const { songs } = await getPlaylist(source.id); return songs; }
if (source.type === 'album') { const { songs } = await getAlbum(source.id); return songs; }
const { albums } = await getArtist(source.id);
const all: SubsonicSong[] = [];
for (const album of albums) { const { songs } = await getAlbum(album.id); all.push(...songs); }
return all;
}
function trackToSyncInfo(track: SubsonicSong, url: string) {
return {
id: track.id, url,
suffix: track.suffix ?? 'mp3',
artist: track.artist ?? '',
album: track.album ?? '',
title: track.title ?? '',
trackNumber: track.track,
discNumber: track.discNumber,
year: track.year,
};
}
// ─── component ───────────────────────────────────────────────────────────────
export default function DeviceSync() {
const { t } = useTranslation();
const targetDir = useDeviceSyncStore(s => s.targetDir);
const filenameTemplate = useDeviceSyncStore(s => s.filenameTemplate);
const sources = useDeviceSyncStore(s => s.sources);
const checkedIds = useDeviceSyncStore(s => s.checkedIds);
const activeJob = useDeviceSyncStore(s => s.activeJob);
const { setTargetDir, setFilenameTemplate, addSource, removeSource,
clearSources, toggleChecked, setCheckedIds, setActiveJob, updateJob } =
useDeviceSyncStore.getState();
const [activeTab, setActiveTab] = useState<SourceTab>('albums');
const [search, setSearch] = useState('');
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
const [loadingBrowser, setLoadingBrowser] = useState(false);
const [deleting, setDeleting] = useState(false);
const [expandedArtistIds, setExpandedArtistIds] = useState<Set<string>>(new Set());
const [artistAlbumsMap, setArtistAlbumsMap] = useState<Map<string, SubsonicAlbum[]>>(new Map());
const [loadingArtistIds, setLoadingArtistIds] = useState<Set<string>>(new Set());
const cancelRef = useRef(false);
// Load browser data when tab switches
useEffect(() => {
setSearch('');
if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists();
if (activeTab === 'albums' && albums.length === 0) loadAlbums();
if (activeTab === 'artists' && artists.length === 0) loadArtists();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const loadPlaylists = useCallback(async () => {
setLoadingBrowser(true);
try { setPlaylists(await getPlaylists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadAlbums = useCallback(async () => {
setLoadingBrowser(true);
try { setAlbums(await getAlbumList('alphabeticalByName', 500, 0)); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const loadArtists = useCallback(async () => {
setLoadingBrowser(true);
try { setArtists(await getArtists()); } catch { /* ignore */ }
finally { setLoadingBrowser(false); }
}, []);
const toggleArtistExpand = useCallback(async (artistId: string) => {
setExpandedArtistIds(prev => {
const next = new Set(prev);
if (next.has(artistId)) { next.delete(artistId); return next; }
next.add(artistId);
return next;
});
if (!artistAlbumsMap.has(artistId)) {
setLoadingArtistIds(prev => new Set(prev).add(artistId));
try {
const { albums } = await getArtist(artistId);
setArtistAlbumsMap(prev => new Map(prev).set(artistId, albums));
} finally {
setLoadingArtistIds(prev => { const n = new Set(prev); n.delete(artistId); return n; });
}
}
}, [artistAlbumsMap]);
const q = search.toLowerCase();
const filteredPlaylists = playlists.filter(p => p.name.toLowerCase().includes(q));
const filteredAlbums = albums.filter(a =>
a.name.toLowerCase().includes(q) || (a.artist ?? '').toLowerCase().includes(q));
const filteredArtists = artists.filter(a => a.name.toLowerCase().includes(q));
const handleChooseFolder = async () => {
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
if (sel) setTargetDir(sel as string);
};
// ─── Sync ────────────────────────────────────────────────────────────────
const handleSync = async () => {
if (!targetDir) { showToast(t('deviceSync.noTargetDir'), 3000, 'error'); return; }
if (sources.length === 0){ showToast(t('deviceSync.noSources'), 3000, 'error'); return; }
cancelRef.current = false;
const jobId = uuid();
setActiveJob({ id: jobId, total: 0, done: 0, skipped: 0, failed: 0, status: 'running' });
let allTracks: SubsonicSong[] = [];
try {
for (const source of sources) {
if (cancelRef.current) break;
allTracks.push(...await fetchTracksForSource(source));
}
} catch {
showToast(t('deviceSync.fetchError'), 3000, 'error');
setActiveJob(null);
return;
}
const seen = new Set<string>();
allTracks = allTracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
if (allTracks.length === 0) {
showToast(t('deviceSync.noTracks'), 3000, 'error');
setActiveJob(null);
return;
}
updateJob({ total: allTracks.length });
const unlisten = await listen<{ jobId: string; status: string }>(
'device:sync:progress',
({ payload }) => {
if (payload.jobId !== jobId) return;
const st = useDeviceSyncStore.getState().activeJob!;
if (payload.status === 'done') updateJob({ done: st.done + 1 });
else if (payload.status === 'skipped') updateJob({ skipped: st.skipped + 1 });
else if (payload.status === 'error') updateJob({ failed: st.failed + 1 });
}
);
const CONCURRENCY = 4;
let idx = 0;
const worker = async () => {
while (idx < allTracks.length && !cancelRef.current) {
const track = allTracks[idx++];
try {
await invoke('sync_track_to_device', {
track: trackToSyncInfo(track, buildDownloadUrl(track.id)),
destDir: targetDir,
template: filenameTemplate,
jobId,
});
} catch { /* emitted via event */ }
}
};
try {
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
} finally {
unlisten();
}
updateJob({ status: cancelRef.current ? 'cancelled' : 'done' });
};
// ─── Delete checked items from device ────────────────────────────────────
const handleDeleteChecked = async () => {
if (!targetDir || checkedIds.length === 0) return;
const toDelete = sources.filter(s => checkedIds.includes(s.id));
const confirmed = window.confirm(
t('deviceSync.confirmDelete', { count: toDelete.length, names: toDelete.map(s => s.name).join(', ') })
);
if (!confirmed) return;
setDeleting(true);
try {
// Collect all tracks for the checked sources
let tracks: SubsonicSong[] = [];
for (const source of toDelete) {
tracks.push(...await fetchTracksForSource(source));
}
const seen = new Set<string>();
tracks = tracks.filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; });
// Compute expected device paths via Rust (same sanitizer as sync)
const paths = await invoke<string[]>('compute_sync_paths', {
tracks: tracks.map(t => trackToSyncInfo(t, '')),
destDir: targetDir,
template: filenameTemplate,
});
for (const path of paths) {
await invoke('delete_device_file', { path }).catch(() => {});
}
// Remove from the list
for (const s of toDelete) removeSource(s.id);
showToast(t('deviceSync.deleteComplete', { count: toDelete.length }), 3000, 'info');
} catch {
showToast(t('deviceSync.fetchError'), 3000, 'error');
} finally {
setDeleting(false);
}
};
const handleCancel = () => { cancelRef.current = true; };
const isRunning = activeJob?.status === 'running';
const isDone = activeJob?.status === 'done';
const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id));
const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id));
const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [
{ key: 'playlists', icon: <ListMusic size={14} />, label: t('deviceSync.tabPlaylists') },
{ key: 'albums', icon: <Disc3 size={14} />, label: t('deviceSync.tabAlbums') },
{ key: 'artists', icon: <Users size={14} />, label: t('deviceSync.tabArtists') },
];
return (
<div className="device-sync-page">
{/* ── Header ── */}
<div className="device-sync-header">
<HardDriveUpload size={20} />
<h1>{t('deviceSync.title')}</h1>
<div className="device-sync-header-config">
<span className="device-sync-folder-path" data-tooltip={targetDir ?? ''}>
{targetDir ?? t('deviceSync.noFolderChosen')}
</span>
<button className="btn btn-surface" onClick={handleChooseFolder}>
<FolderOpen size={13} />{t('deviceSync.chooseFolder')}
</button>
</div>
</div>
{/* ── Template (collapsed row) ── */}
<div className="device-sync-template-row">
<span className="device-sync-label-inline">{t('deviceSync.filenameTemplate')}</span>
<input
className="input device-sync-template-input"
value={filenameTemplate}
onChange={e => setFilenameTemplate(e.target.value)}
spellCheck={false}
data-tooltip={t('deviceSync.templateHint')}
data-tooltip-pos="bottom"
/>
</div>
{/* ── Main ── */}
<div className="device-sync-main">
{/* ── Browser (left) ── */}
<div className="device-sync-browser">
<div className="device-sync-tabs">
{tabs.map(tab => (
<button
key={tab.key}
className={`device-sync-tab${activeTab === tab.key ? ' active' : ''}`}
onClick={() => setActiveTab(tab.key)}
>
{tab.icon}{tab.label}
</button>
))}
</div>
<div className="device-sync-search-wrap">
<input
className="input"
placeholder={t('deviceSync.searchPlaceholder')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="device-sync-list">
{loadingBrowser && (
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
)}
{activeTab === 'playlists' && filteredPlaylists.map(pl => (
<BrowserRow key={pl.id} name={pl.name} meta={`${pl.songCount} tracks`}
selected={sources.some(s => s.id === pl.id)}
onToggle={() => sources.some(s => s.id === pl.id)
? removeSource(pl.id)
: addSource({ type: 'playlist', id: pl.id, name: pl.name })} />
))}
{activeTab === 'albums' && filteredAlbums.map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.artist}
selected={sources.some(s => s.id === al.id)}
onToggle={() => sources.some(s => s.id === al.id)
? removeSource(al.id)
: addSource({ type: 'album', id: al.id, name: al.name })} />
))}
{activeTab === 'artists' && filteredArtists.map(ar => (
<React.Fragment key={ar.id}>
<div className="device-sync-artist-row">
<button
className="device-sync-expand-btn"
onClick={() => toggleArtistExpand(ar.id)}
>
{loadingArtistIds.has(ar.id)
? <Loader2 size={13} className="spin" />
: expandedArtistIds.has(ar.id)
? <ChevronDown size={13} />
: <ChevronRight size={13} />}
</button>
<span className="device-sync-row-name">{ar.name}</span>
{ar.albumCount != null &&
<span className="device-sync-row-meta">{ar.albumCount} Alben</span>}
</div>
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) &&
artistAlbumsMap.get(ar.id)!.map(al => (
<BrowserRow key={al.id} name={al.name} meta={al.year?.toString()}
selected={sources.some(s => s.id === al.id)}
indent
onToggle={() => sources.some(s => s.id === al.id)
? removeSource(al.id)
: addSource({ type: 'album', id: al.id, name: al.name })} />
))
}
</React.Fragment>
))}
</div>
</div>
{/* ── Device list (right) ── */}
<div className="device-sync-device-panel">
<div className="device-sync-panel-header">
<span className="device-sync-panel-title">{t('deviceSync.onDevice')}</span>
<div className="device-sync-panel-actions">
{!activeJob && (
<button
className="btn btn-surface"
onClick={handleSync}
disabled={!targetDir || sources.length === 0}
>
<HardDriveUpload size={13} />
{t('deviceSync.syncButton')}
</button>
)}
{checkedIds.length > 0 && !isRunning && (
<button
className="btn btn-danger"
onClick={handleDeleteChecked}
disabled={deleting}
>
{deleting ? <Loader2 size={13} className="spin" /> : <Trash2 size={13} />}
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
</button>
)}
</div>
</div>
{sources.length === 0 ? (
<p className="device-sync-empty">{t('deviceSync.noSourcesSelected')}</p>
) : (
<>
<div className="device-sync-list-header">
<label className="device-sync-check-label">
<input type="checkbox" checked={allChecked} onChange={toggleAll} />
</label>
<span className="device-sync-list-col-name">{t('deviceSync.colName')}</span>
<span className="device-sync-list-col-type">{t('deviceSync.colType')}</span>
</div>
<div className="device-sync-device-list">
{sources.map(s => (
<label key={s.id} className={`device-sync-device-row${checkedIds.includes(s.id) ? ' checked' : ''}`}>
<input
type="checkbox"
checked={checkedIds.includes(s.id)}
onChange={() => toggleChecked(s.id)}
/>
<span className="device-sync-row-name">{s.name}</span>
<span className="device-sync-source-type">{s.type}</span>
</label>
))}
</div>
</>
)}
{/* Progress / sync result */}
{activeJob && (
<div className="device-sync-progress">
<div className="device-sync-progress-bar-wrap">
<div
className="device-sync-progress-bar"
style={{ width: activeJob.total > 0
? `${((activeJob.done + activeJob.skipped + activeJob.failed) / activeJob.total) * 100}%`
: '0%' }}
/>
</div>
<div className="device-sync-progress-stats">
{isRunning && <Loader2 size={13} className="spin" />}
{isDone && <CheckCircle2 size={13} className="color-success" />}
<span>
{isDone
? t('deviceSync.syncResult', { done: activeJob.done, skipped: activeJob.skipped, total: activeJob.total })
: `${activeJob.done + activeJob.skipped + activeJob.failed} / ${activeJob.total}`}
</span>
{activeJob.failed > 0 && (
<span className="device-sync-stat-error"><AlertCircle size={12} /> {activeJob.failed}</span>
)}
{activeJob.skipped > 0 && (
<span className="device-sync-stat-muted"><SkipForward size={12} /> {activeJob.skipped}</span>
)}
{isRunning
? <button className="btn btn-ghost" onClick={handleCancel}>{t('deviceSync.cancel')}</button>
: <button className="btn btn-ghost" onClick={() => setActiveJob(null)}>{t('deviceSync.dismiss')}</button>
}
</div>
</div>
)}
</div>
</div>
</div>
);
}
// ─── BrowserRow ──────────────────────────────────────────────────────────────
function BrowserRow({ name, meta, selected, onToggle, indent }: {
name: string; meta?: string; selected: boolean; onToggle: () => void; indent?: boolean;
}) {
return (
<button className={`device-sync-browser-row${selected ? ' selected' : ''}${indent ? ' indent' : ''}`} onClick={onToggle}>
<span className="device-sync-row-check">
{selected ? <CheckCircle2 size={14} /> : <span className="device-sync-row-circle" />}
</span>
<span className="device-sync-row-name">{name}</span>
{meta && <span className="device-sync-row-meta">{meta}</span>}
</button>
);
}