import { libraryUpsertSongsFromApi } from '@/lib/api/library'; import { librarySqlServerId } from '@/api/coverCache'; import type { SubsonicSong } from '@/lib/api/subsonicTypes'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '@/store/authStore'; import type { LocalPlaybackEntry, PinSource } from '@/store/localPlaybackStore'; import { useLocalPlaybackStore } from '@/store/localPlaybackStore'; import { getMediaDir } from '@/lib/media/mediaDir'; import { resolveIndexKey, serverIndexKeyForProfile } from '@/lib/server/serverIndexKey'; import { entryBelongsToServer, findLocalPlaybackEntry, indexKeyBelongsToServer, } from '@/store/localPlaybackResolve'; interface LibraryTrackProbeResult { path: string; size: number; layoutFingerprint: string; exists: boolean; } interface LibraryTierDiskHit { trackId: string; path: string; size: number; layoutFingerprint: string; suffix: string; } export interface LibraryTierReconcileResult { syncedFromDisk: number; removedStaleIndex: number; orphansRemoved: number; } function serverIndexKeyForServerId(serverId: string): string { const server = useAuthStore.getState().servers.find(s => s.id === serverId); if (server) { return serverIndexKeyForProfile(server) || resolveIndexKey(serverId) || serverId; } return resolveIndexKey(serverId) || serverId; } function collectCandidateTrackIds(serverId: string, extraTrackIds: string[] = []): string[] { const lp = useLocalPlaybackStore.getState(); const ids = new Set(extraTrackIds); for (const entry of libraryEntriesForServer(serverId)) { ids.add(entry.trackId); } for (const group of lp.listPinnedGroups()) { if (!indexKeyBelongsToServer(group.serverIndexKey, serverId)) continue; for (const trackId of group.trackIds) ids.add(trackId); } return [...ids]; } function libraryEntriesForServer(serverId: string): LocalPlaybackEntry[] { return Object.values(useLocalPlaybackStore.getState().entries).filter( e => e.tier === 'library' && entryBelongsToServer(e, serverId), ); } function upsertFromProbe( probe: LibraryTrackProbeResult, serverIndexKey: string, serverId: string, trackId: string, suffix: string, pinSource?: PinSource, ): void { const lp = useLocalPlaybackStore.getState(); const existing = findLocalPlaybackEntry(trackId, serverId); if (existing && existing.serverIndexKey !== serverIndexKey) { lp.removeEntry(trackId, existing.serverIndexKey, 'reconcile-key-normalize'); } lp.upsertEntry({ serverIndexKey, trackId, localPath: probe.path, sizeBytes: probe.size, layoutFingerprint: probe.layoutFingerprint, tier: 'library', pinSource: pinSource ?? existing?.pinSource, suffix, }); } async function discoverLibraryTierHits( serverId: string, candidateTrackIds: string[], ): Promise { const serverIndexKey = serverIndexKeyForServerId(serverId); const libraryServerId = librarySqlServerId(serverId); try { return await invoke('discover_library_tier_on_disk', { serverIndexKey, libraryServerId, candidateTrackIds, mediaDir: getMediaDir(), }); } catch { return []; } } async function importLibraryTierFromDisk( serverId: string, candidateTrackIds: string[], ): Promise<{ hits: LibraryTierDiskHit[]; imported: number; hitByTrackId: Map; }> { const serverIndexKey = serverIndexKeyForServerId(serverId); const hits = await discoverLibraryTierHits(serverId, candidateTrackIds); const hitByTrackId = new Map(hits.map(hit => [hit.trackId, hit])); let imported = 0; for (const hit of hits) { const existing = findLocalPlaybackEntry(hit.trackId, serverId); if ( existing && existing.localPath === hit.path && existing.layoutFingerprint === hit.layoutFingerprint && existing.sizeBytes === hit.size && existing.serverIndexKey === serverIndexKey ) { continue; } upsertFromProbe( { path: hit.path, size: hit.size, layoutFingerprint: hit.layoutFingerprint, exists: true, }, serverIndexKey, serverId, hit.trackId, hit.suffix || 'mp3', existing?.pinSource, ); imported += 1; } return { hits, imported, hitByTrackId }; } /** * Bidirectional library-tier reconcile for one server scope: * - index row without bytes at canonical path → drop index row * - bytes at canonical path without index → upsert index row * - on-disk files not in the kept set → delete (orphan cleanup) */ /** Directory-first sweep for every configured server profile. */ export async function reconcileAllLibraryTiersFromDisk(): Promise { for (const server of useAuthStore.getState().servers) { await reconcileLibraryTierForServer(server.id); } } export async function reconcileLibraryTierForServer( serverId: string, ): Promise { const serverIndexKey = serverIndexKeyForServerId(serverId); const lp = useLocalPlaybackStore.getState(); const keepPaths = new Set(); let syncedFromDisk = 0; let removedStaleIndex = 0; const candidates = collectCandidateTrackIds(serverId); const diskImport = await importLibraryTierFromDisk(serverId, candidates); syncedFromDisk += diskImport.imported; for (const hit of diskImport.hits) { keepPaths.add(hit.path); } for (const entry of libraryEntriesForServer(serverId)) { const hit = diskImport.hitByTrackId.get(entry.trackId); if (hit) { keepPaths.add(hit.path); continue; } lp.removeEntry(entry.trackId, entry.serverIndexKey, 'reconcile-missing-bytes'); removedStaleIndex += 1; } let orphansRemoved: number; try { const removed = await invoke('prune_orphan_library_tier_files', { serverIndexKey, keepPaths: [...keepPaths], mediaDir: getMediaDir(), }); orphansRemoved = removed.length; } catch { orphansRemoved = 0; } return { syncedFromDisk, removedStaleIndex, orphansRemoved }; } /** Album-scoped reconcile: sync index ↔ disk for the current track list, then prune orphans. */ export async function reconcileLibraryTierForAlbum( serverId: string, songs: SubsonicSong[], pinSource?: PinSource, ): Promise { const serverIndexKey = serverIndexKeyForServerId(serverId); const libraryServerId = librarySqlServerId(serverId); const lp = useLocalPlaybackStore.getState(); const keepPaths = new Set(); let syncedFromDisk = 0; let removedStaleIndex = 0; await libraryUpsertSongsFromApi(libraryServerId, songs).catch(() => {}); const candidates = collectCandidateTrackIds(serverId, songs.map(song => song.id)); const diskImport = await importLibraryTierFromDisk(serverId, candidates); for (const song of songs) { const hit = diskImport.hitByTrackId.get(song.id); const existing = findLocalPlaybackEntry(song.id, serverId); if (hit) { keepPaths.add(hit.path); const effectivePin = pinSource ?? existing?.pinSource; if ( !existing || existing.localPath !== hit.path || existing.layoutFingerprint !== hit.layoutFingerprint || existing.serverIndexKey !== serverIndexKey ) { upsertFromProbe( { path: hit.path, size: hit.size, layoutFingerprint: hit.layoutFingerprint, exists: true, }, serverIndexKey, serverId, song.id, hit.suffix || song.suffix || 'mp3', effectivePin, ); syncedFromDisk += 1; } continue; } if (existing) { lp.removeEntry(song.id, existing.serverIndexKey, 'reconcile-album-missing-bytes'); removedStaleIndex += 1; } } for (const hit of diskImport.hits) { keepPaths.add(hit.path); } let orphansRemoved: number; try { const removed = await invoke('prune_orphan_library_tier_files', { serverIndexKey, keepPaths: [...keepPaths], mediaDir: getMediaDir(), }); orphansRemoved = removed.length; } catch { orphansRemoved = 0; } return { syncedFromDisk, removedStaleIndex, orphansRemoved }; }