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,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