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,593 @@
|
||||
import type { LibraryTrackDto } from '@/api/library';
|
||||
import { libraryGetTrack, libraryGetTracksBatchChunked } from '@/api/library';
|
||||
import type { SubsonicSong } from '@/api/subsonicTypes';
|
||||
import type { CoverServerScope } from '@/cover/types';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { LocalPlaybackEntry, PinnedGroup, PinSource } from '@/store/localPlaybackStore';
|
||||
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
|
||||
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
|
||||
import { resolveTrackCoverArtId, trackToSong } from '@/utils/library/advancedSearchLocal';
|
||||
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
|
||||
import type { Track } from '@/store/playerStoreTypes';
|
||||
import { findServerByIdOrIndexKey, resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
|
||||
import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
|
||||
|
||||
export interface OfflineLibraryCard {
|
||||
serverIndexKey: string;
|
||||
pinSource: PinSource;
|
||||
trackIds: string[];
|
||||
name: string;
|
||||
artist: string;
|
||||
coverArt?: string;
|
||||
/** 2×2 collage when the playlist has no single custom cover. */
|
||||
coverQuadIds?: (string | null)[];
|
||||
year?: number;
|
||||
}
|
||||
|
||||
export function resolveOfflineAlbumMeta(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
): OfflineAlbumMeta | undefined {
|
||||
const albums = useOfflineStore.getState().albums;
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
const indexKey = server ? serverIndexKeyForProfile(server) : serverId;
|
||||
return albums[`${indexKey}:${albumId}`] ?? albums[`${serverId}:${albumId}`];
|
||||
}
|
||||
|
||||
function serverIndexKeysForServerId(serverId: string): string[] {
|
||||
const servers = useAuthStore.getState().servers;
|
||||
const server = servers.find(s => s.id === serverId);
|
||||
const keys = new Set<string>();
|
||||
if (server) {
|
||||
const profileKey = serverIndexKeyForProfile(server);
|
||||
if (profileKey) keys.add(profileKey);
|
||||
keys.add(server.id);
|
||||
}
|
||||
keys.add(resolveIndexKey(serverId));
|
||||
keys.add(serverId);
|
||||
return [...keys].filter(Boolean);
|
||||
}
|
||||
|
||||
export function entryBelongsToServer(entry: LocalPlaybackEntry, serverId: string): boolean {
|
||||
return serverIndexKeysForServerId(serverId).includes(entry.serverIndexKey);
|
||||
}
|
||||
|
||||
export function indexKeyBelongsToServer(serverIndexKey: string, serverId: string): boolean {
|
||||
return serverIndexKeysForServerId(serverId).includes(serverIndexKey);
|
||||
}
|
||||
|
||||
/** Resolve a library-tier row across legacy UUID / URL index-key variants. */
|
||||
export function findLocalPlaybackEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
): LocalPlaybackEntry | null {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const hit = lp.getEntry(trackId, key);
|
||||
if (hit?.tier === 'library') return hit;
|
||||
}
|
||||
for (const entry of Object.values(lp.entries)) {
|
||||
if (entry.trackId !== trackId || entry.tier !== 'library') continue;
|
||||
if (entryBelongsToServer(entry, serverId)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Index cache; run {@link reconcileLibraryTierForAlbum} / server reconcile so rows match disk. */
|
||||
export function hasLocalLibraryBytes(trackId: string, serverId: string): boolean {
|
||||
return !!findLocalPlaybackEntry(trackId, serverId)?.localPath;
|
||||
}
|
||||
|
||||
/** Resolve a `favorite-auto` tier row across index-key variants. */
|
||||
export function findFavoriteAutoEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
): LocalPlaybackEntry | null {
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const hit = lp.getEntry(trackId, key);
|
||||
if (hit?.tier === 'favorite-auto') return hit;
|
||||
}
|
||||
for (const entry of Object.values(lp.entries)) {
|
||||
if (entry.trackId !== trackId || entry.tier !== 'favorite-auto') continue;
|
||||
if (entryBelongsToServer(entry, serverId)) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function hasLocalFavoriteAutoBytes(trackId: string, serverId: string): boolean {
|
||||
return !!findFavoriteAutoEntry(trackId, serverId)?.localPath;
|
||||
}
|
||||
|
||||
/** Manual offline library or favorites auto-sync — skip redundant hot-cache prefetch/promote. */
|
||||
export function hasLocalPersistentPlaybackBytes(trackId: string, serverId: string): boolean {
|
||||
return hasLocalLibraryBytes(trackId, serverId) || hasLocalFavoriteAutoBytes(trackId, serverId);
|
||||
}
|
||||
|
||||
/** Resolve `psysonic-local://` across legacy UUID / host index-key variants. */
|
||||
export function findLocalPlaybackUrl(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
tier: 'library' | 'ephemeral' | 'favorite-auto',
|
||||
): string | null {
|
||||
if (tier === 'library') {
|
||||
const entry = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
|
||||
return null;
|
||||
}
|
||||
if (tier === 'favorite-auto') {
|
||||
const entry = findFavoriteAutoEntry(trackId, serverId);
|
||||
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
|
||||
return null;
|
||||
}
|
||||
const lp = useLocalPlaybackStore.getState();
|
||||
for (const key of serverIndexKeysForServerId(serverId)) {
|
||||
const url = lp.getLocalUrl(trackId, key, 'ephemeral');
|
||||
if (url) return url;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Songs that still need a library-tier pin (used to skip redundant downloads). */
|
||||
export function pendingOfflinePinSongs<T extends { id: string }>(
|
||||
songs: T[],
|
||||
serverId: string,
|
||||
): T[] {
|
||||
return songs.filter(s => !hasLocalLibraryBytes(s.id, serverId));
|
||||
}
|
||||
|
||||
/** True when every track in the offline pin group has local library-tier bytes. */
|
||||
export function isOfflinePinComplete(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
songIds?: string[],
|
||||
): boolean {
|
||||
if (songIds?.length) {
|
||||
return songIds.every(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
const server = useAuthStore.getState().servers.find(s => s.id === serverId);
|
||||
const indexKey = server ? (serverIndexKeyForProfile(server) || serverId) : serverId;
|
||||
const meta = resolveOfflineAlbumMeta(albumId, serverId);
|
||||
const groupTrackIds = useLocalPlaybackStore.getState()
|
||||
.listPinnedGroups(indexKey)
|
||||
.find(g => g.pinSource.sourceId === albumId)?.trackIds;
|
||||
const trackIds = meta?.trackIds.length
|
||||
? meta.trackIds
|
||||
: (groupTrackIds ?? []);
|
||||
if (trackIds.length === 0) return false;
|
||||
return trackIds.every(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link reconcileLibraryTierForAlbum} from `./libraryTierReconcile`. */
|
||||
export async function syncAlbumLibraryTierFromDisk(
|
||||
albumId: string,
|
||||
serverId: string,
|
||||
songs: SubsonicSong[],
|
||||
pinSource?: PinSource,
|
||||
): Promise<number> {
|
||||
const { reconcileLibraryTierForAlbum } = await import('@/features/offline/utils/libraryTierReconcile');
|
||||
const result = await reconcileLibraryTierForAlbum(serverId, songs, pinSource ?? {
|
||||
kind: 'album',
|
||||
sourceId: albumId,
|
||||
});
|
||||
return result.syncedFromDisk;
|
||||
}
|
||||
|
||||
/** @deprecated Use {@link listOfflineLibraryCards}. */
|
||||
export function hasAnyOfflineAlbums(albums: Record<string, OfflineAlbumMeta>): boolean {
|
||||
if (Object.keys(albums).length > 0) return true;
|
||||
return useLocalPlaybackStore.getState().listPinnedGroups().length > 0;
|
||||
}
|
||||
|
||||
export function libraryDtoToTrack(dto: LibraryTrackDto): Track {
|
||||
const song = trackToSong(dto);
|
||||
return {
|
||||
id: song.id,
|
||||
title: song.title,
|
||||
artist: song.artist ?? '',
|
||||
album: song.album,
|
||||
albumId: song.albumId ?? '',
|
||||
artistId: song.artistId,
|
||||
duration: song.duration ?? 0,
|
||||
coverArt: song.coverArt,
|
||||
discNumber: song.discNumber,
|
||||
track: song.track,
|
||||
year: song.year,
|
||||
bitRate: song.bitRate,
|
||||
suffix: song.suffix,
|
||||
genre: song.genre,
|
||||
replayGainTrackDb: dto.replayGainTrackDb ?? undefined,
|
||||
replayGainAlbumDb: dto.replayGainAlbumDb ?? undefined,
|
||||
size: song.size,
|
||||
serverId: dto.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
function legacyCoverForPinnedGroup(group: PinnedGroup): string | undefined {
|
||||
const albums: OfflineAlbumMeta[] = [...Object.values(useOfflineStore.getState().albums)];
|
||||
try {
|
||||
const raw = localStorage.getItem('psysonic-offline');
|
||||
if (raw) {
|
||||
const blob = JSON.parse(raw) as { state?: { albums?: Record<string, OfflineAlbumMeta> } };
|
||||
albums.push(...Object.values(blob.state?.albums ?? {}));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const pinKind = group.pinSource.kind ?? 'album';
|
||||
for (const album of albums) {
|
||||
if (album.id !== group.pinSource.sourceId) continue;
|
||||
if (album.type && album.type !== pinKind) continue;
|
||||
const albumKey = resolveIndexKey(album.serverId);
|
||||
if (albumKey !== group.serverIndexKey && album.serverId !== group.serverIndexKey) continue;
|
||||
const cover = album.coverArt?.trim();
|
||||
if (cover) return cover;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildPlaylistCoverQuad(
|
||||
trackIds: string[],
|
||||
byId: Map<string, LibraryTrackDto>,
|
||||
libraryServerId: string,
|
||||
): (string | null)[] {
|
||||
const seen = new Set<string>();
|
||||
const covers: string[] = [];
|
||||
for (const trackId of trackIds) {
|
||||
const dto = byId.get(`${libraryServerId}:${trackId}`);
|
||||
if (!dto) continue;
|
||||
const cover = resolveTrackCoverArtId(dto);
|
||||
if (!cover || seen.has(cover)) continue;
|
||||
seen.add(cover);
|
||||
covers.push(cover);
|
||||
if (covers.length >= 4) break;
|
||||
}
|
||||
if (covers.length === 0) return [];
|
||||
return Array.from({ length: 4 }, (_, i) => covers[i % covers.length] ?? null);
|
||||
}
|
||||
|
||||
export async function hydrateOfflineLibraryCards(
|
||||
groups: PinnedGroup[],
|
||||
): Promise<OfflineLibraryCard[]> {
|
||||
if (groups.length === 0) return [];
|
||||
const refs = groups.flatMap(g =>
|
||||
g.trackIds.map(trackId => ({
|
||||
serverId: resolveServerIdForIndexKey(g.serverIndexKey) || g.serverIndexKey,
|
||||
trackId,
|
||||
})),
|
||||
);
|
||||
const tracks = await libraryGetTracksBatchChunked(refs);
|
||||
const byId = new Map(tracks.map(t => [`${t.serverId}:${t.id}`, t]));
|
||||
|
||||
return groups.map(group => {
|
||||
const libraryServerId = resolveServerIdForIndexKey(group.serverIndexKey) || group.serverIndexKey;
|
||||
const first = group.trackIds
|
||||
.map(tid => byId.get(`${libraryServerId}:${tid}`))
|
||||
.find(Boolean);
|
||||
const pinKind = group.pinSource.kind ?? 'album';
|
||||
const pinnedMeta = resolveOfflineAlbumMeta(group.pinSource.sourceId, libraryServerId);
|
||||
const legacyCover = legacyCoverForPinnedGroup(group);
|
||||
const displayName = pinKind === 'album'
|
||||
? (group.pinSource.displayName
|
||||
?? first?.album
|
||||
?? first?.title
|
||||
?? group.pinSource.sourceId)
|
||||
: (group.pinSource.displayName ?? group.pinSource.sourceId);
|
||||
const artist = pinKind === 'artist'
|
||||
? (group.pinSource.displayName ?? first?.artist ?? '')
|
||||
: pinKind === 'playlist'
|
||||
? ''
|
||||
: (pinnedMeta?.artist?.trim()
|
||||
|| first?.albumArtist?.trim()
|
||||
|| first?.artist?.trim()
|
||||
|| '');
|
||||
|
||||
let coverArt: string | undefined;
|
||||
let coverQuadIds: (string | null)[] | undefined;
|
||||
if (pinKind === 'playlist') {
|
||||
coverArt = pinnedMeta?.coverArt?.trim() || legacyCover || undefined;
|
||||
if (!coverArt) {
|
||||
const quad = buildPlaylistCoverQuad(group.trackIds, byId, libraryServerId);
|
||||
if (quad.some(Boolean)) coverQuadIds = quad;
|
||||
}
|
||||
} else {
|
||||
coverArt = pinnedMeta?.coverArt?.trim()
|
||||
|| legacyCover
|
||||
|| (first ? resolveTrackCoverArtId(first) : undefined);
|
||||
}
|
||||
|
||||
return {
|
||||
serverIndexKey: group.serverIndexKey,
|
||||
pinSource: group.pinSource,
|
||||
trackIds: group.trackIds,
|
||||
name: displayName,
|
||||
artist,
|
||||
coverArt,
|
||||
coverQuadIds,
|
||||
year: pinKind === 'album' ? (first?.year ?? undefined) : undefined,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function fallbackTrackFromLocalEntry(
|
||||
trackId: string,
|
||||
serverId: string,
|
||||
card: OfflineLibraryCard,
|
||||
): Track | null {
|
||||
const entry = findLocalPlaybackEntry(trackId, serverId);
|
||||
if (!entry?.localPath) return null;
|
||||
return {
|
||||
id: trackId,
|
||||
title: card.pinSource.displayName ?? card.name,
|
||||
artist: card.artist,
|
||||
album: card.name,
|
||||
albumId: card.pinSource.kind === 'album' ? card.pinSource.sourceId : '',
|
||||
duration: 0,
|
||||
coverArt: card.coverArt,
|
||||
suffix: entry.suffix ?? 'mp3',
|
||||
size: entry.sizeBytes,
|
||||
};
|
||||
}
|
||||
|
||||
export async function ensureServerForOfflineIndexKey(serverIndexKey: string): Promise<boolean> {
|
||||
const { activeServerId, servers } = useAuthStore.getState();
|
||||
const resolved = resolveServerIdForIndexKey(serverIndexKey) || serverIndexKey;
|
||||
if (resolved === activeServerId) return true;
|
||||
const server = servers.find(s => s.id === resolved)
|
||||
?? findServerByIdOrIndexKey(serverIndexKey);
|
||||
if (!server) return false;
|
||||
const auth = useAuthStore.getState();
|
||||
auth.setActiveServer(server.id);
|
||||
auth.setLoggedIn(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function listEphemeralCacheEntries(): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'ephemeral' && e.localPath)
|
||||
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
|
||||
}
|
||||
|
||||
/** Indexed ephemeral rows with on-disk bytes under `{media}/cache/`. */
|
||||
export function countEphemeralCacheTracks(): number {
|
||||
return listEphemeralCacheEntries().length;
|
||||
}
|
||||
|
||||
export function ephemeralCacheCoverScope(): CoverServerScope | null {
|
||||
const entry = listEphemeralCacheEntries()[0];
|
||||
if (!entry) return null;
|
||||
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
/** Up to four cover IDs for a playlist-style collage from hot-cache tracks. */
|
||||
export async function collectEphemeralCacheCoverQuad(): Promise<(string | null)[]> {
|
||||
const ephemeral = listEphemeralCacheEntries();
|
||||
if (ephemeral.length === 0) return [null, null, null, null];
|
||||
const refs = ephemeral.slice(0, 16).map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const covers: string[] = [];
|
||||
for (const dto of dtos) {
|
||||
const cover = resolveTrackCoverArtId(dto);
|
||||
if (cover && !covers.includes(cover)) covers.push(cover);
|
||||
if (covers.length >= 4) break;
|
||||
}
|
||||
return Array.from({ length: 4 }, (_, i) => covers[i] ?? null);
|
||||
}
|
||||
|
||||
function listFavoriteAutoEntries(): LocalPlaybackEntry[] {
|
||||
return Object.values(useLocalPlaybackStore.getState().entries)
|
||||
.filter(e => e.tier === 'favorite-auto' && e.localPath)
|
||||
.sort((a, b) => (b.lastPlayedAt ?? b.cachedAt) - (a.lastPlayedAt ?? a.cachedAt));
|
||||
}
|
||||
|
||||
/** Indexed favorite-auto rows with on-disk bytes under `{media}/favorites/`. */
|
||||
export function countFavoriteAutoTracks(): number {
|
||||
return listFavoriteAutoEntries().length;
|
||||
}
|
||||
|
||||
export function favoriteAutoCoverScope(): CoverServerScope | null {
|
||||
const entry = listFavoriteAutoEntries()[0];
|
||||
if (!entry) return null;
|
||||
const server = findServerByIdOrIndexKey(entry.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
export type OfflineCoverQuadCell = {
|
||||
coverArtId: string;
|
||||
serverId: string;
|
||||
} | null;
|
||||
|
||||
/** Up to four cover cells for a collage from favorites-tier tracks (per-server scope). */
|
||||
export async function collectFavoriteAutoCoverQuad(): Promise<OfflineCoverQuadCell[]> {
|
||||
const favorites = listFavoriteAutoEntries();
|
||||
if (favorites.length === 0) return [null, null, null, null];
|
||||
const refs = favorites.slice(0, 16).map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const cells: { coverArtId: string; serverId: string }[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const dto of dtos) {
|
||||
const coverArtId = resolveTrackCoverArtId(dto);
|
||||
if (!coverArtId) continue;
|
||||
const dedupeKey = `${dto.serverId}:${coverArtId}`;
|
||||
if (seen.has(dedupeKey)) continue;
|
||||
seen.add(dedupeKey);
|
||||
cells.push({ coverArtId, serverId: dto.serverId });
|
||||
if (cells.length >= 4) break;
|
||||
}
|
||||
return Array.from({ length: 4 }, (_, i) => cells[i] ?? null);
|
||||
}
|
||||
|
||||
/** Playable tracks under `{media}/favorites/` only (favorite-auto tier). */
|
||||
export async function buildOfflineFavoritesQueueTracks(): Promise<{
|
||||
tracks: Track[];
|
||||
queueServerIndexKey: string | null;
|
||||
}> {
|
||||
const favorites = listFavoriteAutoEntries();
|
||||
if (favorites.length === 0) {
|
||||
return { tracks: [], queueServerIndexKey: null };
|
||||
}
|
||||
|
||||
const refs = favorites.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
|
||||
|
||||
const tracks: Track[] = [];
|
||||
let queueServerIndexKey: string | null = null;
|
||||
for (const entry of favorites) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
} else {
|
||||
tracks.push({
|
||||
id: entry.trackId,
|
||||
title: entry.trackId,
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
suffix: entry.suffix,
|
||||
size: entry.sizeBytes,
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
queueServerIndexKey ??= entry.serverIndexKey;
|
||||
}
|
||||
|
||||
return { tracks, queueServerIndexKey };
|
||||
}
|
||||
|
||||
/** Playable tracks under `{media}/cache/` only (ephemeral / hot-cache tier). */
|
||||
export async function buildOfflineCacheQueueTracks(): Promise<{
|
||||
tracks: Track[];
|
||||
queueServerIndexKey: string | null;
|
||||
}> {
|
||||
const ephemeral = listEphemeralCacheEntries();
|
||||
if (ephemeral.length === 0) {
|
||||
return { tracks: [], queueServerIndexKey: null };
|
||||
}
|
||||
|
||||
const refs = ephemeral.map(e => ({
|
||||
serverId: resolveServerIdForIndexKey(e.serverIndexKey) || e.serverIndexKey,
|
||||
trackId: e.trackId,
|
||||
}));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [`${d.serverId}:${d.id}`, d]));
|
||||
|
||||
const tracks: Track[] = [];
|
||||
let queueServerIndexKey: string | null = null;
|
||||
for (const entry of ephemeral) {
|
||||
const serverId = resolveServerIdForIndexKey(entry.serverIndexKey) || entry.serverIndexKey;
|
||||
const dto = dtoById.get(`${serverId}:${entry.trackId}`);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
} else {
|
||||
tracks.push({
|
||||
id: entry.trackId,
|
||||
title: entry.trackId,
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 0,
|
||||
suffix: entry.suffix,
|
||||
size: entry.sizeBytes,
|
||||
serverId,
|
||||
});
|
||||
}
|
||||
queueServerIndexKey ??= entry.serverIndexKey;
|
||||
}
|
||||
|
||||
return { tracks, queueServerIndexKey };
|
||||
}
|
||||
|
||||
export async function buildTracksForOfflineCard(card: OfflineLibraryCard): Promise<Track[]> {
|
||||
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
|
||||
const localTrackIds = card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId));
|
||||
if (localTrackIds.length === 0) return [];
|
||||
|
||||
const refs = localTrackIds.map(trackId => ({ serverId, trackId }));
|
||||
const dtos = await libraryGetTracksBatchChunked(refs);
|
||||
const dtoById = new Map(dtos.map(d => [d.id, d]));
|
||||
const order = new Map(localTrackIds.map((id, i) => [id, i]));
|
||||
const tracks: Track[] = [];
|
||||
|
||||
for (const trackId of localTrackIds) {
|
||||
const dto = dtoById.get(trackId);
|
||||
if (dto) {
|
||||
tracks.push(libraryDtoToTrack(dto));
|
||||
continue;
|
||||
}
|
||||
const single = await libraryGetTrack(serverId, trackId).catch(() => null);
|
||||
if (single) {
|
||||
tracks.push(libraryDtoToTrack(single));
|
||||
continue;
|
||||
}
|
||||
const fallback = fallbackTrackFromLocalEntry(trackId, serverId, card);
|
||||
if (fallback) tracks.push(fallback);
|
||||
}
|
||||
|
||||
return tracks.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function buildOfflineTracksForAlbum(
|
||||
album: OfflineAlbumMeta,
|
||||
tracks: Record<string, never>,
|
||||
): Track[] {
|
||||
void tracks;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function offlineAlbumCoverScope(card: Pick<OfflineLibraryCard, 'serverIndexKey' | 'coverArt'>): CoverServerScope | null {
|
||||
if (!card.coverArt) return null;
|
||||
const server = findServerByIdOrIndexKey(card.serverIndexKey);
|
||||
if (!server) return null;
|
||||
return {
|
||||
kind: 'server',
|
||||
serverId: server.id,
|
||||
url: server.url,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
};
|
||||
}
|
||||
|
||||
/** Offline play only needs the library index + on-disk bytes — no live server ping. */
|
||||
export async function ensureServerForOfflineCard(card: OfflineLibraryCard): Promise<boolean> {
|
||||
return ensureServerForOfflineIndexKey(card.serverIndexKey);
|
||||
}
|
||||
|
||||
export function offlineQueueServerKeyForCard(card: OfflineLibraryCard): string {
|
||||
return canonicalQueueServerKey(card.serverIndexKey);
|
||||
}
|
||||
|
||||
export function offlineTrackCount(card: OfflineLibraryCard): number {
|
||||
const serverId = resolveServerIdForIndexKey(card.serverIndexKey) || card.serverIndexKey;
|
||||
return card.trackIds.filter(tid => hasLocalLibraryBytes(tid, serverId)).length;
|
||||
}
|
||||
|
||||
export function offlineLibraryCardKey(card: OfflineLibraryCard): string {
|
||||
return `${card.serverIndexKey}:${card.pinSource.kind}:${card.pinSource.sourceId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user