mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user