refactor(deviceSync): co-locate device sync feature into features/deviceSync

This commit is contained in:
Psychotoxical
2026-06-29 22:55:35 +02:00
parent 236acc33dd
commit ea3e3f1adf
23 changed files with 78 additions and 68 deletions
@@ -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');
}
}