import type { LibraryTrackDto } from '../../api/library'; import { libraryAdvancedSearch, libraryGetTracksBatchChunked, libraryGetTracksByAlbum } from '../../api/library'; import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import type { LocalPlaybackEntry } from '../../store/localPlaybackStore'; import { useLocalPlaybackStore } from '../../store/localPlaybackStore'; import { albumToAlbum, artistToArtist, resolveTrackCoverArtId, trackToSong, } from '../library/advancedSearchLocal'; import { filterAlbumsByCompilation, filterAlbumsByGenres, filterAlbumsByStarred, filterAlbumsByYearBounds, } from '../library/albumBrowseFilters'; import type { AlbumBrowseQuery } from '../library/albumBrowseTypes'; import { sortSubsonicAlbums } from '../library/albumBrowseSort'; import { isLosslessSuffix } from '../library/losslessFormats'; import { entryBelongsToServer } from './offlineLibraryHelpers'; function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] { return [...songs].sort((a, b) => a.title.localeCompare(b.title)); } function listBrowsableEntries(serverId: string): LocalPlaybackEntry[] { return Object.values(useLocalPlaybackStore.getState().entries).filter( e => (e.tier === 'library' || e.tier === 'favorite-auto') && !!e.localPath && entryBelongsToServer(e, serverId), ); } export function countLocalBrowsableTracks(serverId: string): number { return listBrowsableEntries(serverId).length; } /** Local library index + at least one on-disk library/favorites track for this server. */ export function offlineLocalBrowseEnabled(serverId: string | null | undefined): boolean { if (!serverId) return false; if (!useLibraryIndexStore.getState().isIndexEnabled(serverId)) return false; return countLocalBrowsableTracks(serverId) > 0; } /** Track DTOs for every library/favorite-auto entry with on-disk bytes for this server. */ export async function fetchBrowsableLocalTrackDtos(serverId: string): Promise { const entries = listBrowsableEntries(serverId); if (entries.length === 0) return []; const refs = entries.map(e => ({ serverId, trackId: e.trackId })); return libraryGetTracksBatchChunked(refs); } export function buildAlbumFromTracks( albumId: string, tracks: LibraryTrackDto[], serverId: string, ): SubsonicAlbum { const songs = tracks.map(trackToSong).map(s => ({ ...s, serverId })); const first = tracks[0]; const starred = tracks.some(t => t.starredAt != null); return { id: albumId, name: first.album ?? albumId, artist: first.albumArtist ?? first.artist ?? '', artistId: first.artistId ?? '', coverArt: resolveTrackCoverArtId(first) ?? albumId, year: first.year ?? undefined, genre: first.genre ?? undefined, songCount: songs.length, duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), starred: starred ? new Date().toISOString() : undefined, serverId, }; } function aggregateAlbumsFromTracks( tracks: LibraryTrackDto[], serverId: string, ): SubsonicAlbum[] { const byAlbum = new Map(); for (const track of tracks) { const albumId = track.albumId; if (!albumId) continue; const list = byAlbum.get(albumId) ?? []; list.push(track); byAlbum.set(albumId, list); } return [...byAlbum.entries()].map(([albumId, albumTracks]) => buildAlbumFromTracks(albumId, albumTracks, serverId), ); } function aggregateArtistsFromTracks( tracks: LibraryTrackDto[], serverId: string, ): SubsonicArtist[] { const albumIdsByArtist = new Map>(); const names = new Map(); for (const track of tracks) { const artistId = track.artistId; if (!artistId) continue; names.set(artistId, track.artist ?? track.albumArtist ?? artistId); const set = albumIdsByArtist.get(artistId) ?? new Set(); if (track.albumId) set.add(track.albumId); albumIdsByArtist.set(artistId, set); } return [...names.entries()] .map(([id, name]) => ({ id, name, albumCount: albumIdsByArtist.get(id)?.size ?? 0, serverId, })) .sort((a, b) => a.name.localeCompare(b.name)); } function applyAlbumBrowseQuery( albums: SubsonicAlbum[], query: AlbumBrowseQuery, starredOverrides: Record, ): SubsonicAlbum[] { let out = albums; if (query.genres.length > 0) { out = filterAlbumsByGenres(out, query.genres); } if (query.year) { out = filterAlbumsByYearBounds(out, query.year); } if (query.starredOnly) { out = filterAlbumsByStarred(out, starredOverrides); } if (query.compFilter !== 'all') { out = filterAlbumsByCompilation(out, query.compFilter); } return sortSubsonicAlbums(out, query.sort); } export async function fetchOfflineLocalBrowsableSongPage( serverId: string, offset: number, chunkSize: number, ): Promise<{ songs: SubsonicSong[]; hasMore: boolean } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; const tracks = await fetchBrowsableLocalTrackDtos(serverId); const songs = sortBrowsableSongs( tracks.map(trackToSong).map(s => ({ ...s, serverId })), ); const slice = songs.slice(offset, offset + chunkSize); return { songs: slice, hasMore: offset + chunkSize < songs.length }; } export async function searchOfflineLocalBrowsableSongs( serverId: string, query: string, offset: number, chunkSize: number, ): Promise { if (!offlineLocalBrowseEnabled(serverId)) return null; const q = query.trim().toLowerCase(); if (!q) return null; const tracks = await fetchBrowsableLocalTrackDtos(serverId); const matched = tracks .filter(t => (t.title?.toLowerCase().includes(q)) || (t.artist?.toLowerCase().includes(q)) || (t.album?.toLowerCase().includes(q)), ) .map(trackToSong) .map(s => ({ ...s, serverId })); return sortBrowsableSongs(matched).slice(offset, offset + chunkSize); } export async function fetchOfflineLocalStarredArtists(serverId: string): Promise { if (!offlineLocalBrowseEnabled(serverId)) return null; const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter(t => t.starredAt != null); return aggregateArtistsFromTracks(tracks, serverId); } export async function fetchOfflineLocalArtistCatalogChunk( serverId: string, offset: number, chunkSize: number, ): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; const tracks = await fetchBrowsableLocalTrackDtos(serverId); const artists = aggregateArtistsFromTracks(tracks, serverId); const slice = artists.slice(offset, offset + chunkSize); return { artists: slice, hasMore: offset + chunkSize < artists.length, }; } export async function searchOfflineLocalArtists( serverId: string, query: string, ): Promise { if (!offlineLocalBrowseEnabled(serverId)) return null; const q = query.trim().toLowerCase(); if (!q) return []; const tracks = await fetchBrowsableLocalTrackDtos(serverId); return aggregateArtistsFromTracks(tracks, serverId) .filter(a => a.name.toLowerCase().includes(q)); } export async function fetchOfflineLocalAlbumCatalogChunk( serverId: string, query: AlbumBrowseQuery, offset: number, chunkSize: number, starredOverrides: Record = {}, ): Promise<{ albums: SubsonicAlbum[]; hasMore: boolean } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; let tracks = await fetchBrowsableLocalTrackDtos(serverId); if (query.losslessOnly) { tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined)); } let albums = aggregateAlbumsFromTracks(tracks, serverId); albums = applyAlbumBrowseQuery(albums, query, starredOverrides); const slice = albums.slice(offset, offset + chunkSize); return { albums: slice, hasMore: offset + chunkSize < albums.length, }; } export async function searchOfflineLocalAlbums( serverId: string, query: string, losslessOnly = false, ): Promise { if (!offlineLocalBrowseEnabled(serverId)) return null; const q = query.trim().toLowerCase(); if (!q) return []; let tracks = await fetchBrowsableLocalTrackDtos(serverId); if (losslessOnly) { tracks = tracks.filter(t => isLosslessSuffix(t.suffix ?? undefined)); } return aggregateAlbumsFromTracks(tracks, serverId) .filter(a => a.name.toLowerCase().includes(q) || a.artist.toLowerCase().includes(q)); } export async function loadAlbumFromLocalPlayback( serverId: string, albumId: string, ): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId)); const tracks = await libraryGetTracksByAlbum(serverId, albumId); const localTracks = tracks.filter(t => localIds.has(t.id)); if (localTracks.length === 0) return null; const songs = localTracks.map(trackToSong).map(s => ({ ...s, serverId })); const albumSearch = await libraryAdvancedSearch({ serverId, entityTypes: ['album'], restrictAlbumIds: [albumId], limit: 1, }).catch(() => null); const albumDto = albumSearch?.albums[0]; const album = albumDto ? { ...albumToAlbum(albumDto), serverId, songCount: songs.length } : buildAlbumFromTracks(albumId, localTracks, serverId); return { album: { ...album, duration: songs.reduce((sum, s) => sum + (s.duration ?? 0), 0), }, songs, }; } export async function loadArtistFromLocalPlayback( serverId: string, artistId: string, ): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] } | null> { if (!offlineLocalBrowseEnabled(serverId)) return null; const localIds = new Set(listBrowsableEntries(serverId).map(e => e.trackId)); const tracks = (await fetchBrowsableLocalTrackDtos(serverId)).filter( t => t.artistId === artistId && localIds.has(t.id), ); if (tracks.length === 0) return null; const albums = aggregateAlbumsFromTracks(tracks, serverId) .sort((a, b) => a.name.localeCompare(b.name)); const artistDto = tracks[0]; const artistSearch = await libraryAdvancedSearch({ serverId, entityTypes: ['artist'], limit: 10_000, }).catch(() => null); const match = artistSearch?.artists.find(a => a.id === artistId); const artist = match ? { ...artistToArtist(match), serverId, albumCount: albums.length } : { id: artistId, name: artistDto.artist ?? artistDto.albumArtist ?? artistId, albumCount: albums.length, serverId, }; return { artist, albums }; }