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:
Frank Stellmacher
2026-05-13 15:06:49 +02:00
committed by GitHub
parent 31542c9923
commit c7946f26b6
5 changed files with 446 additions and 292 deletions
+148
View File
@@ -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');
}
});
}
+143
View File
@@ -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');
}
}