refactor(lib): move songToTrack + pure server-scope helpers to lib/media; split trackServerScope

songToTrack (the canonical Subsonic-song -> Track mapper) and the pure
server-scope helpers (activeServerProfileId, stampTrackServerId/s,
isMultiServerQueue, profileIdFromQueueRef) operate only on the lib/media model +
authStore + server-key utils -- no playback-store read. Move them to lib/media so
the ~58 app-wide consumers (cover, context menus, sharing, lucky-mix, library
browse, pages, hooks) depend on lib, not on the playback feature.

trackServerScope is split: the store-reading queue helpers (queueItemRefAt,
filterQueueRefs*, activeServerQueueTrackIds) stay in
features/playback/utils/playback/trackServerScope and build on the pure lib half.

With Track/QueueItemRef (prior commit), songToTrack and shuffleArray now in lib,
the media-domain model is fully out of features/playback. The remaining
playerStoreTypes imports are PlayerState only (the store shape). tsc 0, lint 0,
full suite 2353/2353, build OK.
This commit is contained in:
Psychotoxical
2026-06-30 17:24:29 +02:00
parent 9058abd340
commit 572dce4703
65 changed files with 123 additions and 102 deletions
+105
View File
@@ -0,0 +1,105 @@
/**
* Pure-helper characterization for `songToTrack`.
*
* Maps a Subsonic song record into the internal `Track` shape used by the
* playback queue. Originally lived in `playerStore.ts`; extracted in M0 of
* the frontend refactor (2026-05-12).
*/
import type { Track } from '@/lib/media/trackTypes';
import { describe, expect, it } from 'vitest';
import { songToTrack } from '@/lib/media/songToTrack';
import { makeSubsonicSong } from '@/test/helpers/factories';
describe('songToTrack', () => {
it('maps required Subsonic fields verbatim', () => {
const song = makeSubsonicSong({
id: 's1',
title: 'Hello',
artist: 'World',
album: 'Sample',
albumId: 'a1',
duration: 240,
});
const t = songToTrack(song);
expect(t.id).toBe('s1');
expect(t.title).toBe('Hello');
expect(t.artist).toBe('World');
expect(t.album).toBe('Sample');
expect(t.albumId).toBe('a1');
expect(t.duration).toBe(240);
});
it('copies optional fields when present', () => {
const song = makeSubsonicSong({
id: 's2',
artistId: 'ar-1',
track: 5,
year: 2024,
bitRate: 320,
suffix: 'flac',
userRating: 4,
starred: '2026-05-01T00:00:00Z',
genre: 'Rock',
samplingRate: 48000,
bitDepth: 24,
size: 9_000_000,
coverArt: 's2',
});
const t = songToTrack(song);
expect(t.artistId).toBe('ar-1');
expect(t.track).toBe(5);
expect(t.year).toBe(2024);
expect(t.bitRate).toBe(320);
expect(t.suffix).toBe('flac');
expect(t.userRating).toBe(4);
expect(t.starred).toBe('2026-05-01T00:00:00Z');
expect(t.genre).toBe('Rock');
expect(t.samplingRate).toBe(48000);
expect(t.bitDepth).toBe(24);
expect(t.size).toBe(9_000_000);
expect(t.coverArt).toBe('s2');
});
it('flattens replayGain into replayGainTrackDb / AlbumDb / Peak', () => {
const song = makeSubsonicSong({
replayGain: {
trackGain: -6.5,
albumGain: -7.1,
trackPeak: 0.98,
albumPeak: 0.99,
},
});
const t = songToTrack(song);
expect(t.replayGainTrackDb).toBe(-6.5);
expect(t.replayGainAlbumDb).toBe(-7.1);
expect(t.replayGainPeak).toBe(0.98);
// albumPeak is intentionally not surfaced — only trackPeak.
expect((t as Track & { replayGainAlbumPeak?: number }).replayGainAlbumPeak).toBeUndefined();
});
it('leaves replayGain fields undefined when the song has no replayGain block', () => {
const song = makeSubsonicSong({ replayGain: undefined });
const t = songToTrack(song);
expect(t.replayGainTrackDb).toBeUndefined();
expect(t.replayGainAlbumDb).toBeUndefined();
expect(t.replayGainPeak).toBeUndefined();
});
it('copies OpenSubsonic artists when present', () => {
const song = makeSubsonicSong({
artists: [{ id: 'a1', name: 'Feat' }, { id: 'a2', name: 'Main' }],
});
const t = songToTrack(song);
expect(t.artists).toEqual(song.artists);
});
it('does not invent fields that the Subsonic song lacks', () => {
const song = makeSubsonicSong({});
const t = songToTrack(song);
// Internal queue-routing flags are added by enqueue paths, not by mapping.
expect(t.autoAdded).toBeUndefined();
expect(t.radioAdded).toBeUndefined();
expect(t.playNextAdded).toBeUndefined();
expect(t.artists).toBeUndefined();
});
});
+36
View File
@@ -0,0 +1,36 @@
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { coerceOpenArtistRefs } from '@/lib/api/openArtistRefs';
import { activeServerProfileId } from '@/lib/media/trackServerScope';
export function songToTrack(song: SubsonicSong): Track {
return {
id: song.id,
title: song.title,
artist: song.artist,
album: song.album,
albumId: song.albumId,
artistId: song.artistId,
artists: (() => {
const artists = coerceOpenArtistRefs(song.artists);
return artists.length > 0 ? artists : undefined;
})(),
duration: song.duration,
coverArt: song.coverArt,
discNumber: song.discNumber,
track: song.track,
year: song.year,
bitRate: song.bitRate,
suffix: song.suffix,
userRating: song.userRating,
replayGainTrackDb: song.replayGain?.trackGain,
replayGainAlbumDb: song.replayGain?.albumGain,
replayGainPeak: song.replayGain?.trackPeak,
starred: song.starred,
genre: song.genre,
samplingRate: song.samplingRate,
bitDepth: song.bitDepth,
size: song.size,
serverId: song.serverId ?? activeServerProfileId(),
};
}
+49
View File
@@ -0,0 +1,49 @@
import type { Track, QueueItemRef } from '@/lib/media/trackTypes';
import { useAuthStore } from '@/store/authStore';
import { canonicalQueueServerKey } from '@/utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
/**
* Pure server-scope helpers for the shared media model: stamp the owning server
* onto a Track, and classify queue refs by server profile. No playback-store
* read, so they live in lib/media next to the Track model. The store-reading
* queue helpers (queueItemRefAt, filterQueueRefs*) stay in
* features/playback/utils/playback/trackServerScope and build on these.
*/
/** Active saved-server profile id (auth UUID), when logged in. */
export function activeServerProfileId(): string | undefined {
return useAuthStore.getState().activeServerId ?? undefined;
}
/**
* Ensure every track carries an owning server before it enters the queue.
* Explicit `track.serverId` wins; otherwise `fallbackServerId`, then active server.
*/
export function stampTrackServerId(track: Track, fallbackServerId?: string): Track {
const serverId = track.serverId ?? fallbackServerId ?? activeServerProfileId();
if (!serverId || track.serverId === serverId) {
return serverId && !track.serverId ? { ...track, serverId } : track;
}
return { ...track, serverId };
}
export function stampTrackServerIds(tracks: Track[], fallbackServerId?: string): Track[] {
return tracks.map(t => stampTrackServerId(t, fallbackServerId));
}
/** True when queue refs resolve to more than one server bucket. */
export function isMultiServerQueue(refs: QueueItemRef[]): boolean {
const keys = new Set<string>();
for (const ref of refs) {
if (!ref.serverId) continue;
keys.add(canonicalQueueServerKey(ref.serverId) || ref.serverId);
if (keys.size > 1) return true;
}
return false;
}
export function profileIdFromQueueRef(ref: QueueItemRef | null | undefined): string {
if (!ref?.serverId) return '';
return resolveServerIdForIndexKey(ref.serverId) || ref.serverId;
}