mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
d4ab56f5a6
Two lower-layer→feature inversions removed: - Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath) were defined in features/album's browse store but are pure URL checks. Extracted to lib/navigation/detailRoutePaths; the browse store re-exports them so the @/features/album barrel surface is unchanged. This frees albumDetailNavigation of its only @/features import, so it moves utils/navigation → lib/navigation (drains utils/navigation). Also fixes the M5 isArtistDetailPath misplacement. - mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the playback feature: utils/mix → features/playback/utils (drains utils/mix). The earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that is only a code comment, verified no runtime import. Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating them would rewrite a do-not-touch file.
157 lines
5.9 KiB
TypeScript
157 lines
5.9 KiB
TypeScript
import { join } from '@tauri-apps/api/path';
|
|
import { invoke } from '@tauri-apps/api/core';
|
|
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
|
|
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
|
|
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
|
import type { Track } from '@/lib/media/trackTypes';
|
|
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
|
import { useZipDownloadStore } from '@/features/offline';
|
|
import { useDownloadModalStore } from '@/features/offline';
|
|
import type { EntityShareKind } from '@/utils/share/shareLink';
|
|
import { copyEntityShareLink } from '@/utils/share/copyEntityShareLink';
|
|
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
|
|
import { songToTrack } from '@/lib/media/songToTrack';
|
|
import { showToast } from '@/lib/dom/toast';
|
|
|
|
export async function copyShareLink(
|
|
kind: EntityShareKind,
|
|
id: string,
|
|
t: (key: string) => string,
|
|
) {
|
|
const ok = await copyEntityShareLink(kind, id);
|
|
if (ok) showToast(t('contextMenu.shareCopied'));
|
|
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
|
|
}
|
|
|
|
export async function startRadio(
|
|
artistId: string,
|
|
artistName: string,
|
|
playTrack: (track: Track, queue: Track[]) => void,
|
|
seedTrack?: Track,
|
|
) {
|
|
if (seedTrack) {
|
|
const state = usePlayerStore.getState();
|
|
if (state.currentTrack?.id === seedTrack.id) {
|
|
if (!state.isPlaying) state.resume();
|
|
} else {
|
|
playTrack(seedTrack, [seedTrack]);
|
|
}
|
|
try {
|
|
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
|
|
const similarTracks = shuffleArray(
|
|
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
|
|
);
|
|
const radioTracks = similarTracks.length > 0
|
|
? similarTracks
|
|
: shuffleArray(
|
|
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
|
|
);
|
|
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
|
} catch (e) {
|
|
console.error('Failed to load radio queue', e);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Artist radio without seed
|
|
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
|
|
try {
|
|
const top = await getTopSongs(artistName);
|
|
const topTracks = shuffleArray(
|
|
top.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
|
|
);
|
|
if (topTracks.length === 0) {
|
|
const similar = await similarPromise;
|
|
const fallback = shuffleArray(
|
|
similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
|
|
);
|
|
if (fallback.length === 0) return;
|
|
const state = usePlayerStore.getState();
|
|
if (state.currentTrack) {
|
|
state.enqueueRadio(fallback, artistId);
|
|
} else {
|
|
state.setRadioArtistId(artistId);
|
|
playTrack(fallback[0], fallback);
|
|
}
|
|
return;
|
|
}
|
|
const state = usePlayerStore.getState();
|
|
if (state.currentTrack) {
|
|
state.enqueueRadio([topTracks[0]], artistId);
|
|
} else {
|
|
state.setRadioArtistId(artistId);
|
|
playTrack(topTracks[0], [topTracks[0]]);
|
|
}
|
|
similarPromise.then(similar => {
|
|
const similarTracks = shuffleArray(
|
|
similar
|
|
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
|
|
.filter(t => t.id !== topTracks[0].id),
|
|
);
|
|
if (similarTracks.length === 0) return;
|
|
const { queueItems, queueIndex } = usePlayerStore.getState();
|
|
// Thin-state: resolve the upcoming radio refs (cache-warm window) back to
|
|
// Tracks so they merge with the new similars in enqueueRadio.
|
|
const pendingRadio = queueItems
|
|
.slice(queueIndex + 1)
|
|
.filter(r => r.radioAdded)
|
|
.map(r => resolveQueueTrack(r));
|
|
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to start radio', e);
|
|
}
|
|
}
|
|
|
|
export async function startInstantMix(
|
|
song: Track,
|
|
t: (key: string) => string,
|
|
) {
|
|
usePlayerStore.getState().reseedQueueForInstantMix(song);
|
|
const serverId = useAuthStore.getState().activeServerId;
|
|
try {
|
|
const similar = await fetchSimilarTracksRouted(song.id, 50);
|
|
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
|
|
const mixCfg = getMixMinRatingsConfigFromAuth();
|
|
const ratedFiltered = await filterSongsForLuckyMixRatings(
|
|
similar.filter(s => s.id !== song.id),
|
|
mixCfg,
|
|
);
|
|
const shuffled = shuffleArray(
|
|
ratedFiltered.map(s => ({ ...songToTrack(s), radioAdded: true as const })),
|
|
);
|
|
if (shuffled.length > 0) {
|
|
const aid = song.artistId?.trim() || undefined;
|
|
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
|
}
|
|
} catch (e) {
|
|
console.error('Instant mix failed', e);
|
|
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
|
|
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
|
|
}
|
|
}
|
|
|
|
export async function downloadAlbum(albumName: string, albumId: string) {
|
|
const auth = useAuthStore.getState();
|
|
const requestDownloadFolder = useDownloadModalStore.getState().requestFolder;
|
|
const folder = auth.downloadFolder || await requestDownloadFolder();
|
|
if (!folder) return;
|
|
|
|
const filename = `${sanitizeFilename(albumName)}.zip`;
|
|
const destPath = await join(folder, filename);
|
|
const url = buildDownloadUrl(albumId);
|
|
const id = crypto.randomUUID();
|
|
|
|
const { start, complete, fail } = useZipDownloadStore.getState();
|
|
start(id, filename);
|
|
try {
|
|
await invoke('download_zip', { id, url, destPath });
|
|
complete(id);
|
|
} catch (e) {
|
|
fail(id);
|
|
console.error('ZIP download failed:', e);
|
|
}
|
|
}
|