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
-110
View File
@@ -1,110 +0,0 @@
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 '../utils/deviceSync/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,
};
}
-64
View File
@@ -1,64 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { TFunction } from 'i18next';
import { useDeviceSyncStore, type DeviceSyncSource } from '../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 };
}
-47
View File
@@ -1,47 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { RemovableDrive } from '../utils/deviceSync/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 };
}
-75
View File
@@ -1,75 +0,0 @@
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 '../store/deviceSyncJobStore';
import { useDeviceSyncStore } from '../store/deviceSyncStore';
import { showToast } from '../utils/ui/toast';
import { trackToSyncInfo } from '../utils/deviceSync/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]);
}
-71
View File
@@ -1,71 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import { fetchTracksForSource } from '../utils/playback/fetchTracksForSource';
import { trackToSyncInfo, type SyncStatus } from '../utils/deviceSync/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) {
// 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 };
}