mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
cb1a110afb
Relocate the playback/queue/transport/audio-output engine out of the type-first
store/ + utils/playback/ + utils/audio/ dirs into a cohesive src/features/playback/,
structure-preserving:
store/<x> -> features/playback/store/<x>
store/audioListenerSetup/<x> -> features/playback/store/audioListenerSetup/<x>
utils/playback/<x> -> features/playback/utils/playback/<x>
utils/audio/<x> -> features/playback/utils/audio/<x>
184 files moved (107 source + 77 tests), 365 consumers rewritten. Pure move — no
behavior change, no state-split (the playerStore state-split stays a separate M5
question). Enabled by this session's decouple seams (artist/offline/orbit/auth →
core registries), so the engine carries no inbound core->feature inversion: store/
now holds only the 50 cross-cutting global stores (auth family, the seams, library
index, UI/settings stores).
KEPT OUT of the move (would re-create global->engine edges): the 3 pure config
helpers utils/audio/{loudnessPreAnalysisSlider,hiResCrossfadeResample} +
utils/playback/autodjOverlapCap (authStore + settings UI read them — they stay in
utils/). Ambiguous view-state stores (eqStore, queueToolbarStore,
playerBarLayoutStore) stay global (no engine imports).
Consumers use DEEP paths (@/features/playback/...), no barrel — matches the lib/
approach and avoids barrel-mock-collapse across the 140 usePlayerStore consumers.
Two tolerated type-only core->feature edges remain (localPlaybackStore->QueueItemRef,
localPlaybackMigration->HotCacheEntry, both erased).
tsc 0, lint 0, full suite 319/2353 green, iron-rule clean (no runtime store->feature
import). Behavior-touching only via the prerequisite bridge seam (already QA-flagged);
the move itself is pure.
149 lines
5.3 KiB
TypeScript
149 lines
5.3 KiB
TypeScript
import type { TFunction } from 'i18next';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
|
import type { SubsonicSong } from '@/lib/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 '@/features/playback/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('@/lib/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');
|
|
}
|
|
});
|
|
}
|