mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
refactor(deviceSync): co-locate device sync feature into features/deviceSync
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
meta?: string;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
indent?: boolean;
|
||||
}
|
||||
|
||||
export default function BrowserRow({ name, meta, selected, onToggle, indent }: Props) {
|
||||
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}
|
||||
{meta && <span className="device-sync-row-artist"> · {meta}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ChevronDown, ChevronRight, Disc3, ListMusic,
|
||||
Loader2, Shuffle, Users, Zap,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicPlaylist,
|
||||
} from '@/api/subsonicTypes';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import type { SourceTab } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import BrowserRow from '@/features/deviceSync/components/BrowserRow';
|
||||
|
||||
interface Props {
|
||||
activeTab: SourceTab;
|
||||
setActiveTab: (t: SourceTab) => void;
|
||||
search: string;
|
||||
setSearch: (v: string) => void;
|
||||
playlists: SubsonicPlaylist[];
|
||||
randomAlbums: SubsonicAlbum[];
|
||||
albumSearchResults: SubsonicAlbum[];
|
||||
albumSearchLoading: boolean;
|
||||
artists: SubsonicArtist[];
|
||||
loadingBrowser: boolean;
|
||||
expandedArtistIds: Set<string>;
|
||||
artistAlbumsMap: Map<string, SubsonicAlbum[]>;
|
||||
loadingArtistIds: Set<string>;
|
||||
toggleArtistExpand: (artistId: string) => Promise<void>;
|
||||
sources: DeviceSyncSource[];
|
||||
pendingDeletion: string[];
|
||||
handleToggleSource: (source: DeviceSyncSource) => void;
|
||||
}
|
||||
|
||||
export default function DeviceSyncBrowserPanel({
|
||||
activeTab, setActiveTab, search, setSearch,
|
||||
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
|
||||
artists, loadingBrowser,
|
||||
expandedArtistIds, artistAlbumsMap, loadingArtistIds, toggleArtistExpand,
|
||||
sources, pendingDeletion, handleToggleSource,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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') },
|
||||
];
|
||||
|
||||
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]);
|
||||
|
||||
return (
|
||||
<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)}
|
||||
/>
|
||||
{activeTab === 'albums' && (
|
||||
<span className="device-sync-live-badge">
|
||||
<Zap size={10} />{t('deviceSync.liveSearch')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-list">
|
||||
{(loadingBrowser || albumSearchLoading) && (
|
||||
<div className="device-sync-loading"><Loader2 size={16} className="spin" /></div>
|
||||
)}
|
||||
{activeTab === 'albums' && !search.trim() && !loadingBrowser && randomAlbums.length > 0 && (
|
||||
<div className="device-sync-section-label">
|
||||
<Shuffle size={11} />{t('deviceSync.randomAlbumsLabel')}
|
||||
</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) && !pendingDeletion.includes(pl.id)}
|
||||
onToggle={() => handleToggleSource({ type: 'playlist', id: pl.id, name: pl.name })} />
|
||||
))}
|
||||
{activeTab === 'albums' && (search.trim() ? albumSearchResults : randomAlbums).map(al => (
|
||||
<BrowserRow key={al.id} name={al.name} meta={al.artist}
|
||||
selected={sources.some(s => s.id === al.id) && !pendingDeletion.includes(al.id)}
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist })} />
|
||||
))}
|
||||
{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} Albums</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) && !pendingDeletion.includes(al.id)}
|
||||
indent
|
||||
onToggle={() => handleToggleSource({ type: 'album', id: al.id, name: al.name, artist: al.artist || ar.name })} />
|
||||
))
|
||||
}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
AlertCircle, CheckCircle2, Clock, HardDriveUpload, Loader2,
|
||||
Trash2, Undo2,
|
||||
} from 'lucide-react';
|
||||
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import type { SyncStatus } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
|
||||
interface Props {
|
||||
sources: DeviceSyncSource[];
|
||||
sourceStatuses: Map<string, SyncStatus>;
|
||||
driveDetected: boolean;
|
||||
scanning: boolean;
|
||||
checkedIds: string[];
|
||||
toggleChecked: (id: string) => void;
|
||||
allChecked: boolean;
|
||||
toggleAll: () => void;
|
||||
syncedCount: number;
|
||||
pendingCount: number;
|
||||
deletionCount: number;
|
||||
isRunning: boolean;
|
||||
actionButtonLabel: string;
|
||||
actionButtonDisabled: boolean;
|
||||
promptSyncSummary: () => Promise<void>;
|
||||
handleMarkCheckedForDeletion: () => void;
|
||||
handleToggleSource: (source: DeviceSyncSource) => void;
|
||||
markForDeletion: (ids: string[]) => void;
|
||||
unmarkDeletion: (id: string) => void;
|
||||
jobStatus: string;
|
||||
jobDone: number;
|
||||
jobSkip: number;
|
||||
jobFail: number;
|
||||
jobTotal: number;
|
||||
}
|
||||
|
||||
export default function DeviceSyncDevicePanel({
|
||||
sources, sourceStatuses, driveDetected, scanning,
|
||||
checkedIds, toggleChecked, allChecked, toggleAll,
|
||||
syncedCount, pendingCount, deletionCount,
|
||||
isRunning, actionButtonLabel, actionButtonDisabled,
|
||||
promptSyncSummary, handleMarkCheckedForDeletion, handleToggleSource,
|
||||
markForDeletion, unmarkDeletion,
|
||||
jobStatus, jobDone, jobSkip, jobFail, jobTotal,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="device-sync-device-panel">
|
||||
<div className="device-sync-panel-header">
|
||||
<span className="device-sync-panel-title">
|
||||
{t('deviceSync.onDevice')}
|
||||
{scanning && <Loader2 size={12} className="spin" style={{ marginLeft: 6 }} />}
|
||||
</span>
|
||||
<div className="device-sync-panel-actions">
|
||||
{/* Sync button */}
|
||||
<button
|
||||
className="btn btn-surface"
|
||||
onClick={promptSyncSummary}
|
||||
disabled={actionButtonDisabled}
|
||||
>
|
||||
{isRunning
|
||||
? <><Loader2 size={13} className="spin" /> {jobDone + jobSkip + jobFail}/{jobTotal}</>
|
||||
: <>
|
||||
{deletionCount > 0 && pendingCount === 0
|
||||
? <Trash2 size={13} />
|
||||
: <HardDriveUpload size={13} />}
|
||||
{actionButtonLabel}
|
||||
</>
|
||||
}
|
||||
</button>
|
||||
|
||||
{/* Mark for deletion */}
|
||||
{checkedIds.length > 0 && !isRunning && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={handleMarkCheckedForDeletion}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
{t('deviceSync.deleteFromDevice', { count: checkedIds.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status summary badges */}
|
||||
{sources.length > 0 && driveDetected && (
|
||||
<div className="device-sync-status-summary">
|
||||
{syncedCount > 0 && (
|
||||
<span className="device-sync-badge synced">
|
||||
<CheckCircle2 size={11} /> {syncedCount} {t('deviceSync.statusSynced')}
|
||||
</span>
|
||||
)}
|
||||
{pendingCount > 0 && (
|
||||
<span className="device-sync-badge pending">
|
||||
<Clock size={11} /> {pendingCount} {t('deviceSync.statusPending')}
|
||||
</span>
|
||||
)}
|
||||
{deletionCount > 0 && (
|
||||
<span className="device-sync-badge deletion">
|
||||
<Trash2 size={11} /> {deletionCount} {t('deviceSync.statusDeletion')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources.length === 0 || !driveDetected ? (
|
||||
<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>
|
||||
<span className="device-sync-list-col-status">{t('deviceSync.colStatus')}</span>
|
||||
<span className="device-sync-list-col-actions" />
|
||||
</div>
|
||||
<div className="device-sync-device-list">
|
||||
{sources.map(s => {
|
||||
const status = sourceStatuses.get(s.id) ?? 'pending';
|
||||
return (
|
||||
<label
|
||||
key={s.id}
|
||||
className={`device-sync-device-row ${status}${checkedIds.includes(s.id) ? ' checked' : ''}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checkedIds.includes(s.id)}
|
||||
onChange={() => toggleChecked(s.id)}
|
||||
disabled={status === 'deletion'}
|
||||
/>
|
||||
<span className="device-sync-row-name">
|
||||
{s.name}
|
||||
{s.artist && <span className="device-sync-row-artist"> · {s.artist}</span>}
|
||||
</span>
|
||||
<span className="device-sync-source-type">{s.type}</span>
|
||||
<span className={`device-sync-status-icon ${status}`}>
|
||||
{status === 'synced' && <CheckCircle2 size={13} />}
|
||||
{status === 'pending' && <Clock size={13} />}
|
||||
{status === 'deletion' && <Trash2 size={13} />}
|
||||
</span>
|
||||
<span className="device-sync-row-actions">
|
||||
{status === 'synced' && (
|
||||
<button
|
||||
className="device-sync-action-btn danger"
|
||||
onClick={e => { e.preventDefault(); markForDeletion([s.id]); }}
|
||||
data-tooltip={t('deviceSync.markForDeletion')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'pending' && (
|
||||
<button
|
||||
className="device-sync-action-btn muted"
|
||||
onClick={e => { e.preventDefault(); handleToggleSource(s); }}
|
||||
data-tooltip={t('deviceSync.removeSource')}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
{status === 'deletion' && (
|
||||
<button
|
||||
className="device-sync-action-btn undo"
|
||||
onClick={e => { e.preventDefault(); unmarkDeletion(s.id); }}
|
||||
data-tooltip={t('deviceSync.undoDeletion')}
|
||||
>
|
||||
<Undo2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Background sync progress (non-blocking) */}
|
||||
{jobStatus === 'running' && (
|
||||
<div className="device-sync-bg-progress">
|
||||
<div className="device-sync-bg-progress-bar-wrap">
|
||||
<div
|
||||
className="device-sync-bg-progress-bar"
|
||||
style={{ width: jobTotal > 0
|
||||
? `${((jobDone + jobSkip + jobFail) / jobTotal) * 100}%`
|
||||
: '0%' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="device-sync-bg-progress-text">
|
||||
<Loader2 size={12} className="spin" />
|
||||
{t('deviceSync.syncInProgress', { done: jobDone + jobSkip, total: jobTotal })}
|
||||
{jobFail > 0 && <span className="device-sync-stat-error"><AlertCircle size={11} /> {jobFail}</span>}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 12, padding: '2px 10px' }}
|
||||
onClick={() => {
|
||||
const jobId = useDeviceSyncJobStore.getState().jobId;
|
||||
if (jobId) invoke('cancel_device_sync', { jobId });
|
||||
useDeviceSyncJobStore.getState().cancel();
|
||||
}}
|
||||
>
|
||||
{t('deviceSync.cancelSync')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobStatus === 'cancelled' && (
|
||||
<div className="device-sync-bg-progress done">
|
||||
<span className="device-sync-bg-progress-text">
|
||||
<AlertCircle size={12} style={{ color: 'var(--text-muted)' }} />
|
||||
{t('deviceSync.syncCancelled', { done: jobDone, total: jobTotal })}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||
{t('deviceSync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{jobStatus === 'done' && (
|
||||
<div className="device-sync-bg-progress done">
|
||||
<span className="device-sync-bg-progress-text">
|
||||
<CheckCircle2 size={12} className="color-success" />
|
||||
{t('deviceSync.syncResult', { done: jobDone, skipped: jobSkip, total: jobTotal })}
|
||||
</span>
|
||||
<button className="btn btn-ghost" onClick={() => useDeviceSyncJobStore.getState().reset()}>
|
||||
{t('deviceSync.dismiss')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle, FolderOpen, HardDriveUpload, RefreshCw, Usb,
|
||||
} from 'lucide-react';
|
||||
import CustomSelect from '@/ui/CustomSelect';
|
||||
import type { RemovableDrive } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { formatBytes } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
|
||||
interface Props {
|
||||
targetDir: string | null;
|
||||
setTargetDir: (dir: string) => void;
|
||||
sources: DeviceSyncSource[];
|
||||
drives: RemovableDrive[];
|
||||
drivesLoading: boolean;
|
||||
activeDrive: RemovableDrive | null;
|
||||
refreshDrives: () => Promise<void>;
|
||||
scanDevice: () => Promise<void>;
|
||||
handleChooseFolder: () => Promise<void>;
|
||||
startMigrationPreview: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function DeviceSyncHeader({
|
||||
targetDir, setTargetDir, sources, drives, drivesLoading, activeDrive,
|
||||
refreshDrives, scanDevice, handleChooseFolder, startMigrationPreview,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="device-sync-header">
|
||||
<div className="device-sync-header-title">
|
||||
<HardDriveUpload size={20} />
|
||||
<h1>{t('deviceSync.title')}</h1>
|
||||
</div>
|
||||
|
||||
<div className="device-sync-config-row">
|
||||
|
||||
{/* ── Left: Fixed schema info ── */}
|
||||
<div className="device-sync-schema-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.schemaLabel', { defaultValue: 'Naming scheme' })}</span>
|
||||
<code className="device-sync-schema-code">
|
||||
{'{AlbumArtist}/{Album}/{TrackNum} - {Title}.{ext}'}
|
||||
</code>
|
||||
<span className="device-sync-schema-hint">
|
||||
{t('deviceSync.schemaHint', {
|
||||
defaultValue: 'Fixed scheme for reliable cross-OS sync. Playlists are written as .m3u8 that reference the album tracks — no duplicates on the device.',
|
||||
})}
|
||||
</span>
|
||||
{targetDir && sources.length > 0 && (
|
||||
<button
|
||||
className="btn btn-ghost device-sync-migrate-btn"
|
||||
onClick={startMigrationPreview}
|
||||
data-tooltip={t('deviceSync.migrateTooltip', {
|
||||
defaultValue: 'Rename existing files on the device into the new scheme (from the old filename template).',
|
||||
})}
|
||||
data-tooltip-pos="bottom"
|
||||
>
|
||||
{t('deviceSync.migrateButton', { defaultValue: 'Reorganize existing files…' })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Right: Drive config ── */}
|
||||
<div className="device-sync-target-section">
|
||||
<span className="device-sync-label-inline">{t('deviceSync.targetDevice')}</span>
|
||||
<div className="device-sync-header-config">
|
||||
<div className="device-sync-drive-layout">
|
||||
{/* Row 1: Controls */}
|
||||
<div className="device-sync-drive-controls">
|
||||
{/* Fallback manual folder picker & Refresh */}
|
||||
<button className="btn btn-ghost" onClick={handleChooseFolder} data-tooltip={t('deviceSync.browseManual')}>
|
||||
<FolderOpen size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-ghost device-sync-refresh-btn"
|
||||
onClick={refreshDrives}
|
||||
disabled={drivesLoading}
|
||||
data-tooltip={t('deviceSync.refreshDrives')}
|
||||
>
|
||||
<RefreshCw size={18} className={drivesLoading ? 'spin' : ''} />
|
||||
</button>
|
||||
|
||||
{/* Dropdown element */}
|
||||
{drives.length > 0 ? (
|
||||
<>
|
||||
<Usb size={18} className="device-sync-drive-icon" />
|
||||
<CustomSelect
|
||||
className="input device-sync-drive-select"
|
||||
value={targetDir ?? ''}
|
||||
onChange={v => {
|
||||
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 }))
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<span className="device-sync-no-drives">
|
||||
<AlertCircle size={18} />
|
||||
{t('deviceSync.noDrivesDetected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2: Metadata */}
|
||||
{activeDrive && (
|
||||
<div className="device-sync-drive-meta">
|
||||
{formatBytes(activeDrive.available_space)} {t('deviceSync.free')} / {formatBytes(activeDrive.total_space)} • {activeDrive.file_system}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import type {
|
||||
MigrationPair, MigrationPhase, MigrationResult,
|
||||
} from '@/features/deviceSync/utils/runDeviceSyncMigration';
|
||||
|
||||
interface Props {
|
||||
migrationPhase: MigrationPhase;
|
||||
migrationOldTemplate: string;
|
||||
migrationPairs: MigrationPair[];
|
||||
migrationCollisions: MigrationPair[];
|
||||
migrationUnchanged: number;
|
||||
migrationResult: MigrationResult | null;
|
||||
executeMigration: () => Promise<void>;
|
||||
closeMigration: () => void;
|
||||
}
|
||||
|
||||
export default function DeviceSyncMigrationModal({
|
||||
migrationPhase, migrationOldTemplate, migrationPairs, migrationCollisions,
|
||||
migrationUnchanged, migrationResult, executeMigration, closeMigration,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (migrationPhase === 'closed') return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={migrationPhase === 'executing' ? undefined : closeMigration}>
|
||||
<div className="modal-content device-sync-migrate-modal" onClick={e => e.stopPropagation()}>
|
||||
<h2 className="modal-title">{t('deviceSync.migrateTitle', { defaultValue: 'Reorganize existing files' })}</h2>
|
||||
<div className="device-sync-migrate-body">
|
||||
{migrationPhase === 'loading' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateLoading', { defaultValue: 'Analyzing existing files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'nothing' && (
|
||||
<div className="device-sync-migrate-nothing">
|
||||
{migrationOldTemplate ? (
|
||||
t('deviceSync.migrateNothingToDo', { defaultValue: 'All existing files already match the new scheme — nothing to do.' })
|
||||
) : (
|
||||
t('deviceSync.migrateNoTemplate', { defaultValue: 'No legacy filename template found on the device. Migration only applies when the stick was synced with a Psysonic version that supported custom templates.' })
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<div className="device-sync-migrate-summary">
|
||||
<div>
|
||||
<strong>{migrationPairs.length}</strong>{' '}
|
||||
{t('deviceSync.migrateFilesToRename', { defaultValue: 'files will be renamed' })}
|
||||
</div>
|
||||
{migrationUnchanged > 0 && (
|
||||
<div className="muted">
|
||||
{t('deviceSync.migrateUnchanged', {
|
||||
defaultValue: '{{n}} files are already at the correct path',
|
||||
n: migrationUnchanged,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationCollisions.length > 0 && (
|
||||
<div className="device-sync-migrate-warning">
|
||||
<AlertCircle size={14} />
|
||||
{t('deviceSync.migrateCollisions', {
|
||||
defaultValue: '{{n}} files cannot be renamed automatically (multiple tracks map to the same target). They will be left untouched — the next sync re-downloads them into the correct location.',
|
||||
n: migrationCollisions.length,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-preview-note">
|
||||
{t('deviceSync.migratePreviewNote', {
|
||||
defaultValue: 'Old template: {{tpl}}',
|
||||
tpl: migrationOldTemplate,
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{migrationPhase === 'executing' && (
|
||||
<div className="device-sync-migrate-loading">
|
||||
<Loader2 size={18} className="spin" />
|
||||
<span>{t('deviceSync.migrateExecuting', { defaultValue: 'Renaming files…' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{migrationPhase === 'done' && migrationResult && (
|
||||
<div className="device-sync-migrate-result">
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<CheckCircle2 size={14} className="positive" />
|
||||
{t('deviceSync.migrateSuccess', {
|
||||
defaultValue: '{{n}} files renamed successfully',
|
||||
n: migrationResult.ok,
|
||||
})}
|
||||
</div>
|
||||
{migrationResult.failed > 0 && (
|
||||
<div className="device-sync-migrate-result-line">
|
||||
<AlertCircle size={14} className="danger" />
|
||||
{t('deviceSync.migrateFailed', {
|
||||
defaultValue: '{{n}} renames failed',
|
||||
n: migrationResult.failed,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{migrationResult.errors.length > 0 && (
|
||||
<details className="device-sync-migrate-errors">
|
||||
<summary>{t('deviceSync.migrateShowErrors', { defaultValue: 'Show errors' })}</summary>
|
||||
<ul>
|
||||
{migrationResult.errors.slice(0, 50).map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
{migrationResult.errors.length > 50 && (
|
||||
<li>… {migrationResult.errors.length - 50} more</li>
|
||||
)}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="device-sync-migrate-footer">
|
||||
{migrationPhase === 'preview' && (
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={closeMigration}>{t('common.cancel')}</button>
|
||||
<button className="btn btn-primary" onClick={executeMigration} disabled={migrationPairs.length === 0}>
|
||||
{t('deviceSync.migrateStart', { defaultValue: 'Start renaming' })}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{(migrationPhase === 'done' || migrationPhase === 'nothing') && (
|
||||
<button className="btn btn-primary" onClick={closeMigration}>{t('common.close')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||
import type { SyncDelta } from '@/features/deviceSync/utils/runDeviceSyncExecution';
|
||||
import { formatMb } from '@/utils/format/formatBytes';
|
||||
|
||||
interface Props {
|
||||
preSyncOpen: boolean;
|
||||
preSyncLoading: boolean;
|
||||
syncDelta: SyncDelta;
|
||||
onCancel: () => void;
|
||||
onProceed: () => void;
|
||||
}
|
||||
|
||||
export default function DeviceSyncPreSyncModal({
|
||||
preSyncOpen, preSyncLoading, syncDelta, onCancel, onProceed,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!preSyncOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content device-sync-modal">
|
||||
<h2 className="modal-title">{t('deviceSync.syncSummary')}</h2>
|
||||
|
||||
{preSyncLoading ? (
|
||||
<div className="device-sync-loading-modal" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', margin: '20px' }}>
|
||||
<Loader2 size={32} className="spin" />
|
||||
<p style={{ marginTop: '10px' }}>{t('deviceSync.calculating')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="device-sync-summary-stats" style={{ display: 'flex', flexDirection: 'column', gap: '8px', margin: '10px 0' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<span>{t('deviceSync.filesToAdd')}</span>
|
||||
<span className="color-success">+{syncDelta.addCount} ({formatMb(syncDelta.addBytes)})</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '4px 0' }}>
|
||||
<span>{t('deviceSync.filesToDelete')}</span>
|
||||
<span className="color-error">-{syncDelta.delCount} ({formatMb(syncDelta.delBytes)})</span>
|
||||
</div>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid var(--border)', margin: '10px 0' }} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold' }}>
|
||||
<span>{t('deviceSync.netChange')}</span>
|
||||
<span>{formatMb(syncDelta.addBytes - syncDelta.delBytes)}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 'bold', color: syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes ? 'var(--danger)' : 'inherit', marginTop: '10px' }}>
|
||||
<span>{t('deviceSync.availableSpace')}</span>
|
||||
<span>{formatMb(syncDelta.availableBytes)}</span>
|
||||
</div>
|
||||
{syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes && (
|
||||
<div className="sync-warning error" style={{ background: 'color-mix(in srgb, var(--danger) 15%, transparent)', padding: '10px', borderRadius: 'var(--radius-md)', marginTop: '15px', display: 'flex', gap: '10px', color: 'var(--danger)', alignItems: 'flex-start' }}>
|
||||
<AlertCircle size={16} style={{ flexShrink: 0, marginTop: '2px' }} />
|
||||
<span>{t('deviceSync.spaceWarning')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!preSyncLoading && (
|
||||
<div className="modal-actions" style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '25px' }}>
|
||||
<button className="btn btn-ghost" onClick={onCancel}>
|
||||
{t('deviceSync.cancel')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={onProceed}
|
||||
disabled={syncDelta.addBytes > syncDelta.availableBytes + syncDelta.delBytes}
|
||||
>
|
||||
{t('deviceSync.proceed')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { getPlaylists } from '@/api/subsonicPlaylists';
|
||||
import { getArtists, getArtist } from '@/api/subsonicArtists';
|
||||
import { getAlbumList } from '@/api/subsonicLibrary';
|
||||
import { search as searchSubsonic } from '@/api/subsonicSearch';
|
||||
import type {
|
||||
SubsonicAlbum, SubsonicArtist, SubsonicPlaylist,
|
||||
} from '@/api/subsonicTypes';
|
||||
import type { SourceTab } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
|
||||
export interface DeviceSyncBrowserResult {
|
||||
playlists: SubsonicPlaylist[];
|
||||
randomAlbums: SubsonicAlbum[];
|
||||
albumSearchResults: SubsonicAlbum[];
|
||||
albumSearchLoading: boolean;
|
||||
artists: SubsonicArtist[];
|
||||
loadingBrowser: boolean;
|
||||
expandedArtistIds: Set<string>;
|
||||
artistAlbumsMap: Map<string, SubsonicAlbum[]>;
|
||||
loadingArtistIds: Set<string>;
|
||||
toggleArtistExpand: (artistId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDeviceSyncBrowser(
|
||||
activeTab: SourceTab,
|
||||
search: string,
|
||||
resetSearch: () => void,
|
||||
): DeviceSyncBrowserResult {
|
||||
const [playlists, setPlaylists] = useState<SubsonicPlaylist[]>([]);
|
||||
const [randomAlbums, setRandomAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [albumSearchResults, setAlbumSearchResults] = useState<SubsonicAlbum[]>([]);
|
||||
const [albumSearchLoading, setAlbumSearchLoading] = useState(false);
|
||||
const [artists, setArtists] = useState<SubsonicArtist[]>([]);
|
||||
const [loadingBrowser, setLoadingBrowser] = 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 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); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
resetSearch();
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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();
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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 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]);
|
||||
|
||||
return {
|
||||
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
|
||||
artists, loadingBrowser,
|
||||
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
|
||||
toggleArtistExpand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
export interface DeviceSyncDeviceScanResult {
|
||||
scanDevice: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDeviceSyncDeviceScan(
|
||||
targetDir: string | null,
|
||||
sourcesLength: number,
|
||||
driveDetected: boolean,
|
||||
t: TFunction,
|
||||
): DeviceSyncDeviceScanResult {
|
||||
const setDeviceFilePaths = useDeviceSyncStore.getState().setDeviceFilePaths;
|
||||
const setScanning = useDeviceSyncStore.getState().setScanning;
|
||||
|
||||
const scanDevice = useCallback(async () => {
|
||||
if (!targetDir || sourcesLength === 0) {
|
||||
setDeviceFilePaths([]);
|
||||
return;
|
||||
}
|
||||
setScanning(true);
|
||||
try {
|
||||
const files = await invoke<string[]>('list_device_dir_files', { dir: targetDir });
|
||||
setDeviceFilePaths(files);
|
||||
} catch {
|
||||
setDeviceFilePaths([]);
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, [targetDir, sourcesLength, setDeviceFilePaths, setScanning]);
|
||||
|
||||
// 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, setDeviceFilePaths]);
|
||||
|
||||
return { scanDevice };
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { RemovableDrive } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
|
||||
export interface DeviceSyncDrivesResult {
|
||||
drives: RemovableDrive[];
|
||||
drivesLoading: boolean;
|
||||
activeDrive: RemovableDrive | null;
|
||||
driveDetected: boolean;
|
||||
refreshDrives: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function useDeviceSyncDrives(targetDir: string | null): DeviceSyncDrivesResult {
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
const [drivesLoading, setDrivesLoading] = useState(false);
|
||||
|
||||
const refreshDrives = useCallback(async () => {
|
||||
setDrivesLoading(true);
|
||||
try {
|
||||
const result = await invoke<RemovableDrive[]>('get_removable_drives');
|
||||
setDrives(result);
|
||||
} catch {
|
||||
setDrives([]);
|
||||
} finally {
|
||||
setDrivesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch drives on mount, then poll every 5 seconds
|
||||
useEffect(() => {
|
||||
// React Compiler set-state-in-effect rule: state set from a timer/animation callback.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
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;
|
||||
|
||||
return { drives, drivesLoading, activeDrive, driveDetected, refreshDrives };
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
|
||||
import { useDeviceSyncStore } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { trackToSyncInfo } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
|
||||
export function useDeviceSyncJobEvents(
|
||||
t: TFunction,
|
||||
scanDevice: () => Promise<void>,
|
||||
): void {
|
||||
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(() => {});
|
||||
// For every playlist source, write an Extended-M3U next to the
|
||||
// playlist-folder tracks. Context carries the playlist name +
|
||||
// per-track index so the filenames match the files we just synced.
|
||||
const playlistSources = srcs.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: dir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* m3u8 failure is non-fatal — skip silently */ }
|
||||
});
|
||||
}
|
||||
}
|
||||
// Re-scan the device after sync completes (cancelled or not)
|
||||
scanDevice();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unlistenProgress.then(f => f());
|
||||
unlistenComplete.then(f => f());
|
||||
};
|
||||
}, [t, scanDevice]);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { trackToSyncInfo, type SyncStatus } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
|
||||
export interface DeviceSyncSourceStatusesResult {
|
||||
sourcePathsMap: Map<string, string[]>;
|
||||
sourceStatuses: Map<string, SyncStatus>;
|
||||
}
|
||||
|
||||
export function useDeviceSyncSourceStatuses(
|
||||
targetDir: string | null,
|
||||
sources: DeviceSyncSource[],
|
||||
pendingDeletion: string[],
|
||||
deviceFilePaths: string[],
|
||||
): DeviceSyncSourceStatusesResult {
|
||||
// Map source IDs → computed device paths (for status derivation)
|
||||
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// Compute expected paths for each source (for status comparison)
|
||||
useEffect(() => {
|
||||
if (!targetDir || sources.length === 0) {
|
||||
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setSourcePathsMap(new Map());
|
||||
return;
|
||||
}
|
||||
// Path schema is fixed in the Rust backend now — no template parameter.
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
await Promise.all(sources.map(async source => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
});
|
||||
map.set(source.id, paths);
|
||||
} catch {
|
||||
map.set(source.id, []);
|
||||
}
|
||||
}));
|
||||
if (!cancelled) setSourcePathsMap(map);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [targetDir, sources]);
|
||||
|
||||
// Derive sync status per source
|
||||
const sourceStatuses = useMemo(() => {
|
||||
const deviceSet = new Set(deviceFilePaths);
|
||||
const statuses = new Map<string, SyncStatus>();
|
||||
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]);
|
||||
|
||||
return { sourcePathsMap, sourceStatuses };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Device Sync feature — copies/transcodes library tracks onto external devices
|
||||
* (USB drives, players). The `DeviceSync` page is lazy-loaded by the router via
|
||||
* its deep path, so it is intentionally not re-exported here.
|
||||
*
|
||||
* Note: `audioListenerSetup/eqDeviceSync` is NOT part of this feature — it is the
|
||||
* audio-core EQ-per-output-device profile sync, kept in the audio core.
|
||||
*/
|
||||
export { useDeviceSyncJobStore } from './store/deviceSyncJobStore';
|
||||
export type { DeviceSyncSource } from './store/deviceSyncStore';
|
||||
@@ -0,0 +1,275 @@
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDeviceSyncStore, DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
|
||||
|
||||
import {
|
||||
type SourceTab,
|
||||
} from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { useDeviceSyncDrives } from '@/features/deviceSync/hooks/useDeviceSyncDrives';
|
||||
import { useDeviceSyncSourceStatuses } from '@/features/deviceSync/hooks/useDeviceSyncSourceStatuses';
|
||||
import { useDeviceSyncBrowser } from '@/features/deviceSync/hooks/useDeviceSyncBrowser';
|
||||
import { useDeviceSyncDeviceScan } from '@/features/deviceSync/hooks/useDeviceSyncDeviceScan';
|
||||
import { useDeviceSyncJobEvents } from '@/features/deviceSync/hooks/useDeviceSyncJobEvents';
|
||||
import {
|
||||
runDeviceSyncMigrationPreview,
|
||||
runDeviceSyncMigrationExecute,
|
||||
type MigrationPhase, type MigrationPair, type MigrationResult,
|
||||
} from '@/features/deviceSync/utils/runDeviceSyncMigration';
|
||||
import {
|
||||
runDeviceSyncSummaryPrompt,
|
||||
runDeviceSyncExecute,
|
||||
type SyncDelta,
|
||||
} from '@/features/deviceSync/utils/runDeviceSyncExecution';
|
||||
import { runDeviceSyncChooseFolder } from '@/features/deviceSync/utils/runDeviceSyncChooseFolder';
|
||||
import DeviceSyncHeader from '@/features/deviceSync/components/DeviceSyncHeader';
|
||||
import DeviceSyncPreSyncModal from '@/features/deviceSync/components/DeviceSyncPreSyncModal';
|
||||
import DeviceSyncMigrationModal from '@/features/deviceSync/components/DeviceSyncMigrationModal';
|
||||
import DeviceSyncBrowserPanel from '@/features/deviceSync/components/DeviceSyncBrowserPanel';
|
||||
import DeviceSyncDevicePanel from '@/features/deviceSync/components/DeviceSyncDevicePanel';
|
||||
|
||||
// ─── component ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DeviceSync() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const targetDir = useDeviceSyncStore(s => s.targetDir);
|
||||
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, addSource, removeSource,
|
||||
toggleChecked, setCheckedIds, markForDeletion,
|
||||
unmarkDeletion, removeSources,
|
||||
} = 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<SourceTab>('albums');
|
||||
const [search, setSearch] = useState('');
|
||||
// ─── Removable drive detection ──────────────────────────────────────────
|
||||
const { drives, drivesLoading, activeDrive, driveDetected, refreshDrives } =
|
||||
useDeviceSyncDrives(targetDir);
|
||||
|
||||
const [preSyncOpen, setPreSyncOpen] = useState(false);
|
||||
const [preSyncLoading, setPreSyncLoading] = useState(false);
|
||||
const [syncDelta, setSyncDelta] = useState<SyncDelta>({ addBytes: 0, addCount: 0, delBytes: 0, delCount: 0, availableBytes: 0, tracks: [] as SubsonicSong[] });
|
||||
|
||||
// ─── Migration (rename existing files into the fixed scheme) ────────────
|
||||
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
|
||||
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
|
||||
const [migrationPairs, setMigrationPairs] = useState<MigrationPair[]>([]);
|
||||
const [migrationCollisions, setMigrationCollisions] = useState<MigrationPair[]>([]);
|
||||
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
|
||||
const [migrationResult, setMigrationResult] = useState<MigrationResult | null>(null);
|
||||
|
||||
const isRunning = jobStatus === 'running';
|
||||
|
||||
// ─── Device scan + manifest auto-import ─────────────────────────────────
|
||||
const { scanDevice } = useDeviceSyncDeviceScan(targetDir, sources.length, driveDetected, t);
|
||||
|
||||
// Source status (path map + derived synced/pending/deletion)
|
||||
const { sourcePathsMap, sourceStatuses } = useDeviceSyncSourceStatuses(
|
||||
targetDir, sources, pendingDeletion, 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 ──────────────────────────────────
|
||||
useDeviceSyncJobEvents(t, scanDevice);
|
||||
|
||||
// Browser (playlists / albums / artists tabs + their loaders + debounced search)
|
||||
const {
|
||||
playlists, randomAlbums, albumSearchResults, albumSearchLoading,
|
||||
artists, loadingBrowser,
|
||||
expandedArtistIds, artistAlbumsMap, loadingArtistIds,
|
||||
toggleArtistExpand,
|
||||
} = useDeviceSyncBrowser(activeTab, search, () => setSearch(''));
|
||||
|
||||
// ─── Migration handlers ─────────────────────────────────────────────────
|
||||
|
||||
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
|
||||
targetDir, sources,
|
||||
setMigrationPhase, setMigrationResult, setMigrationOldTemplate,
|
||||
setMigrationPairs, setMigrationCollisions, setMigrationUnchanged,
|
||||
});
|
||||
|
||||
const executeMigration = () => runDeviceSyncMigrationExecute({
|
||||
targetDir, sources, migrationPairs,
|
||||
setMigrationPhase, setMigrationResult, scanDevice,
|
||||
});
|
||||
|
||||
const closeMigration = () => {
|
||||
setMigrationPhase('closed');
|
||||
setMigrationPairs([]);
|
||||
setMigrationCollisions([]);
|
||||
setMigrationResult(null);
|
||||
setMigrationOldTemplate('');
|
||||
};
|
||||
|
||||
const handleChooseFolder = () => runDeviceSyncChooseFolder({ t, setTargetDir, scanDevice });
|
||||
|
||||
// ─── Sync (non-blocking) ────────────────────────────────────────────────
|
||||
|
||||
const promptSyncSummary = () => runDeviceSyncSummaryPrompt({
|
||||
targetDir, sources, pendingDeletion, t,
|
||||
setPreSyncLoading, setPreSyncOpen, setSyncDelta,
|
||||
});
|
||||
|
||||
const handleSyncExecution = () => runDeviceSyncExecute({
|
||||
targetDir, sources, pendingDeletion, syncDelta, t,
|
||||
setPreSyncOpen, removeSources, scanDevice,
|
||||
});
|
||||
|
||||
// ─── 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);
|
||||
|
||||
return (
|
||||
<div className="device-sync-page">
|
||||
|
||||
<DeviceSyncHeader
|
||||
targetDir={targetDir}
|
||||
setTargetDir={setTargetDir}
|
||||
sources={sources}
|
||||
drives={drives}
|
||||
drivesLoading={drivesLoading}
|
||||
activeDrive={activeDrive}
|
||||
refreshDrives={refreshDrives}
|
||||
scanDevice={scanDevice}
|
||||
handleChooseFolder={handleChooseFolder}
|
||||
startMigrationPreview={startMigrationPreview}
|
||||
/>
|
||||
|
||||
{/* ── Main ── */}
|
||||
<div className="device-sync-main">
|
||||
|
||||
<DeviceSyncBrowserPanel
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
search={search}
|
||||
setSearch={setSearch}
|
||||
playlists={playlists}
|
||||
randomAlbums={randomAlbums}
|
||||
albumSearchResults={albumSearchResults}
|
||||
albumSearchLoading={albumSearchLoading}
|
||||
artists={artists}
|
||||
loadingBrowser={loadingBrowser}
|
||||
expandedArtistIds={expandedArtistIds}
|
||||
artistAlbumsMap={artistAlbumsMap}
|
||||
loadingArtistIds={loadingArtistIds}
|
||||
toggleArtistExpand={toggleArtistExpand}
|
||||
sources={sources}
|
||||
pendingDeletion={pendingDeletion}
|
||||
handleToggleSource={handleToggleSource}
|
||||
/>
|
||||
|
||||
<DeviceSyncDevicePanel
|
||||
sources={sources}
|
||||
sourceStatuses={sourceStatuses}
|
||||
driveDetected={driveDetected}
|
||||
scanning={scanning}
|
||||
checkedIds={checkedIds}
|
||||
toggleChecked={toggleChecked}
|
||||
allChecked={allChecked}
|
||||
toggleAll={toggleAll}
|
||||
syncedCount={syncedCount}
|
||||
pendingCount={pendingCount}
|
||||
deletionCount={deletionCount}
|
||||
isRunning={isRunning}
|
||||
actionButtonLabel={actionButtonLabel}
|
||||
actionButtonDisabled={actionButtonDisabled}
|
||||
promptSyncSummary={promptSyncSummary}
|
||||
handleMarkCheckedForDeletion={handleMarkCheckedForDeletion}
|
||||
handleToggleSource={handleToggleSource}
|
||||
markForDeletion={markForDeletion}
|
||||
unmarkDeletion={unmarkDeletion}
|
||||
jobStatus={jobStatus}
|
||||
jobDone={jobDone}
|
||||
jobSkip={jobSkip}
|
||||
jobFail={jobFail}
|
||||
jobTotal={jobTotal}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<DeviceSyncPreSyncModal
|
||||
preSyncOpen={preSyncOpen}
|
||||
preSyncLoading={preSyncLoading}
|
||||
syncDelta={syncDelta}
|
||||
onCancel={() => setPreSyncOpen(false)}
|
||||
onProceed={handleSyncExecution}
|
||||
/>
|
||||
|
||||
<DeviceSyncMigrationModal
|
||||
migrationPhase={migrationPhase}
|
||||
migrationOldTemplate={migrationOldTemplate}
|
||||
migrationPairs={migrationPairs}
|
||||
migrationCollisions={migrationCollisions}
|
||||
migrationUnchanged={migrationUnchanged}
|
||||
migrationResult={migrationResult}
|
||||
executeMigration={executeMigration}
|
||||
closeMigration={closeMigration}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface DeviceSyncJobState {
|
||||
jobId: string | null;
|
||||
total: number;
|
||||
done: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
status: 'idle' | 'running' | 'done' | 'cancelled';
|
||||
|
||||
startSync: (jobId: string, total: number) => void;
|
||||
updateProgress: (done: number, skipped: number, failed: number) => void;
|
||||
complete: (done: number, skipped: number, failed: number) => void;
|
||||
cancel: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncJobStore = create<DeviceSyncJobState>()((set) => ({
|
||||
jobId: null,
|
||||
total: 0,
|
||||
done: 0,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
status: 'idle',
|
||||
|
||||
startSync: (jobId, total) =>
|
||||
set({ jobId, total, done: 0, skipped: 0, failed: 0, status: 'running' }),
|
||||
|
||||
updateProgress: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed }),
|
||||
|
||||
complete: (done, skipped, failed) =>
|
||||
set({ done, skipped, failed, status: 'done' }),
|
||||
|
||||
cancel: () =>
|
||||
set({ status: 'cancelled' }),
|
||||
|
||||
reset: () =>
|
||||
set({ jobId: null, total: 0, done: 0, skipped: 0, failed: 0, status: 'idle' }),
|
||||
}));
|
||||
@@ -0,0 +1,102 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export interface DeviceSyncSource {
|
||||
type: 'album' | 'playlist' | 'artist';
|
||||
id: string;
|
||||
name: string;
|
||||
/** Album artist — only set when type === 'album'. Shown as a subtitle in the device list. */
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
interface DeviceSyncState {
|
||||
targetDir: string | null;
|
||||
sources: DeviceSyncSource[]; // persistent device content list
|
||||
checkedIds: string[]; // currently checked for bulk actions (not persisted)
|
||||
pendingDeletion: string[]; // source IDs marked for deletion (not persisted)
|
||||
deviceFilePaths: string[]; // actual file paths found on the device (not persisted)
|
||||
scanning: boolean; // true while scanning the device
|
||||
|
||||
setTargetDir: (dir: string | null) => void;
|
||||
addSource: (source: DeviceSyncSource) => void;
|
||||
removeSource: (id: string) => void;
|
||||
clearSources: () => void;
|
||||
toggleChecked: (id: string) => void;
|
||||
setCheckedIds: (ids: string[]) => void;
|
||||
markForDeletion: (ids: string[]) => void;
|
||||
unmarkDeletion: (id: string) => void;
|
||||
clearPendingDeletion: () => void;
|
||||
removeSources: (ids: string[]) => void;
|
||||
setDeviceFilePaths: (paths: string[]) => void;
|
||||
setScanning: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const useDeviceSyncStore = create<DeviceSyncState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
targetDir: null,
|
||||
sources: [],
|
||||
checkedIds: [],
|
||||
pendingDeletion: [],
|
||||
deviceFilePaths: [],
|
||||
scanning: false,
|
||||
|
||||
setTargetDir: (dir) => set({ targetDir: dir }),
|
||||
|
||||
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),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearSources: () => set({ sources: [], checkedIds: [], pendingDeletion: [] }),
|
||||
|
||||
toggleChecked: (id) =>
|
||||
set((s) => ({
|
||||
checkedIds: s.checkedIds.includes(id)
|
||||
? s.checkedIds.filter((x) => x !== id)
|
||||
: [...s.checkedIds, id],
|
||||
})),
|
||||
|
||||
setCheckedIds: (ids) => set({ checkedIds: ids }),
|
||||
|
||||
markForDeletion: (ids) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: [...new Set([...s.pendingDeletion, ...ids])],
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
unmarkDeletion: (id) =>
|
||||
set((s) => ({
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => x !== id),
|
||||
})),
|
||||
|
||||
clearPendingDeletion: () => set({ pendingDeletion: [] }),
|
||||
|
||||
removeSources: (ids) =>
|
||||
set((s) => ({
|
||||
sources: s.sources.filter((x) => !ids.includes(x.id)),
|
||||
checkedIds: s.checkedIds.filter((x) => !ids.includes(x)),
|
||||
pendingDeletion: s.pendingDeletion.filter((x) => !ids.includes(x)),
|
||||
})),
|
||||
|
||||
setDeviceFilePaths: (paths) => set({ deviceFilePaths: paths }),
|
||||
setScanning: (v) => set({ scanning: v }),
|
||||
}),
|
||||
{
|
||||
name: 'psysonic_device_sync',
|
||||
partialize: (s) => ({
|
||||
targetDir: s.targetDir,
|
||||
sources: s.sources,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
|
||||
export type SourceTab = 'playlists' | 'albums' | 'artists';
|
||||
|
||||
export function uuid(): string { return crypto.randomUUID(); }
|
||||
|
||||
export type SyncStatus = 'synced' | 'pending' | 'deletion';
|
||||
|
||||
export interface RemovableDrive {
|
||||
name: string;
|
||||
mount_point: string;
|
||||
available_space: number;
|
||||
total_space: number;
|
||||
file_system: string;
|
||||
is_removable: boolean;
|
||||
}
|
||||
|
||||
export 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]}`;
|
||||
}
|
||||
|
||||
/** Tracks that came from `calculate_sync_payload` may carry embedded playlist
|
||||
* context so the follow-up `sync_batch_to_device` call knows to place them
|
||||
* under `Playlists/{Name}/` instead of the album tree. */
|
||||
export type SyncTrackMaybePlaylist = SubsonicSong & { _playlistName?: string; _playlistIndex?: number };
|
||||
|
||||
export function trackToSyncInfo(
|
||||
track: SyncTrackMaybePlaylist,
|
||||
url: string,
|
||||
playlistCtx?: { name: string; index: number },
|
||||
) {
|
||||
// Fall back to track artist when the file has no albumArtist tag — not every
|
||||
// library is tagged with it. Treat empty strings as missing (some Subsonic
|
||||
// servers return "" rather than omitting the field).
|
||||
const albumArtist = (track.albumArtist?.trim() || track.artist?.trim() || '');
|
||||
return {
|
||||
id: track.id, url,
|
||||
suffix: track.suffix ?? 'mp3',
|
||||
artist: track.artist ?? '',
|
||||
albumArtist,
|
||||
album: track.album ?? '',
|
||||
title: track.title ?? '',
|
||||
trackNumber: track.track,
|
||||
duration: track.duration,
|
||||
playlistName: playlistCtx?.name ?? track._playlistName,
|
||||
playlistIndex: playlistCtx?.index ?? track._playlistIndex,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { IS_WINDOWS } from '@/utils/platform';
|
||||
|
||||
// Same sanitize rules the Rust side uses (`sanitize_path_component`): strip
|
||||
// Windows-illegal chars and control chars, trim leading/trailing dots + spaces.
|
||||
// Kept in JS only for the migration flow — computes the *old* path under a
|
||||
// user-supplied template so we can diff against the current files on disk.
|
||||
export function sanitizeComponent(s: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return s.replace(/[/\\:*?"<>|\x00-\x1f\x7f]/g, '_').replace(/^[. ]+|[. ]+$/g, '');
|
||||
}
|
||||
|
||||
export interface OldTemplateTrack {
|
||||
artist: string;
|
||||
album: string;
|
||||
title: string;
|
||||
trackNumber?: number;
|
||||
discNumber?: number;
|
||||
year?: number;
|
||||
suffix: string;
|
||||
}
|
||||
|
||||
/** Renders a track's path under a legacy (user-configurable) template. Used only
|
||||
* for the migration preview — the live sync flow goes through Rust's fixed
|
||||
* `build_track_path`. */
|
||||
export function applyLegacyTemplate(template: string, track: OldTemplateTrack): string {
|
||||
const relative = template
|
||||
.replace(/\{artist\}/g, sanitizeComponent(track.artist))
|
||||
.replace(/\{album\}/g, sanitizeComponent(track.album))
|
||||
.replace(/\{title\}/g, sanitizeComponent(track.title))
|
||||
.replace(/\{track_number\}/g, track.trackNumber != null ? String(track.trackNumber).padStart(2, '0') : '')
|
||||
.replace(/\{disc_number\}/g, track.discNumber != null ? String(track.discNumber) : '')
|
||||
.replace(/\{year\}/g, track.year != null ? String(track.year) : '');
|
||||
const withExt = `${relative}.${track.suffix}`;
|
||||
return IS_WINDOWS ? withExt.replace(/\//g, '\\') : withExt;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
|
||||
export interface RunDeviceSyncChooseFolderDeps {
|
||||
t: TFunction;
|
||||
setTargetDir: (dir: string) => void;
|
||||
scanDevice: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runDeviceSyncChooseFolder(deps: RunDeviceSyncChooseFolderDeps): Promise<void> {
|
||||
const { t, setTargetDir, scanDevice } = deps;
|
||||
const sel = await openDialog({ directory: true, multiple: false, title: t('deviceSync.chooseFolder') });
|
||||
if (!sel) return;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildDownloadUrl } from '@/api/subsonicStreamUrl';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { useDeviceSyncStore, type DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import { useDeviceSyncJobStore } from '@/features/deviceSync/store/deviceSyncJobStore';
|
||||
import { showToast } from '@/utils/ui/toast';
|
||||
import { trackToSyncInfo, uuid } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
|
||||
export interface SyncDelta {
|
||||
addBytes: number;
|
||||
addCount: number;
|
||||
delBytes: number;
|
||||
delCount: number;
|
||||
availableBytes: number;
|
||||
tracks: SubsonicSong[];
|
||||
}
|
||||
|
||||
export interface RunDeviceSyncSummaryDeps {
|
||||
targetDir: string | null;
|
||||
sources: DeviceSyncSource[];
|
||||
pendingDeletion: string[];
|
||||
t: TFunction;
|
||||
setPreSyncLoading: (v: boolean) => void;
|
||||
setPreSyncOpen: (v: boolean) => void;
|
||||
setSyncDelta: (v: SyncDelta) => void;
|
||||
}
|
||||
|
||||
export async function runDeviceSyncSummaryPrompt(deps: RunDeviceSyncSummaryDeps): Promise<void> {
|
||||
const { targetDir, sources, pendingDeletion, t, setPreSyncLoading, setPreSyncOpen, setSyncDelta } = deps;
|
||||
|
||||
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/subsonicClient');
|
||||
const { baseUrl, params } = getClient();
|
||||
const payload = await invoke<SyncDelta>('calculate_sync_payload', {
|
||||
sources,
|
||||
deletionIds: pendingDeletion,
|
||||
auth: { baseUrl, ...params },
|
||||
targetDir,
|
||||
});
|
||||
|
||||
setSyncDelta(payload);
|
||||
} catch {
|
||||
showToast(t('deviceSync.fetchError'), 3000, 'error');
|
||||
setPreSyncOpen(false);
|
||||
} finally {
|
||||
setPreSyncLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunDeviceSyncExecuteDeps {
|
||||
targetDir: string | null;
|
||||
sources: DeviceSyncSource[];
|
||||
pendingDeletion: string[];
|
||||
syncDelta: SyncDelta;
|
||||
t: TFunction;
|
||||
setPreSyncOpen: (v: boolean) => void;
|
||||
removeSources: (ids: string[]) => void;
|
||||
scanDevice: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runDeviceSyncExecute(deps: RunDeviceSyncExecuteDeps): Promise<void> {
|
||||
const { targetDir, sources, pendingDeletion, syncDelta, t, setPreSyncOpen, removeSources, scanDevice } = deps;
|
||||
|
||||
setPreSyncOpen(false);
|
||||
|
||||
// 1. Handle pending deletions first
|
||||
const deletionSources = sources.filter(s => pendingDeletion.includes(s.id));
|
||||
if (deletionSources.length > 0) {
|
||||
try {
|
||||
const allPaths: string[] = [];
|
||||
// Compute paths per source so playlist sources delete from their own
|
||||
// folder (Playlists/{Name}/…) rather than from the album tree.
|
||||
for (const source of deletionSources) {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
const paths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(
|
||||
tr, '',
|
||||
source.type === 'playlist' ? { name: source.name, index: idx + 1 } : undefined,
|
||||
)),
|
||||
destDir: targetDir,
|
||||
});
|
||||
allPaths.push(...paths);
|
||||
}
|
||||
|
||||
await invoke<number>('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) {
|
||||
// No new downloads needed, but the user may still have added a
|
||||
// playlist source — (re)write its .m3u8 against the existing files.
|
||||
if (targetDir) {
|
||||
const playlistSources = sources.filter(s => s.type === 'playlist');
|
||||
playlistSources.forEach(async playlist => {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(playlist);
|
||||
await invoke('write_playlist_m3u8', {
|
||||
destDir: targetDir,
|
||||
playlistName: playlist.name,
|
||||
tracks: tracks.map((tr, idx) => trackToSyncInfo(tr, '', { name: playlist.name, index: idx + 1 })),
|
||||
});
|
||||
} catch { /* non-fatal */ }
|
||||
});
|
||||
}
|
||||
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,
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type React from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { DeviceSyncSource } from '@/features/deviceSync/store/deviceSyncStore';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import { applyLegacyTemplate } from '@/features/deviceSync/utils/deviceSyncLegacyTemplate';
|
||||
import { trackToSyncInfo } from '@/features/deviceSync/utils/deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from '@/utils/playback/fetchTracksForSource';
|
||||
import { IS_WINDOWS } from '@/utils/platform';
|
||||
|
||||
export type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
|
||||
|
||||
export interface MigrationPair { old: string; new: string; }
|
||||
export interface MigrationResult { ok: number; failed: number; errors: string[]; }
|
||||
|
||||
export interface RunMigrationPreviewDeps {
|
||||
targetDir: string | null;
|
||||
sources: DeviceSyncSource[];
|
||||
setMigrationPhase: React.Dispatch<React.SetStateAction<MigrationPhase>>;
|
||||
setMigrationResult: React.Dispatch<React.SetStateAction<MigrationResult | null>>;
|
||||
setMigrationOldTemplate: React.Dispatch<React.SetStateAction<string>>;
|
||||
setMigrationPairs: React.Dispatch<React.SetStateAction<MigrationPair[]>>;
|
||||
setMigrationCollisions: React.Dispatch<React.SetStateAction<MigrationPair[]>>;
|
||||
setMigrationUnchanged: React.Dispatch<React.SetStateAction<number>>;
|
||||
}
|
||||
|
||||
export async function runDeviceSyncMigrationPreview(deps: RunMigrationPreviewDeps): Promise<void> {
|
||||
const {
|
||||
targetDir, sources, setMigrationPhase, setMigrationResult, setMigrationOldTemplate,
|
||||
setMigrationPairs, setMigrationCollisions, setMigrationUnchanged,
|
||||
} = deps;
|
||||
|
||||
if (!targetDir || sources.length === 0) return;
|
||||
setMigrationPhase('loading');
|
||||
setMigrationResult(null);
|
||||
try {
|
||||
// Look up the old template from the v1 manifest on disk.
|
||||
const manifest = await invoke<{ version: number; filenameTemplate?: string } | null>(
|
||||
'read_device_manifest', { destDir: targetDir }
|
||||
);
|
||||
const oldTemplate = manifest?.filenameTemplate?.trim() || '';
|
||||
if (!oldTemplate) {
|
||||
// v2 manifest or missing — nothing to migrate from.
|
||||
setMigrationPhase('nothing');
|
||||
return;
|
||||
}
|
||||
setMigrationOldTemplate(oldTemplate);
|
||||
|
||||
// Migration only renames tracks that came from album/artist sources —
|
||||
// under the old template all tracks lived in a flat album tree. Playlist
|
||||
// sources get their own `Playlists/{name}/…` folder under the new scheme,
|
||||
// so the files they need are a subset (or copies) of the album tracks and
|
||||
// are cleaner to just re-download on the next sync.
|
||||
const albumSourceTracks: SubsonicSong[] = [];
|
||||
const seenIds = new Set<string>();
|
||||
for (const source of sources.filter(s => s.type !== 'playlist')) {
|
||||
try {
|
||||
const tracks = await fetchTracksForSource(source);
|
||||
for (const tr of tracks) {
|
||||
if (seenIds.has(tr.id)) continue;
|
||||
seenIds.add(tr.id);
|
||||
albumSourceTracks.push(tr);
|
||||
}
|
||||
} catch { /* skip unreachable source */ }
|
||||
}
|
||||
|
||||
// New paths via Rust (fixed album-tree schema).
|
||||
const newAbsPaths = await invoke<string[]>('compute_sync_paths', {
|
||||
tracks: albumSourceTracks.map(tr => trackToSyncInfo(tr, '')),
|
||||
destDir: targetDir,
|
||||
});
|
||||
const sepChar = IS_WINDOWS ? '\\' : '/';
|
||||
const prefix = targetDir.endsWith(sepChar) ? targetDir : targetDir + sepChar;
|
||||
const newRelPaths = newAbsPaths.map(p => p.startsWith(prefix) ? p.slice(prefix.length) : p);
|
||||
|
||||
// Old paths via the legacy template (JS).
|
||||
const oldRelPaths = albumSourceTracks.map(tr => applyLegacyTemplate(oldTemplate, {
|
||||
artist: tr.artist ?? '',
|
||||
album: tr.album ?? '',
|
||||
title: tr.title ?? '',
|
||||
trackNumber: tr.track,
|
||||
discNumber: tr.discNumber,
|
||||
year: tr.year,
|
||||
suffix: tr.suffix ?? 'mp3',
|
||||
}));
|
||||
|
||||
const pairs: MigrationPair[] = [];
|
||||
const collisions: MigrationPair[] = [];
|
||||
const newPathCounts = new Map<string, number>();
|
||||
let unchanged = 0;
|
||||
|
||||
for (let i = 0; i < albumSourceTracks.length; i++) {
|
||||
const o = oldRelPaths[i];
|
||||
const n = newRelPaths[i];
|
||||
if (o === n) { unchanged += 1; continue; }
|
||||
newPathCounts.set(n, (newPathCounts.get(n) ?? 0) + 1);
|
||||
pairs.push({ old: o, new: n });
|
||||
}
|
||||
// Two separate old files mapping onto the same new path → collision.
|
||||
const colliding = new Set([...newPathCounts.entries()].filter(([, c]) => c > 1).map(([p]) => p));
|
||||
const cleanPairs = pairs.filter(p => !colliding.has(p.new));
|
||||
for (const p of pairs.filter(p => colliding.has(p.new))) collisions.push(p);
|
||||
|
||||
setMigrationPairs(cleanPairs);
|
||||
setMigrationCollisions(collisions);
|
||||
setMigrationUnchanged(unchanged);
|
||||
setMigrationPhase(cleanPairs.length === 0 && collisions.length === 0 ? 'nothing' : 'preview');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: 0, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunMigrationExecuteDeps {
|
||||
targetDir: string | null;
|
||||
sources: DeviceSyncSource[];
|
||||
migrationPairs: MigrationPair[];
|
||||
setMigrationPhase: React.Dispatch<React.SetStateAction<MigrationPhase>>;
|
||||
setMigrationResult: React.Dispatch<React.SetStateAction<MigrationResult | null>>;
|
||||
scanDevice: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function runDeviceSyncMigrationExecute(deps: RunMigrationExecuteDeps): Promise<void> {
|
||||
const { targetDir, sources, migrationPairs, setMigrationPhase, setMigrationResult, scanDevice } = deps;
|
||||
if (!targetDir || migrationPairs.length === 0) { setMigrationPhase('closed'); return; }
|
||||
setMigrationPhase('executing');
|
||||
try {
|
||||
const results = await invoke<{ oldPath: string; newPath: string; ok: boolean; error: string | null }[]>(
|
||||
'rename_device_files',
|
||||
{ targetDir, pairs: migrationPairs.map(p => [p.old, p.new]) }
|
||||
);
|
||||
const ok = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const errors = results.filter(r => !r.ok).map(r => `${r.oldPath}: ${r.error ?? 'unknown'}`);
|
||||
setMigrationResult({ ok, failed, errors });
|
||||
// Bump manifest to v2 (no template field) + rescan the device.
|
||||
invoke('write_device_manifest', { destDir: targetDir, sources }).catch(() => {});
|
||||
scanDevice();
|
||||
setMigrationPhase('done');
|
||||
} catch (e) {
|
||||
setMigrationResult({ ok: 0, failed: migrationPairs.length, errors: [String(e)] });
|
||||
setMigrationPhase('done');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user