Files
Psychotoxical-psysonic/src/cover/ref.ts
T
Psychotoxical cb1a110afb refactor(playback): move the audio engine into features/playback
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.
2026-06-30 15:00:20 +02:00

195 lines
6.4 KiB
TypeScript

import { getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
import { useAuthStore } from '../store/authStore';
import { coverServerScopeForServerId } from './serverScope';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { CoverArtId, CoverArtRef, CoverCacheKind, CoverServerScope } from './types';
import {
albumHasDistinctDiscCovers,
coverEntryToRef,
resolveAlbumCoverEntry,
resolveArtistCoverEntry,
resolveTrackCoverEntry,
} from './resolveEntry';
export type { CoverEntry } from './resolveEntry';
export { albumHasDistinctDiscCovers } from './resolveEntry';
export type AlbumCoverRefOptions = {
serverScope?: CoverServerScope;
distinctDiscCovers?: boolean;
};
const albumDistinctDiscCoversByAlbumId = new Map<string, boolean>();
export function rememberAlbumDistinctDiscCovers(
albumId: string,
songs: ReadonlyArray<Pick<SubsonicSong, 'discNumber' | 'coverArt' | 'id' | 'albumId'>>,
): void {
const id = albumId.trim();
if (!id) return;
albumDistinctDiscCoversByAlbumId.set(id, albumHasDistinctDiscCovers(songs));
}
export function forgetAlbumDistinctDiscCovers(albumId: string): void {
albumDistinctDiscCoversByAlbumId.delete(albumId.trim());
}
export type DistinctDiscCoversHint = Pick<
SubsonicSong,
'discNumber' | 'coverArt' | 'id' | 'albumId'
>;
/**
* Whether per-disc `mf-*` cache slots apply — from album tracklist memory or song hints
* when the album page has not been opened yet.
*/
export function resolveDistinctDiscCoversForAlbum(
albumId: string,
fetchCoverArtId?: string | null,
songHint?: DistinctDiscCoversHint,
): boolean {
const album = albumId.trim();
if (!album) return false;
const known = albumDistinctDiscCoversByAlbumId.get(album);
if (known === true) return true;
if (known === false) return false;
if (songHint) {
const cover = songHint.coverArt?.trim();
if ((songHint.discNumber ?? 1) > 1 && Boolean(cover && cover !== album)) return true;
}
const fetch = fetchCoverArtId?.trim();
if (fetch && fetch !== album && fetch.startsWith('mf-')) return true;
return false;
}
function resolveAlbumCoverRefOptions(
third?: CoverServerScope | AlbumCoverRefOptions,
): { serverScope: CoverServerScope; distinctDiscCovers: boolean } {
if (!third || 'kind' in third) {
return { serverScope: third ?? { kind: 'active' }, distinctDiscCovers: false };
}
return {
serverScope: third.serverScope ?? { kind: 'active' },
distinctDiscCovers: third.distinctDiscCovers ?? false,
};
}
/** @deprecated Use {@link resolveAlbumCoverEntry}. */
export function resolveAlbumCoverCacheEntityId(
albumId: string,
fetchCoverArtId?: string | null,
distinctDiscCovers = false,
): string {
return resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers)?.cacheEntityId ?? '';
}
/**
* Sync fallback for cover identity — UI should prefer {@link useAlbumCoverRef} /
* {@link AlbumCoverArtImage}; async paths should use {@link resolveAlbumCoverRefFromLibrary}.
*/
export function albumCoverRef(
albumId: string,
fetchCoverArtId?: string | null,
scopeOrOpts: CoverServerScope | AlbumCoverRefOptions = { kind: 'active' },
): CoverArtRef {
const { serverScope, distinctDiscCovers } = resolveAlbumCoverRefOptions(scopeOrOpts);
const entry = resolveAlbumCoverEntry(albumId, fetchCoverArtId, distinctDiscCovers);
if (!entry) {
const id = (fetchCoverArtId ?? albumId).trim();
return coverEntryToRef(
{ cacheKind: 'album', cacheEntityId: id, fetchCoverArtId: id },
serverScope,
);
}
return coverEntryToRef(entry, serverScope);
}
export function albumCoverRefForSong(
song: Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
distinctDiscCovers?: boolean,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef | undefined {
const albumId = song.albumId?.trim();
const distinct =
distinctDiscCovers
?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId, song.coverArt, song) : false);
const entry = resolveTrackCoverEntry(song, distinct);
return entry ? coverEntryToRef(entry, serverScope) : undefined;
}
export function albumCoverRefForPlayback(
track: Pick<SubsonicSong, 'coverArt' | 'id' | 'discNumber'> & { albumId?: string | null },
serverScope: CoverServerScope = resolvePlaybackCoverScope(),
): CoverArtRef | undefined {
const albumId = track.albumId?.trim();
if (!albumId) return undefined;
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(
albumId,
track.coverArt,
{ ...track, albumId } as DistinctDiscCoversHint,
);
return albumCoverRefForSong(
{ ...track, albumId } as Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
distinctDiscCovers,
serverScope,
);
}
export function artistCoverRef(
artistId: string,
fetchCoverArtId?: string | null,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef {
const entry = resolveArtistCoverEntry(artistId, fetchCoverArtId);
if (!entry) {
const id = (fetchCoverArtId ?? artistId).trim();
return coverEntryToRef(
{ cacheKind: 'artist', cacheEntityId: id, fetchCoverArtId: id },
serverScope,
);
}
return coverEntryToRef(entry, serverScope);
}
export function coverRefFromEntity(
cacheKind: CoverCacheKind,
cacheEntityId: string,
fetchCoverArtId?: string | null,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef {
const entry =
cacheKind === 'artist'
? resolveArtistCoverEntry(cacheEntityId, fetchCoverArtId)
: resolveAlbumCoverEntry(cacheEntityId, fetchCoverArtId);
if (!entry) {
const id = (fetchCoverArtId ?? cacheEntityId).trim();
return coverEntryToRef(
{ cacheKind, cacheEntityId: id, fetchCoverArtId: id },
serverScope,
);
}
return coverEntryToRef(entry, serverScope);
}
/** @deprecated Prefer entity helpers in {@link resolveEntry}. */
export function coverArtRef(
coverArtId: CoverArtId,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef {
const id = coverArtId.trim();
if (id.startsWith('ar-')) return artistCoverRef(id, id, serverScope);
return albumCoverRef(id, id, serverScope);
}
export function resolvePlaybackCoverScope(): CoverServerScope {
const playbackSid = getPlaybackServerId();
if (!playbackSid) return { kind: 'playback' };
const activeSid = useAuthStore.getState().activeServerId;
if (playbackSid === activeSid) return { kind: 'playback' };
return coverServerScopeForServerId(playbackSid);
}