mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(deviceSync): co-locate device sync feature into features/deviceSync
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user