mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
572dce4703
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.
408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
import { fetchSimilarTracksRouted } from '@/lib/api/subsonicArtists';
|
|
import { filterSongsToActiveLibrary, getRandomSongs } from '@/lib/api/subsonicLibrary';
|
|
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
|
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
|
import { songToTrack } from '@/lib/media/songToTrack';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import i18n from '@/lib/i18n';
|
|
import { useAuthStore } from '../../store/authStore';
|
|
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import { useLuckyMixStore } from '../../store/luckyMixStore';
|
|
import { isLuckyMixAvailable } from '../../hooks/useLuckyMixAvailable';
|
|
import { showToast } from '../ui/toast';
|
|
import {
|
|
bindQueueServerForPlayback,
|
|
playbackServerDiffersFromActive,
|
|
prepareActiveServerForNewMix,
|
|
shouldHandoffQueueToActiveServer,
|
|
} from '@/features/playback/utils/playback/playbackServer';
|
|
import {
|
|
filterSongsForLuckyMixRatings,
|
|
filterTopArtistsForMixRatings,
|
|
getMixMinRatingsConfigFromAuth,
|
|
} from './mixRatingFilter';
|
|
import {
|
|
MIX_TARGET_SIZE,
|
|
SEED_TARGET_SIZE,
|
|
sampleRandom,
|
|
uniqueBySongId,
|
|
uniqueAppend,
|
|
deriveTopArtistsFromFrequentAlbums,
|
|
fetchFrequentAlbumsPool,
|
|
pickSongsForArtist,
|
|
pickSongsForAlbum,
|
|
pickGoodRatedSongs,
|
|
} from './luckyMixHelpers';
|
|
|
|
/**
|
|
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
|
|
* flips to true. The `catch` handler swallows it silently (no toast, no
|
|
* queue restore, no error state) — the user already moved on.
|
|
*/
|
|
class LuckyMixCancelled extends Error {
|
|
constructor() {
|
|
super('lucky-mix-cancelled');
|
|
this.name = 'LuckyMixCancelled';
|
|
}
|
|
}
|
|
|
|
export async function buildAndPlayLuckyMix(): Promise<void> {
|
|
const lucky = useLuckyMixStore.getState();
|
|
if (lucky.isRolling) return;
|
|
const auth = useAuthStore.getState();
|
|
const debugEnabled = auth.loggingMode === 'debug';
|
|
const debugSteps: Array<{ step: string; details?: unknown }> = [];
|
|
const logStep = (step: string, details?: unknown) => {
|
|
if (!debugEnabled) return;
|
|
const payload = { step, details };
|
|
debugSteps.push(payload);
|
|
console.debug('[psysonic][lucky-mix]', payload);
|
|
void invoke('frontend_debug_log', {
|
|
scope: 'lucky-mix',
|
|
message: JSON.stringify(payload),
|
|
}).catch(() => {});
|
|
};
|
|
const songDebug = (songs: SubsonicSong[]) =>
|
|
songs.map(s => ({ id: s.id, title: s.title, artist: s.artist, rating: s.userRating ?? 0 }));
|
|
const albumDebug = (albums: SubsonicAlbum[]) =>
|
|
albums.map(a => ({ id: a.id, name: a.name, artist: a.artist, playCount: a.playCount ?? 0 }));
|
|
const activeServerId = auth.activeServerId;
|
|
const available = isLuckyMixAvailable({
|
|
activeServerId,
|
|
audiomuseByServer: auth.audiomuseNavidromeByServer,
|
|
showLuckyMixMenu: auth.showLuckyMixMenu,
|
|
});
|
|
const mixRatingCfg = getMixMinRatingsConfigFromAuth();
|
|
logStep('init', {
|
|
activeServerId,
|
|
available,
|
|
showLuckyMixMenu: auth.showLuckyMixMenu,
|
|
libraryFilter: activeServerId ? (auth.musicLibraryFilterByServer[activeServerId] ?? 'all') : 'all',
|
|
mixRatingFilter: mixRatingCfg,
|
|
crossServerPlayback: playbackServerDiffersFromActive(),
|
|
handoffQueueToActive: shouldHandoffQueueToActiveServer(),
|
|
});
|
|
if (!available) {
|
|
logStep('abort_unavailable');
|
|
showToast(i18n.t('luckyMix.unavailable'), 4000, 'warning');
|
|
return;
|
|
}
|
|
|
|
lucky.start();
|
|
|
|
// Snapshot the current queue *before* we prune — so if the build fails
|
|
// before we ever play a track, we can put it back the way it was instead
|
|
// of leaving the user with an empty player. Thin-state: snapshot the refs and
|
|
// the resolved tracks (to re-seed the resolver on restore).
|
|
const playerStateBefore = usePlayerStore.getState();
|
|
const queueSnapshot: {
|
|
queueItems: QueueItemRef[];
|
|
queueIndex: number;
|
|
queueServerId: string | null;
|
|
} = {
|
|
queueItems: [...playerStateBefore.queueItems],
|
|
queueIndex: playerStateBefore.queueIndex,
|
|
queueServerId: playerStateBefore.queueServerId,
|
|
};
|
|
|
|
// One undo step for the whole Lucky Mix run — internal prune/play/enqueue
|
|
// batches must not each push (QUEUE_UNDO_MAX would drop this snapshot).
|
|
pushQueueUndoFromGetter(() => usePlayerStore.getState());
|
|
|
|
let unsubPlayer: (() => void) | null = null;
|
|
try {
|
|
// Browsed server ≠ queue server: stop A's stream so Now Playing does not call
|
|
// ensurePlaybackServerActive() and revert the UI mid-build.
|
|
if (shouldHandoffQueueToActiveServer()) {
|
|
prepareActiveServerForNewMix();
|
|
logStep('cross_server_handoff', { activeServerId });
|
|
} else {
|
|
// Drop the old "upcoming" tail so the queue UI does not show stale next
|
|
// tracks while the mix is still building (first playTrack may be delayed).
|
|
usePlayerStore.getState().pruneUpcomingToCurrent(true);
|
|
}
|
|
let startedPlayback = false;
|
|
try {
|
|
let allSeedSongs: SubsonicSong[] = [];
|
|
|
|
const mixQueueSize = () => usePlayerStore.getState().queueItems.length;
|
|
const mixQueueTrackIds = () => new Set(usePlayerStore.getState().queueItems.map(r => r.trackId));
|
|
|
|
const bailIfCancelled = () => {
|
|
if (useLuckyMixStore.getState().cancelRequested) throw new LuckyMixCancelled();
|
|
};
|
|
const reachedTarget = () => mixQueueSize() >= MIX_TARGET_SIZE;
|
|
|
|
const startImmediatePlayback = async (song: SubsonicSong, source: string) => {
|
|
if (startedPlayback || !song?.id) return;
|
|
const allowed = await filterSongsForLuckyMixRatings([song], mixRatingCfg);
|
|
if (!allowed.length) return;
|
|
const play = allowed[0];
|
|
startedPlayback = true;
|
|
const track = songToTrack(play);
|
|
usePlayerStore.getState().playTrack(track, [track], false);
|
|
logStep('start_immediate_playback', {
|
|
source,
|
|
song: songDebug([play])[0],
|
|
queuedCount: mixQueueSize(),
|
|
});
|
|
|
|
// Auto-cancel: once we're playing, watch the player store. If the
|
|
// current track switches to something the user picked themselves (not
|
|
// in the mix queue), treat that as "user moved on" and cancel the build.
|
|
if (!unsubPlayer) {
|
|
unsubPlayer = usePlayerStore.subscribe((state, prev) => {
|
|
const prevId = prev.currentTrack?.id ?? null;
|
|
const nextId = state.currentTrack?.id ?? null;
|
|
if (nextId === prevId) return;
|
|
if (!nextId) return;
|
|
if (state.queueItems.some(r => r.trackId === nextId)) return;
|
|
useLuckyMixStore.getState().cancel();
|
|
});
|
|
}
|
|
};
|
|
|
|
const appendSongsToQueue = async (songs: SubsonicSong[], reason: string): Promise<number> => {
|
|
if (useLuckyMixStore.getState().cancelRequested) return 0;
|
|
if (reachedTarget()) return 0;
|
|
if (!songs.length) return 0;
|
|
const knownIds = mixQueueTrackIds();
|
|
const unique = uniqueBySongId(songs).filter(s => !knownIds.has(s.id));
|
|
const deduped = await filterSongsForLuckyMixRatings(unique, mixRatingCfg);
|
|
if (!deduped.length) return 0;
|
|
|
|
const candidates = [...deduped];
|
|
if (!startedPlayback && candidates.length > 0) {
|
|
const first = candidates.shift();
|
|
if (first) await startImmediatePlayback(first, reason);
|
|
}
|
|
|
|
if (!candidates.length) return 0;
|
|
const remaining = Math.max(0, MIX_TARGET_SIZE - mixQueueSize());
|
|
if (remaining <= 0) return 0;
|
|
const toAdd = sampleRandom(candidates, Math.min(remaining, candidates.length));
|
|
if (!toAdd.length) return 0;
|
|
const before = mixQueueSize();
|
|
bindQueueServerForPlayback();
|
|
usePlayerStore.getState().enqueue(toAdd.map(songToTrack), true, true);
|
|
const added = mixQueueSize() - before;
|
|
logStep('append_queue_batch', {
|
|
reason,
|
|
added,
|
|
queuedCount: mixQueueSize(),
|
|
songs: songDebug(toAdd),
|
|
});
|
|
return added;
|
|
};
|
|
|
|
const frequentAlbums = await fetchFrequentAlbumsPool();
|
|
bailIfCancelled();
|
|
const albumsWithPlays = frequentAlbums.filter(a => (a.playCount ?? 0) > 0);
|
|
logStep('fetch_frequent_albums', {
|
|
fetched: frequentAlbums.length,
|
|
withPlays: albumsWithPlays.length,
|
|
});
|
|
const topArtists = await filterTopArtistsForMixRatings(
|
|
deriveTopArtistsFromFrequentAlbums(albumsWithPlays),
|
|
mixRatingCfg,
|
|
);
|
|
const pickedArtists = sampleRandom(topArtists, 2);
|
|
logStep('pick_top_artists', {
|
|
topArtistsCount: topArtists.length,
|
|
pickedArtists,
|
|
});
|
|
|
|
for (const artist of pickedArtists) {
|
|
bailIfCancelled();
|
|
const songs = await pickSongsForArtist(artist, 3, mixRatingCfg);
|
|
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
|
const firstPlayable = songs[0];
|
|
if (firstPlayable) await startImmediatePlayback(firstPlayable, `artist:${artist.name}`);
|
|
logStep('pick_artist_songs', {
|
|
artist,
|
|
pickedCount: songs.length,
|
|
songs: songDebug(songs),
|
|
});
|
|
}
|
|
|
|
const pickedAlbums = sampleRandom(albumsWithPlays, 2);
|
|
logStep('pick_top_albums', {
|
|
poolCount: albumsWithPlays.length,
|
|
pickedAlbums: albumDebug(pickedAlbums),
|
|
});
|
|
for (const album of pickedAlbums) {
|
|
bailIfCancelled();
|
|
const songs = await pickSongsForAlbum(album.id, 3, mixRatingCfg);
|
|
allSeedSongs = uniqueAppend(allSeedSongs, songs);
|
|
const firstPlayable = songs[0];
|
|
if (firstPlayable) await startImmediatePlayback(firstPlayable, `album:${album.id}`);
|
|
logStep('pick_album_songs', {
|
|
albumId: album.id,
|
|
pickedCount: songs.length,
|
|
songs: songDebug(songs),
|
|
});
|
|
}
|
|
|
|
bailIfCancelled();
|
|
const rated = await pickGoodRatedSongs(new Set(allSeedSongs.map(s => s.id)), 3, mixRatingCfg);
|
|
logStep('pick_rated_songs_4plus_only', {
|
|
ratedPickedCount: rated.length,
|
|
ratedSongs: songDebug(rated),
|
|
});
|
|
allSeedSongs = uniqueAppend(allSeedSongs, rated);
|
|
let seeds = await filterSongsForLuckyMixRatings(allSeedSongs, mixRatingCfg);
|
|
logStep('seed_after_dedup', {
|
|
seedCount: seeds.length,
|
|
seeds: songDebug(seeds),
|
|
});
|
|
|
|
if (seeds.length < SEED_TARGET_SIZE) {
|
|
logStep('seed_fill_start', { target: SEED_TARGET_SIZE, before: seeds.length });
|
|
for (let i = 0; i < 10 && seeds.length < SEED_TARGET_SIZE; i++) {
|
|
bailIfCancelled();
|
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(80));
|
|
const allowedRnd = await filterSongsForLuckyMixRatings(rnd, mixRatingCfg);
|
|
seeds = uniqueAppend(seeds, allowedRnd);
|
|
const firstPlayable = allowedRnd[0];
|
|
if (firstPlayable) await startImmediatePlayback(firstPlayable, `seed-fill-batch:${i + 1}`);
|
|
logStep('seed_fill_batch', {
|
|
batch: i + 1,
|
|
fetched: rnd.length,
|
|
seedCount: seeds.length,
|
|
});
|
|
}
|
|
seeds = seeds.slice(0, SEED_TARGET_SIZE);
|
|
logStep('seed_fill_end', {
|
|
finalSeedCount: seeds.length,
|
|
seeds: songDebug(seeds),
|
|
});
|
|
}
|
|
|
|
if (seeds.length === 0) {
|
|
throw new Error('no-seeds');
|
|
}
|
|
if (!startedPlayback) {
|
|
const firstPlayableSeed = seeds[0];
|
|
if (firstPlayableSeed) await startImmediatePlayback(firstPlayableSeed, 'seed-fallback-first');
|
|
}
|
|
|
|
let similarRaw: SubsonicSong[] = [];
|
|
let similar: SubsonicSong[] = [];
|
|
for (let i = 0; i < seeds.length; i++) {
|
|
bailIfCancelled();
|
|
const seed = seeds[i];
|
|
const oneRaw = await fetchSimilarTracksRouted(seed.id, 60).catch(() => [] as SubsonicSong[]);
|
|
const oneScoped = await filterSongsToActiveLibrary(oneRaw);
|
|
similarRaw = uniqueAppend(similarRaw, oneRaw);
|
|
similar = uniqueAppend(similar, oneScoped);
|
|
await appendSongsToQueue(oneScoped, `similar-seed-${i + 1}/${seeds.length}`);
|
|
if (reachedTarget()) break;
|
|
}
|
|
const seedForPool = seeds.filter(() => Math.random() < 0.5);
|
|
let pool = uniqueBySongId([...seedForPool, ...similar]);
|
|
await appendSongsToQueue(seedForPool, 'seed-50pct');
|
|
logStep('instant_mix', {
|
|
seedUsedForInstantMixCount: seeds.length,
|
|
seedIncludedInPoolCount: seedForPool.length,
|
|
seedIncludedInPool: songDebug(seedForPool),
|
|
similarRawCount: similarRaw.length,
|
|
similarScopedCount: similar.length,
|
|
initialPoolCount: pool.length,
|
|
});
|
|
|
|
const poolFillMaxBatches = mixRatingCfg.enabled ? 25 : 10;
|
|
for (let i = 0; i < poolFillMaxBatches && !reachedTarget(); i++) {
|
|
bailIfCancelled();
|
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
|
pool = uniqueAppend(pool, rnd);
|
|
await appendSongsToQueue(rnd, `pool-fill-${i + 1}`);
|
|
logStep('pool_fill_batch', {
|
|
batch: i + 1,
|
|
fetched: rnd.length,
|
|
poolCount: pool.length,
|
|
queueCount: mixQueueSize(),
|
|
});
|
|
}
|
|
|
|
bailIfCancelled();
|
|
if (!reachedTarget()) {
|
|
const poolFiltered = await filterSongsForLuckyMixRatings(pool, mixRatingCfg);
|
|
const need = MIX_TARGET_SIZE - mixQueueSize();
|
|
const finalSongs = sampleRandom(
|
|
poolFiltered.filter(s => !mixQueueTrackIds().has(s.id)),
|
|
need,
|
|
);
|
|
await appendSongsToQueue(finalSongs, 'finalize-randomized');
|
|
}
|
|
|
|
for (let i = 0; i < 20 && !reachedTarget(); i++) {
|
|
bailIfCancelled();
|
|
const rnd = await filterSongsToActiveLibrary(await getRandomSongs(120));
|
|
const added = await appendSongsToQueue(rnd, `topup-${i + 1}`);
|
|
if (added === 0 && i >= 8) break;
|
|
}
|
|
|
|
const finalQueueCount = mixQueueSize();
|
|
logStep('final_queue_state', {
|
|
poolCount: pool.length,
|
|
queuedCount: finalQueueCount,
|
|
queuedTarget: MIX_TARGET_SIZE,
|
|
});
|
|
if (finalQueueCount === 0) {
|
|
throw new Error('empty-mix');
|
|
}
|
|
showToast(i18n.t('luckyMix.done', { count: finalQueueCount }), 3500, 'success');
|
|
logStep('done', { queueCount: finalQueueCount });
|
|
if (debugEnabled) {
|
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
|
void invoke('frontend_debug_log', {
|
|
scope: 'lucky-mix',
|
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
|
}).catch(() => {});
|
|
}
|
|
} catch (err) {
|
|
// Cancellation is a user-initiated path, not an error. Silent teardown.
|
|
if (err instanceof LuckyMixCancelled) {
|
|
logStep('cancelled');
|
|
if (debugEnabled) {
|
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
|
void invoke('frontend_debug_log', {
|
|
scope: 'lucky-mix',
|
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
|
}).catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
console.error('[psysonic] lucky mix failed:', err);
|
|
logStep('failed', { error: String(err) });
|
|
if (debugEnabled) {
|
|
console.debug('[psysonic][lucky-mix] full-steps', debugSteps);
|
|
void invoke('frontend_debug_log', {
|
|
scope: 'lucky-mix',
|
|
message: JSON.stringify({ step: 'full-steps', details: debugSteps }),
|
|
}).catch(() => {});
|
|
}
|
|
// If we failed before ever calling playTrack, the queue-prune we did up
|
|
// front left the user with nothing. Restore the snapshot so they land
|
|
// back where they were pre-click instead of in an empty player.
|
|
// If playback did start, leave it alone — their current track plus
|
|
// whatever we managed to enqueue is more useful than the old queue.
|
|
if (!startedPlayback) {
|
|
usePlayerStore.setState({
|
|
queueItems: queueSnapshot.queueItems,
|
|
queueIndex: queueSnapshot.queueIndex,
|
|
queueServerId: queueSnapshot.queueServerId,
|
|
});
|
|
logStep('queue_restored_after_failure', {
|
|
restoredCount: queueSnapshot.queueItems.length,
|
|
});
|
|
}
|
|
showToast(i18n.t('luckyMix.failed'), 5000, 'error');
|
|
}
|
|
} finally {
|
|
if (unsubPlayer) { try { unsubPlayer(); } catch { /* noop */ } }
|
|
useLuckyMixStore.getState().stop();
|
|
}
|
|
}
|