mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 14:35:41 +00:00
refactor(device-sync): G.75 — extract drives hook + source-statuses hook + migration + execution orchestrators (cluster) (#642)
Four-cut cluster pulling the orchestrators out of DeviceSync.tsx.
1179 → 905 LOC (−274).
useDeviceSyncDrives — drives state + drivesLoading + refreshDrives
callback + the 5 s polling useEffect + the activeDrive memo that
matches targetDir against any detected drive's mount_point.
Returns { drives, drivesLoading, activeDrive, driveDetected,
refreshDrives }.
useDeviceSyncSourceStatuses — owns sourcePathsMap state + the
useEffect that computes per-source paths through compute_sync_paths
(parallel for all sources, with the cancellation guard), and the
derived sourceStatuses Map keyed on 'synced' / 'pending' /
'deletion'.
runDeviceSyncMigration — runDeviceSyncMigrationPreview (read v1
manifest's filenameTemplate, fetch album-source tracks, compute
new paths via Rust + old paths via JS legacy template, diff into
pairs + collisions + unchanged) and runDeviceSyncMigrationExecute
(invoke rename_device_files, bump manifest to v2, rescan device).
Exports MigrationPhase / MigrationPair / MigrationResult types so
the page state stays typed.
runDeviceSyncExecution — runDeviceSyncSummaryPrompt (input
validation + invoke calculate_sync_payload through the subsonic
client) and runDeviceSyncExecute (delete pending sources, re-write
playlist m3u8 even when nothing to download, fire sync_batch_to_device
with the right toast variants on space / mount / generic errors).
SyncDelta type moves with it.
DeviceSync drops the inline definitions; closeMigration stays
inline (six lines, no real win extracting it). Pure code move
otherwise — no behaviour change.
This commit is contained in:
committed by
GitHub
parent
31542c9923
commit
c7946f26b6
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { RemovableDrive } from '../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(() => {
|
||||
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,69 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { fetchTracksForSource } from '../utils/fetchTracksForSource';
|
||||
import { trackToSyncInfo, type SyncStatus } from '../utils/deviceSyncHelpers';
|
||||
import type { DeviceSyncSource } from '../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) {
|
||||
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 };
|
||||
}
|
||||
+41
-292
@@ -21,12 +21,23 @@ import { showToast } from '../utils/toast';
|
||||
import { IS_WINDOWS } from '../utils/platform';
|
||||
|
||||
import {
|
||||
uuid, formatBytes, trackToSyncInfo,
|
||||
type SourceTab, type SyncStatus, type RemovableDrive,
|
||||
formatBytes, trackToSyncInfo,
|
||||
type SourceTab,
|
||||
} from '../utils/deviceSyncHelpers';
|
||||
import { applyLegacyTemplate } from '../utils/deviceSyncLegacyTemplate';
|
||||
import { fetchTracksForSource } from '../utils/fetchTracksForSource';
|
||||
import BrowserRow from '../components/deviceSync/BrowserRow';
|
||||
import { useDeviceSyncDrives } from '../hooks/useDeviceSyncDrives';
|
||||
import { useDeviceSyncSourceStatuses } from '../hooks/useDeviceSyncSourceStatuses';
|
||||
import {
|
||||
runDeviceSyncMigrationPreview,
|
||||
runDeviceSyncMigrationExecute,
|
||||
type MigrationPhase, type MigrationPair, type MigrationResult,
|
||||
} from '../utils/runDeviceSyncMigration';
|
||||
import {
|
||||
runDeviceSyncSummaryPrompt,
|
||||
runDeviceSyncExecute,
|
||||
type SyncDelta,
|
||||
} from '../utils/runDeviceSyncExecution';
|
||||
|
||||
// ─── component ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -63,52 +74,21 @@ export default function DeviceSync() {
|
||||
const [artistAlbumsMap, setArtistAlbumsMap] = useState<Map<string, SubsonicAlbum[]>>(new Map());
|
||||
const [loadingArtistIds, setLoadingArtistIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Map source IDs → computed device paths (for status derivation)
|
||||
const [sourcePathsMap, setSourcePathsMap] = useState<Map<string, string[]>>(new Map());
|
||||
|
||||
// ─── Removable drive detection ──────────────────────────────────────────
|
||||
const [drives, setDrives] = useState<RemovableDrive[]>([]);
|
||||
const [drivesLoading, setDrivesLoading] = useState(false);
|
||||
const { drives, drivesLoading, activeDrive, driveDetected, refreshDrives } =
|
||||
useDeviceSyncDrives(targetDir);
|
||||
|
||||
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 [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) ────────────
|
||||
type MigrationPhase = 'closed' | 'loading' | 'preview' | 'executing' | 'done' | 'nothing';
|
||||
const [migrationPhase, setMigrationPhase] = useState<MigrationPhase>('closed');
|
||||
const [migrationOldTemplate, setMigrationOldTemplate] = useState<string>('');
|
||||
const [migrationPairs, setMigrationPairs] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationCollisions, setMigrationCollisions] = useState<{ old: string; new: string }[]>([]);
|
||||
const [migrationPairs, setMigrationPairs] = useState<MigrationPair[]>([]);
|
||||
const [migrationCollisions, setMigrationCollisions] = useState<MigrationPair[]>([]);
|
||||
const [migrationUnchanged, setMigrationUnchanged] = useState(0);
|
||||
const [migrationResult, setMigrationResult] = useState<{ ok: number; failed: number; errors: string[] } | null>(null);
|
||||
|
||||
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(() => {
|
||||
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 [migrationResult, setMigrationResult] = useState<MigrationResult | null>(null);
|
||||
|
||||
const isRunning = jobStatus === 'running';
|
||||
|
||||
@@ -157,52 +137,10 @@ export default function DeviceSync() {
|
||||
}
|
||||
}, [driveDetected]);
|
||||
|
||||
// Compute expected paths for each source (for status comparison)
|
||||
useEffect(() => {
|
||||
if (!targetDir || sources.length === 0) {
|
||||
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]);
|
||||
// Source status (path map + derived synced/pending/deletion)
|
||||
const { sourcePathsMap, sourceStatuses } = useDeviceSyncSourceStatuses(
|
||||
targetDir, sources, pendingDeletion, deviceFilePaths,
|
||||
);
|
||||
|
||||
// ─── Desired State / Diff Logic ─────────────────────────────────────────
|
||||
|
||||
@@ -363,109 +301,17 @@ export default function DeviceSync() {
|
||||
const filteredArtists = useMemo(() => artists.filter(a => a.name.toLowerCase().includes(q)), [artists, q]);
|
||||
|
||||
// ─── Migration handlers ─────────────────────────────────────────────────
|
||||
const startMigrationPreview = async () => {
|
||||
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 */ }
|
||||
}
|
||||
const startMigrationPreview = () => runDeviceSyncMigrationPreview({
|
||||
targetDir, sources,
|
||||
setMigrationPhase, setMigrationResult, setMigrationOldTemplate,
|
||||
setMigrationPairs, setMigrationCollisions, setMigrationUnchanged,
|
||||
});
|
||||
|
||||
// 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: { old: string; new: string }[] = [];
|
||||
const collisions: { old: string; new: string }[] = [];
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
const executeMigration = async () => {
|
||||
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');
|
||||
}
|
||||
};
|
||||
const executeMigration = () => runDeviceSyncMigrationExecute({
|
||||
targetDir, sources, migrationPairs,
|
||||
setMigrationPhase, setMigrationResult, scanDevice,
|
||||
});
|
||||
|
||||
const closeMigration = () => {
|
||||
setMigrationPhase('closed');
|
||||
@@ -499,112 +345,15 @@ export default function DeviceSync() {
|
||||
|
||||
// ─── 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; }
|
||||
const promptSyncSummary = () => runDeviceSyncSummaryPrompt({
|
||||
targetDir, sources, pendingDeletion, t,
|
||||
setPreSyncLoading, setPreSyncOpen, setSyncDelta,
|
||||
});
|
||||
|
||||
setPreSyncLoading(true);
|
||||
setPreSyncOpen(true);
|
||||
|
||||
try {
|
||||
const { getClient } = await import('../api/subsonicClient');
|
||||
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,
|
||||
});
|
||||
|
||||
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[] = [];
|
||||
// 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');
|
||||
}
|
||||
});
|
||||
};
|
||||
const handleSyncExecution = () => runDeviceSyncExecute({
|
||||
targetDir, sources, pendingDeletion, syncDelta, t,
|
||||
setPreSyncOpen, removeSources, scanDevice,
|
||||
});
|
||||
|
||||
// ─── Actions ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 '../store/deviceSyncStore';
|
||||
import { useDeviceSyncJobStore } from '../store/deviceSyncJobStore';
|
||||
import { showToast } from './toast';
|
||||
import { trackToSyncInfo, uuid } from './deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from './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 '../store/deviceSyncStore';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { applyLegacyTemplate } from './deviceSyncLegacyTemplate';
|
||||
import { trackToSyncInfo } from './deviceSyncHelpers';
|
||||
import { fetchTracksForSource } from './fetchTracksForSource';
|
||||
import { IS_WINDOWS } from './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