mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
refactor(offline): co-locate offline feature into features/offline
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { libraryGetTracksBatch } from '@/api/library';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { useLocalPlaybackStore, type LocalPlaybackEntry, type PinSource } from '@/store/localPlaybackStore';
|
||||
import { localPlaybackEntryKey } from '@/store/localPlaybackKeys';
|
||||
import { importLegacyLocalPlayback } from '@/store/localPlaybackMigration';
|
||||
import { getMediaDir } from '@/utils/media/mediaDir';
|
||||
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
|
||||
interface LegacyOfflineMigrationResult {
|
||||
trackId: string;
|
||||
serverIndexKey: string;
|
||||
path: string;
|
||||
size: number;
|
||||
layoutFingerprint: string;
|
||||
relocated: boolean;
|
||||
skippedReason?: string | null;
|
||||
}
|
||||
|
||||
type PersistCapableStore = {
|
||||
persist: {
|
||||
hasHydrated: () => boolean;
|
||||
onFinishHydration: (fn: () => void) => () => void;
|
||||
};
|
||||
};
|
||||
|
||||
type LegacyOfflineBlob = {
|
||||
state?: {
|
||||
albums?: Record<string, OfflineAlbumMeta>;
|
||||
};
|
||||
};
|
||||
|
||||
function waitForStoreHydration(store: PersistCapableStore): Promise<void> {
|
||||
if (store.persist.hasHydrated()) return Promise.resolve();
|
||||
return new Promise(resolve => {
|
||||
const unsub = store.persist.onFinishHydration(() => {
|
||||
unsub();
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function migrationDebug(payload: Record<string, unknown>): void {
|
||||
if (useAuthStore.getState().loggingMode !== 'debug') return;
|
||||
void invoke('frontend_debug_log', {
|
||||
scope: 'legacy-offline-migration',
|
||||
message: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function resolveIndexKeyForServerId(serverId: string): string {
|
||||
const trimmed = serverId.trim();
|
||||
if (!trimmed) return trimmed;
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const byId = servers.find(s => s.id === trimmed);
|
||||
if (byId) return resolveIndexKey(byId.id) || trimmed;
|
||||
return resolveIndexKey(trimmed) || trimmed;
|
||||
}
|
||||
|
||||
function collectLegacyOfflineAlbums(): OfflineAlbumMeta[] {
|
||||
const merged: Record<string, OfflineAlbumMeta> = {
|
||||
...useOfflineStore.getState().albums,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic-offline');
|
||||
if (raw) {
|
||||
const blob = JSON.parse(raw) as LegacyOfflineBlob;
|
||||
Object.assign(merged, blob.state?.albums ?? {});
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return Object.values(merged);
|
||||
}
|
||||
|
||||
function pinSourceForTrack(
|
||||
serverIndexKey: string,
|
||||
trackId: string,
|
||||
albums: OfflineAlbumMeta[],
|
||||
): PinSource | undefined {
|
||||
for (const album of albums) {
|
||||
if (!album.trackIds.includes(trackId)) continue;
|
||||
const albumKey = resolveIndexKeyForServerId(album.serverId);
|
||||
if (albumKey !== serverIndexKey && album.serverId !== serverIndexKey) continue;
|
||||
return {
|
||||
kind: album.type ?? 'album',
|
||||
sourceId: album.id,
|
||||
displayName: album.name,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** True when the file still uses the flat legacy offline layout (not under `media/…/library/`). */
|
||||
export function entryNeedsFileRelocation(entry: LocalPlaybackEntry): boolean {
|
||||
if (entry.tier !== 'library' || !entry.localPath.trim()) return false;
|
||||
const normalized = entry.localPath.replace(/\\/g, '/');
|
||||
if (normalized.includes('/media/library/')) return false;
|
||||
if (normalized.includes('/psysonic-offline/')) return true;
|
||||
return !normalized.includes('/library/');
|
||||
}
|
||||
|
||||
/** Offline Library cards need `pinSource` — restore from legacy albums + persist import. */
|
||||
export function restoreOfflineLibraryPinSources(): number {
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const albums = collectLegacyOfflineAlbums();
|
||||
const fromPersist = importLegacyLocalPlayback(servers);
|
||||
const store = useLocalPlaybackStore.getState();
|
||||
const merged = { ...store.entries };
|
||||
let updated = 0;
|
||||
|
||||
for (const entry of Object.values(merged)) {
|
||||
if (entry.tier !== 'library' || entry.pinSource) continue;
|
||||
const key = localPlaybackEntryKey(entry.serverIndexKey, entry.trackId);
|
||||
const legacy = fromPersist[key];
|
||||
const pin = legacy?.pinSource
|
||||
?? pinSourceForTrack(entry.serverIndexKey, entry.trackId, albums);
|
||||
if (!pin) continue;
|
||||
merged[key] = { ...entry, pinSource: pin };
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
useLocalPlaybackStore.setState({ entries: merged });
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Group orphan library pins by album metadata when legacy cards are gone. */
|
||||
export async function inferPinSourcesFromLibraryIndex(): Promise<number> {
|
||||
const needs = Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'library' && !e.pinSource);
|
||||
if (needs.length === 0) return 0;
|
||||
|
||||
const refs = needs.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const tracks = await libraryGetTracksBatch(refs);
|
||||
const byRef = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
|
||||
let updated = 0;
|
||||
|
||||
for (const entry of needs) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = byRef.get(`${serverId}:${entry.trackId}`);
|
||||
if (!dto?.albumId) continue;
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
...entry,
|
||||
pinSource: {
|
||||
kind: 'album',
|
||||
sourceId: dto.albumId,
|
||||
displayName: dto.album,
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function applyMigrationResults(results: LegacyOfflineMigrationResult[]): number {
|
||||
let relocated = 0;
|
||||
for (const r of results) {
|
||||
if (!r.path || r.skippedReason === 'library_track_not_found') continue;
|
||||
const key = localPlaybackEntryKey(r.serverIndexKey, r.trackId);
|
||||
const prev = useLocalPlaybackStore.getState().entries[key];
|
||||
useLocalPlaybackStore.getState().upsertEntry({
|
||||
serverIndexKey: r.serverIndexKey,
|
||||
trackId: r.trackId,
|
||||
localPath: r.path,
|
||||
layoutFingerprint: r.layoutFingerprint || prev?.layoutFingerprint || '',
|
||||
sizeBytes: r.size || prev?.sizeBytes || 0,
|
||||
tier: 'library',
|
||||
cachedAt: prev?.cachedAt ?? Date.now(),
|
||||
pinSource: prev?.pinSource,
|
||||
suffix: prev?.suffix ?? 'mp3',
|
||||
});
|
||||
if (r.relocated) relocated += 1;
|
||||
}
|
||||
return relocated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan flat `psysonic-offline/{segment}/{trackId}.ext`, keep tracks that still
|
||||
* exist in the library index, move them under `media/library/…`, then restore
|
||||
* Offline Library grouping (`pinSource`).
|
||||
*/
|
||||
export async function runLegacyOfflineFileMigration(serverIndexKey?: string): Promise<number> {
|
||||
await waitForStoreHydration(useAuthStore as unknown as PersistCapableStore);
|
||||
await waitForStoreHydration(useLocalPlaybackStore as unknown as PersistCapableStore);
|
||||
await waitForStoreHydration(useOfflineStore as unknown as PersistCapableStore);
|
||||
|
||||
const customOfflineDir = useAuthStore.getState().offlineDownloadDir?.trim() || null;
|
||||
let relocated = 0;
|
||||
try {
|
||||
const results = await invoke<LegacyOfflineMigrationResult[]>('migrate_legacy_offline_disk', {
|
||||
mediaDir: getMediaDir(),
|
||||
customOfflineDir,
|
||||
serverIndexKeyFilter: serverIndexKey ?? null,
|
||||
});
|
||||
relocated = applyMigrationResults(results);
|
||||
migrationDebug({
|
||||
event: 'disk-migrate',
|
||||
serverIndexKey: serverIndexKey ?? null,
|
||||
scanned: results.length,
|
||||
relocated,
|
||||
results: results.map(r => ({
|
||||
trackId: r.trackId,
|
||||
relocated: r.relocated,
|
||||
skippedReason: r.skippedReason ?? null,
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
migrationDebug({
|
||||
event: 'disk-migrate-error',
|
||||
serverIndexKey: serverIndexKey ?? null,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
const fromAlbums = restoreOfflineLibraryPinSources();
|
||||
const fromIndex = await inferPinSourcesFromLibraryIndex();
|
||||
migrationDebug({
|
||||
event: 'pin-source-restore',
|
||||
fromAlbums,
|
||||
fromIndex,
|
||||
groups: useLocalPlaybackStore.getState().listPinnedGroups().length,
|
||||
});
|
||||
|
||||
return relocated;
|
||||
}
|
||||
Reference in New Issue
Block a user