import React, { useEffect, useState, useRef, useCallback, useMemo } 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, Clock, ChevronRight, ChevronDown, Trash2, Undo2, Search, Usb, RefreshCw, Shuffle, Zap, } from 'lucide-react'; import CustomSelect from '../components/CustomSelect'; import { useTranslation } from 'react-i18next'; import { useDeviceSyncStore, DeviceSyncSource } from '../store/deviceSyncStore'; import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore'; import { getPlaylists, getAlbumList, getArtists, getAlbum, getPlaylist, getArtist, buildDownloadUrl, search as searchSubsonic, 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 { 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, }; } type SyncStatus = 'synced' | 'pending' | 'deletion'; interface RemovableDrive { name: string; mount_point: string; available_space: number; total_space: number; file_system: string; is_removable: boolean; } function formatBytes(bytes: number): string { if (bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; } // ─── 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 pendingDeletion = useDeviceSyncStore(s => s.pendingDeletion); const deviceFilePaths = useDeviceSyncStore(s => s.deviceFilePaths); const scanning = useDeviceSyncStore(s => s.scanning); const { setTargetDir, setFilenameTemplate, addSource, removeSource, clearSources, toggleChecked, setCheckedIds, markForDeletion, unmarkDeletion, removeSources, setDeviceFilePaths, setScanning, } = useDeviceSyncStore.getState(); const jobStatus = useDeviceSyncJobStore(s => s.status); const jobDone = useDeviceSyncJobStore(s => s.done); const jobSkip = useDeviceSyncJobStore(s => s.skipped); const jobFail = useDeviceSyncJobStore(s => s.failed); const jobTotal = useDeviceSyncJobStore(s => s.total); const [activeTab, setActiveTab] = useState('albums'); const [search, setSearch] = useState(''); const [playlists, setPlaylists] = useState([]); const [randomAlbums, setRandomAlbums] = useState([]); const [albumSearchResults, setAlbumSearchResults] = useState([]); const [albumSearchLoading, setAlbumSearchLoading] = useState(false); const [artists, setArtists] = useState([]); const [loadingBrowser, setLoadingBrowser] = useState(false); const [expandedArtistIds, setExpandedArtistIds] = useState>(new Set()); const [artistAlbumsMap, setArtistAlbumsMap] = useState>(new Map()); const [loadingArtistIds, setLoadingArtistIds] = useState>(new Set()); // Map source IDs → computed device paths (for status derivation) const [sourcePathsMap, setSourcePathsMap] = useState>(new Map()); // ─── Removable drive detection ────────────────────────────────────────── const [drives, setDrives] = useState([]); const [drivesLoading, setDrivesLoading] = useState(false); const [preSyncOpen, setPreSyncOpen] = useState(false); const [preSyncLoading, setPreSyncLoading] = useState(false); const [syncDelta, setSyncDelta] = useState({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] }); const refreshDrives = useCallback(async () => { setDrivesLoading(true); try { const result = await invoke('get_removable_drives'); setDrives(result); } catch { setDrives([]); } finally { setDrivesLoading(false); } }, []); // Fetch drives on mount, then poll every 5 seconds useEffect(() => { refreshDrives(); const interval = setInterval(refreshDrives, 5000); return () => clearInterval(interval); }, [refreshDrives]); // Detect if the current targetDir is on a detected removable drive const activeDrive = useMemo(() => { if (!targetDir) return null; return drives.find(d => targetDir.startsWith(d.mount_point)) ?? null; }, [targetDir, drives]); const driveDetected = activeDrive !== null; const isRunning = jobStatus === 'running'; // ─── Device scan on mount ─────────────────────────────────────────────── const scanDevice = useCallback(async () => { if (!targetDir || sources.length === 0) { setDeviceFilePaths([]); return; } setScanning(true); try { const files = await invoke('list_device_dir_files', { dir: targetDir }); setDeviceFilePaths(files); } catch { setDeviceFilePaths([]); } finally { setScanning(false); } }, [targetDir, sources.length]); // Scan device on mount and when targetDir changes useEffect(() => { scanDevice(); }, [scanDevice]); // Auto-import manifest when page loads and drive is already connected const manifestImportedRef = useRef(false); useEffect(() => { if (!targetDir || !driveDetected || manifestImportedRef.current) return; manifestImportedRef.current = true; invoke<{ version: number; sources: DeviceSyncSource[] } | null>( 'read_device_manifest', { destDir: targetDir } ).then(manifest => { if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } }).catch(() => {}); }, [targetDir, driveDetected, t]); // Clear device file list and reset import flag when stick is unplugged useEffect(() => { if (!driveDetected) { setDeviceFilePaths([]); manifestImportedRef.current = false; } }, [driveDetected]); // Compute expected paths for each source (for status comparison) useEffect(() => { if (!targetDir || sources.length === 0) { setSourcePathsMap(new Map()); return; } let cancelled = false; (async () => { const map = new Map(); await Promise.all(sources.map(async source => { if (cancelled) return; try { const tracks = await fetchTracksForSource(source); const paths = await invoke('compute_sync_paths', { tracks: tracks.map(t => trackToSyncInfo(t, '')), destDir: targetDir, template: filenameTemplate, }); map.set(source.id, paths); } catch { map.set(source.id, []); } })); if (!cancelled) setSourcePathsMap(map); })(); return () => { cancelled = true; }; }, [targetDir, filenameTemplate, sources]); // Derive sync status per source const sourceStatuses = useMemo(() => { const deviceSet = new Set(deviceFilePaths); const statuses = new Map(); for (const source of sources) { if (pendingDeletion.includes(source.id)) { statuses.set(source.id, 'deletion'); } else { const paths = sourcePathsMap.get(source.id) ?? []; const allSynced = paths.length > 0 && paths.every(p => deviceSet.has(p)); statuses.set(source.id, allSynced ? 'synced' : 'pending'); } } return statuses; }, [sources, pendingDeletion, sourcePathsMap, deviceFilePaths]); // ─── Desired State / Diff Logic ───────────────────────────────────────── const handleToggleSource = useCallback((source: DeviceSyncSource) => { const isSelected = sources.some(s => s.id === source.id); const isPendingDeletion = pendingDeletion.includes(source.id); const isActuallySelected = isSelected && !isPendingDeletion; if (isActuallySelected) { // User initiated a DE-SELECTION. Diff check against target device const isSynced = sourceStatuses.get(source.id) === 'synced'; const pathsOnDisk = sourcePathsMap.get(source.id)?.filter(p => deviceFilePaths.includes(p)).length || 0; if (pathsOnDisk > 0 || isSynced) { // Source currently has physical footprint. Stage for deletion. markForDeletion([source.id]); } else { // Zero physical footprint. Strip safely. removeSource(source.id); } } else { // User initiated a SELECTION. if (isPendingDeletion) { unmarkDeletion(source.id); // Cancel queued red/strikethrough state } else if (!isSelected) { addSource(source); // Trigger clean pending install state } } }, [sources, pendingDeletion, sourceStatuses, sourcePathsMap, deviceFilePaths, markForDeletion, removeSource, unmarkDeletion, addSource]); // ─── Listen for background sync events ────────────────────────────────── useEffect(() => { const jobStore = useDeviceSyncJobStore.getState; const unlistenProgress = listen<{ jobId: string; done: number; skipped: number; failed: number; total: number; }>('device:sync:progress', ({ payload }) => { const current = jobStore(); if (current.jobId && payload.jobId === current.jobId) { useDeviceSyncJobStore.getState().updateProgress( payload.done, payload.skipped, payload.failed ); } }); const unlistenComplete = listen<{ jobId: string; done: number; skipped: number; failed: number; total: number; cancelled?: boolean; }>('device:sync:complete', ({ payload }) => { const current = jobStore(); if (current.jobId && payload.jobId === current.jobId) { if (payload.cancelled) { useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); // status is already 'cancelled' from the button click; complete() would overwrite it — restore it useDeviceSyncJobStore.getState().cancel(); } else { useDeviceSyncJobStore.getState().complete(payload.done, payload.skipped, payload.failed); showToast( t('deviceSync.syncResult', { done: payload.done, skipped: payload.skipped, total: payload.total }), 5000, 'info' ); // Write manifest so another machine can read the synced sources from the stick const { targetDir: dir, sources: srcs } = useDeviceSyncStore.getState(); if (dir) invoke('write_device_manifest', { destDir: dir, sources: srcs }).catch(() => {}); } // Re-scan the device after sync completes (cancelled or not) scanDevice(); } }); return () => { unlistenProgress.then(f => f()); unlistenComplete.then(f => f()); }; }, [t, scanDevice]); // Load browser data when tab switches useEffect(() => { setSearch(''); if (activeTab === 'playlists' && playlists.length === 0) loadPlaylists(); if (activeTab === 'albums' && randomAlbums.length === 0) loadRandomAlbums(); if (activeTab === 'artists' && artists.length === 0) loadArtists(); // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab]); // Live album search with 300ms debounce useEffect(() => { if (activeTab !== 'albums') return; const q = search.trim(); if (!q) { setAlbumSearchResults([]); return; } setAlbumSearchLoading(true); const timer = setTimeout(async () => { try { const { albums } = await searchSubsonic(q, { albumCount: 20, artistCount: 0, songCount: 0 }); setAlbumSearchResults(albums); } catch { setAlbumSearchResults([]); } finally { setAlbumSearchLoading(false); } }, 300); return () => { clearTimeout(timer); setAlbumSearchLoading(false); }; }, [search, activeTab]); const loadPlaylists = useCallback(async () => { setLoadingBrowser(true); try { setPlaylists(await getPlaylists()); } catch { /* ignore */ } finally { setLoadingBrowser(false); } }, []); const loadRandomAlbums = useCallback(async () => { setLoadingBrowser(true); try { setRandomAlbums(await getAlbumList('random', 10)); } 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 = useMemo(() => playlists.filter(p => p.name.toLowerCase().includes(q)), [playlists, q]); const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]); const handleChooseFolder = async () => { const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') }); if (sel) { const dir = sel as string; setTargetDir(dir); // If the device has a psysonic-sync.json, always import it — replacing any // sources from a previous device so switching sticks works correctly. try { const manifest = await invoke<{ version: number; sources: DeviceSyncSource[] } | null>( 'read_device_manifest', { destDir: dir } ); if (manifest?.sources?.length) { useDeviceSyncStore.getState().clearSources(); manifest.sources.forEach(s => useDeviceSyncStore.getState().addSource(s)); showToast(t('deviceSync.manifestImported', { count: manifest.sources.length }), 4000, 'info'); } } catch { /* no manifest, that's fine */ } // Trigger a device scan after folder change setTimeout(() => scanDevice(), 100); } }; // ─── Sync (non-blocking) ──────────────────────────────────────────────── const promptSyncSummary = async () => { if (!targetDir) { showToast(t('deviceSync.noTargetDir'), 3000, 'error'); return; } if (sources.length === 0){ showToast(t('deviceSync.noSources'), 3000, 'error'); return; } setPreSyncLoading(true); setPreSyncOpen(true); try { const { getClient } = await import('../api/subsonic'); const { baseUrl, params } = getClient(); const payload = await invoke<{ addBytes: number; addCount: number; delBytes: number; delCount: number; availableBytes: number; tracks: SubsonicSong[]; }>('calculate_sync_payload', { sources, deletionIds: pendingDeletion, auth: { baseUrl, ...params }, targetDir, template: filenameTemplate, }); setSyncDelta(payload); } catch { showToast(t('deviceSync.fetchError'), 3000, 'error'); setPreSyncOpen(false); } finally { setPreSyncLoading(false); } }; const handleSyncExecution = async () => { setPreSyncOpen(false); // 1. Handle pending deletions first const deletionSources = sources.filter(s => pendingDeletion.includes(s.id)); if (deletionSources.length > 0) { try { const allPaths: string[] = []; const trackArrays = await Promise.all(deletionSources.map(s => fetchTracksForSource(s))); const deletionTracks = trackArrays.flat(); const paths = await invoke('compute_sync_paths', { tracks: deletionTracks.map(t => trackToSyncInfo(t, '')), destDir: targetDir, template: filenameTemplate, }); allPaths.push(...paths); await invoke('delete_device_files', { paths: allPaths }); removeSources(deletionSources.map(s => s.id)); // Update manifest so it stays in sync after deletions const remainingSources = useDeviceSyncStore.getState().sources; if (targetDir) invoke('write_device_manifest', { destDir: targetDir, sources: remainingSources }).catch(() => {}); showToast( t('deviceSync.deleteComplete', { count: deletionSources.length }), 3000, 'info' ); } catch { showToast(t('deviceSync.fetchError'), 3000, 'error'); } } const allTracks = syncDelta.tracks; if (allTracks.length === 0) { scanDevice(); return; } const jobId = uuid(); useDeviceSyncJobStore.getState().startSync(jobId, allTracks.length); showToast(t('deviceSync.syncInBackground'), 3000, 'info'); invoke('sync_batch_to_device', { tracks: allTracks.map(track => trackToSyncInfo(track, buildDownloadUrl(track.id))), destDir: targetDir, template: filenameTemplate, jobId, expectedBytes: syncDelta.addBytes, }).catch((err: string) => { useDeviceSyncJobStore.getState().complete(0, 0, allTracks.length); if (err.includes('NOT_ENOUGH_SPACE')) { showToast(t('deviceSync.notEnoughSpace'), 5000, 'error'); } else if (err === 'NOT_MOUNTED_VOLUME') { showToast(t('deviceSync.notMountedVolume'), 5000, 'error'); } else { showToast(t('deviceSync.fetchError'), 3000, 'error'); } }); }; // ─── Actions ──────────────────────────────────────────────────────────── const handleMarkCheckedForDeletion = () => { if (checkedIds.length === 0) return; markForDeletion(checkedIds); }; const allChecked = sources.length > 0 && sources.every(s => checkedIds.includes(s.id)); const toggleAll = () => setCheckedIds(allChecked ? [] : sources.map(s => s.id)); const pendingCount = Array.from(sourceStatuses.values()).filter(s => s === 'pending').length; const syncedCount = Array.from(sourceStatuses.values()).filter(s => s === 'synced').length; const deletionCount = pendingDeletion.length; // ─── Dynamic action button label ──────────────────────────────────────── const actionButtonLabel = useMemo(() => { if (deletionCount > 0 && pendingCount === 0) return t('deviceSync.actionDelete'); if (pendingCount > 0 && deletionCount === 0) return t('deviceSync.actionTransfer'); if (pendingCount > 0 && deletionCount > 0) return t('deviceSync.actionApplyAll'); return t('deviceSync.syncButton'); // both zero — button will be disabled }, [pendingCount, deletionCount, t]); const actionButtonDisabled = !targetDir || sources.length === 0 || isRunning || (!driveDetected && !!targetDir) || (pendingCount === 0 && deletionCount === 0); // ─── Template preview (dummy track) ───────────────────────────────────── const PREVIEW_TRACK = { artist: 'Volker Pispers', album: '...Bis Neulich 2007', title: 'Kapitalismus', track_number: '01', disc_number: '1', year: '2007', } as const; const templatePreviewText = useMemo(() => { try { const result = filenameTemplate .replace(/\{artist\}/g, PREVIEW_TRACK.artist) .replace(/\{album\}/g, PREVIEW_TRACK.album) .replace(/\{title\}/g, PREVIEW_TRACK.title) .replace(/\{track_number\}/g, PREVIEW_TRACK.track_number) .replace(/\{disc_number\}/g, PREVIEW_TRACK.disc_number) .replace(/\{year\}/g, PREVIEW_TRACK.year); return `${result}.mp3`; } catch { return ''; } }, [filenameTemplate]); const tabs: { key: SourceTab; icon: React.ReactNode; label: string }[] = [ { key: 'playlists', icon: , label: t('deviceSync.tabPlaylists') }, { key: 'albums', icon: , label: t('deviceSync.tabAlbums') }, { key: 'artists', icon: , label: t('deviceSync.tabArtists') }, ]; return (
{/* ── Header ── */}

{t('deviceSync.title')}

{/* ── Left: Template ── */}
{t('deviceSync.filenameTemplate')}
setFilenameTemplate(e.target.value)} spellCheck={false} data-tooltip={t('deviceSync.templateHint')} data-tooltip-pos="bottom" /> {templatePreviewText && ( {t('deviceSync.templatePreview')}: {templatePreviewText} )}
{/* ── Right: Drive config ── */}
{t('deviceSync.targetDevice')}
{/* Row 1: Controls */}
{/* Fallback manual folder picker & Refresh */} {/* Dropdown element */} {drives.length > 0 ? ( <> { setTargetDir(v); if (v) { setTimeout(() => scanDevice(), 100); } }} options={[ { value: '', label: t('deviceSync.selectDrive') }, ...drives.map(d => ({ value: d.mount_point, label: d.name || d.mount_point })) ]} /> ) : ( {t('deviceSync.noDrivesDetected')} )}
{/* Row 2: Metadata */} {activeDrive && (
{formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system}
)}
{/* ── Main ── */}
{/* ── Browser (left) ── */}
{tabs.map(tab => ( ))}
setSearch(e.target.value)} /> {activeTab === 'albums' && ( {t('deviceSync.liveSearch')} )}
{(loadingBrowser || albumSearchLoading) && (
)} {activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
{t('deviceSync.randomAlbumsLabel')}
)} {activeTab === 'playlists' && filteredPlaylists.map(pl => ( s.id === pl.id) && !pendingDeletion.includes(pl.id)} onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} /> ))} {activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => ( s.id === al.id) && !pendingDeletion.includes(al.id)} onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} /> ))} {activeTab === 'artists' && filteredArtists.map(ar => (
{ar.name} {ar.albumCount != null && {ar.albumCount} Albums}
{expandedArtistIds.has(ar.id) && artistAlbumsMap.has(ar.id) && artistAlbumsMap.get(ar.id)!.map(al => ( s.id === al.id) && !pendingDeletion.includes(al.id)} indent onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name })} /> )) }
))}
{/* ── Device Manager (right) ── */}
{t('deviceSync.onDevice')} {scanning && }
{/* Sync button */} {/* Mark for deletion */} {checkedIds.length > 0 && !isRunning && ( )}
{/* Status summary badges */} {sources.length > 0 && driveDetected && (
{syncedCount > 0 && ( {syncedCount} {t('deviceSync.statusSynced')} )} {pendingCount > 0 && ( {pendingCount} {t('deviceSync.statusPending')} )} {deletionCount > 0 && ( {deletionCount} {t('deviceSync.statusDeletion')} )}
)} {sources.length === 0 || !driveDetected ? (

{t('deviceSync.noSourcesSelected')}

) : ( <>
{t('deviceSync.colName')} {t('deviceSync.colType')} {t('deviceSync.colStatus')}
{sources.map(s => { const status = sourceStatuses.get(s.id) ?? 'pending'; return ( ); })}
)} {/* Background sync progress (non-blocking) */} {jobStatus === 'running' && (
0 ? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%` : '0%' }} />
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })} {jobFail > 0 && {jobFail}}
)} {jobStatus === 'cancelled' && (
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
)} {jobStatus === 'done' && (
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
)}
{/* Pre-Sync Summary Modal */} {preSyncOpen && (

{t('deviceSync.syncSummary')}

{preSyncLoading ? (

{t('deviceSync.calculating')}

) : (
{t('deviceSync.filesToAdd')} +{syncDelta.addCount} ({(syncDelta.addBytes / 1_048_576).toFixed(1)} MB)
{t('deviceSync.filesToDelete')} -{syncDelta.delCount} ({(syncDelta.delBytes / 1_048_576).toFixed(1)} MB)

{t('deviceSync.netChange')} {((syncDelta.addBytes - syncDelta.delBytes) / 1_048_576).toFixed(1)} MB
syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}> {t('deviceSync.availableSpace')} {(syncDelta.availableBytes / 1_048_576).toFixed(1)} MB
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
{t('deviceSync.spaceWarning')}
)}
)} {!preSyncLoading && (
)}
)}
); } // ─── BrowserRow ────────────────────────────────────────────────────────────── function BrowserRow({ name, meta, selected, onToggle, indent }: { name: string; meta?: string; selected: boolean; onToggle: () => void; indent?: boolean; }) { return ( ); }