mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(share): open Navidrome public share links for anonymous playback (#1275)
This commit is contained in:
@@ -101,6 +101,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
* New **Start Minimized to Tray** toggle under **Settings → System → Behavior**. When enabled, the next cold start keeps the main window hidden and Psysonic runs from the system tray until you show it from the tray icon.
|
||||
* Requires **Show Tray Icon** (turning this on enables the tray automatically; hiding the tray clears the setting). The choice applies on the next launch only — toggling it in Settings does not hide the window immediately.
|
||||
|
||||
### Navidrome public share links — open and play without logging in
|
||||
|
||||
**By [@cucadmuh](https://github.com/cucadmuh), PR [#1275](https://github.com/Psychotoxical/psysonic/pull/1275)**
|
||||
|
||||
* Paste or search a Navidrome **public share** URL (`/share/{id}`) to preview the shared track list in a modal, then play the full queue with no server account — direct stream and cover URLs are resolved anonymously from the share page.
|
||||
* Share playback uses a dedicated scope so an idle server play-queue pull cannot replace the share queue while you are also logged into Navidrome. Share sessions are not restored after an app restart — the server play queue applies as usual.
|
||||
|
||||
|
||||
## Changed
|
||||
|
||||
|
||||
@@ -196,6 +196,7 @@ const CONTRIBUTOR_ENTRIES = [
|
||||
'Library — prune orphaned identity keys from the multi-library dedup sidecar (library-cluster.db) on rebuild so it no longer bloats with rows for removed/renamed tracks (PR #1255)',
|
||||
'Servers — connect and run fully behind a custom-header gate (Cloudflare Access / Pangolin): native connect probe + REST proxy so browse/playback/covers pass, add-form failure reasons, live LAN/public badge on server switch, and LAN reclaim from a sticky public endpoint (PR #1273)',
|
||||
'Per-device EQ — when System Default is selected, profiles follow the active OS default output and switch on external output changes (PR #1274)',
|
||||
'Navidrome public share links — paste/search preview modal, anonymous queue playback, idle server queue-restore guard (PR #1275)',
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -61,13 +61,14 @@ export default function FullscreenPlayer({ onClose }: FullscreenPlayerProps) {
|
||||
const portraitCover = usePlaybackCoverArt(playbackCoverRef, 500);
|
||||
const coverUrl = portraitCover.src;
|
||||
const coverKey = portraitCover.cacheKey;
|
||||
// `false` = no fetchUrl fallback — prevents double crossfade (fetchUrl → blobUrl).
|
||||
const resolvedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
const directCover = currentTrack?.directCoverArtUrl;
|
||||
const cachedCoverUrl = useCachedUrl(coverUrl, coverKey, false);
|
||||
const resolvedCoverUrl = directCover ?? cachedCoverUrl;
|
||||
|
||||
// Dynamic accent color extracted from the current album cover, applied as
|
||||
// --dynamic-fs-accent on the root element. Cache hits return instantly so
|
||||
// same-album tracks reuse the color without re-fetching.
|
||||
const dynamicAccent = useFsDynamicAccent(artUrl, artKey);
|
||||
const dynamicAccent = useFsDynamicAccent(directCover ?? artUrl, artKey);
|
||||
|
||||
// Artist image → portrait on right. Resolved through the shared fullscreen
|
||||
// backdrop hook (banner / fanart.tv / Navidrome artist cover, in the user's
|
||||
|
||||
@@ -84,7 +84,7 @@ export default function FullscreenPlayerPrism({ onClose }: { onClose: () => void
|
||||
const albumRef =
|
||||
useAlbumCoverRef(currentTrack?.albumId, undefined, undefined, { libraryResolve: false }) ?? undefined;
|
||||
const cover = usePlaybackCoverArt(albumRef, 300);
|
||||
const dynamicAccent = useFsDynamicAccent(cover.src, cover.cacheKey);
|
||||
const dynamicAccent = useFsDynamicAccent(currentTrack?.directCoverArtUrl ?? cover.src, cover.cacheKey);
|
||||
|
||||
const [lyricsOpen, setLyricsOpen] = useState(true);
|
||||
const [queueOpen, setQueueOpen] = useState(false);
|
||||
|
||||
@@ -89,9 +89,8 @@ export default function FullscreenPlayerStatic({ onClose }: Props) {
|
||||
// thumbnail — crisp instead of the old low-res tier. It is no longer a
|
||||
// background source (see below).
|
||||
const cover = usePlaybackCoverArt(playbackCoverRef, 2000, { fullRes: true });
|
||||
// `true` = show the raw URL immediately while the blob resolves (same as FsArt).
|
||||
const coverUrl = useCachedUrl(cover.src, cover.cacheKey, true);
|
||||
const thumbUrl = coverUrl;
|
||||
const thumbUrl = currentTrack?.directCoverArtUrl ?? coverUrl;
|
||||
// Background (§28). The album cover is deliberately NOT a background source —
|
||||
// it only ever feeds the foreground thumbnail. The user-configurable source
|
||||
// list (fanart / Navidrome artist image, no banner) drives the rest, resolved
|
||||
|
||||
@@ -26,6 +26,7 @@ interface HeroProps {
|
||||
networkLoveEnabled: boolean;
|
||||
activeLyricsTab: boolean;
|
||||
coverRef?: CoverArtRef;
|
||||
directCoverArtUrl?: string;
|
||||
onNavigate: (path: string) => void;
|
||||
onToggleStar: () => void;
|
||||
onToggleNetworkLove: () => void;
|
||||
@@ -46,7 +47,7 @@ function renderStars(rating?: number) {
|
||||
);
|
||||
}
|
||||
|
||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
|
||||
const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatingOverride, networkTrack, networkArtist, starred, networkLoved, networkLoveEnabled, activeLyricsTab, coverRef, directCoverArtUrl, onNavigate, onToggleStar, onToggleNetworkLove, onOpenLyrics }: HeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const networkLabel = useEnrichmentPrimaryLabel() ?? '';
|
||||
const networkIcon = useEnrichmentPrimaryIcon();
|
||||
@@ -57,7 +58,9 @@ const Hero = memo(function Hero({ track, artistRefs, genre, playCount, userRatin
|
||||
return (
|
||||
<div className="np-dash-hero">
|
||||
<div className="np-dash-hero-cover">
|
||||
{coverRef ? (
|
||||
{directCoverArtUrl ? (
|
||||
<img className="np-cover" src={directCoverArtUrl} alt="" />
|
||||
) : coverRef ? (
|
||||
<CoverArtImage
|
||||
className="np-cover"
|
||||
coverRef={coverRef}
|
||||
|
||||
@@ -244,9 +244,11 @@ export default function MobilePlayerView() {
|
||||
const playbackCoverRef = usePlaybackTrackCoverRef(currentTrack ?? undefined);
|
||||
const { src: coverFetchUrl, cacheKey: coverKey } = usePlaybackCoverArt(playbackCoverRef, 800);
|
||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
||||
const directCover = currentTrack?.directCoverArtUrl;
|
||||
const displayCover = directCover ?? resolvedCover;
|
||||
|
||||
// Dynamic background color extracted from cover art
|
||||
const accentColor = useAlbumAccentColor(resolvedCover);
|
||||
const accentColor = useAlbumAccentColor(displayCover);
|
||||
|
||||
// Star / favorite
|
||||
const isStarred = currentTrack
|
||||
@@ -361,8 +363,8 @@ export default function MobilePlayerView() {
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="mp-cover-wrap">
|
||||
{resolvedCover ? (
|
||||
<img src={resolvedCover} alt="" className="mp-cover" />
|
||||
{displayCover ? (
|
||||
<img src={displayCover} alt="" className="mp-cover" />
|
||||
) : (
|
||||
<div className="mp-cover mp-cover-fallback">
|
||||
<Music size={64} />
|
||||
|
||||
@@ -284,6 +284,7 @@ export default function NowPlaying() {
|
||||
networkLoveEnabled={networkLoveEnabled}
|
||||
activeLyricsTab={activeTab === 'lyrics' && isQueueVisible}
|
||||
coverRef={playbackCoverRef}
|
||||
directCoverArtUrl={currentTrack.directCoverArtUrl}
|
||||
onNavigate={stableNavigate}
|
||||
onToggleStar={toggleStar}
|
||||
onToggleNetworkLove={toggleNetworkLove}
|
||||
|
||||
@@ -69,6 +69,7 @@ export function PlayerTrackInfo({
|
||||
showPreviewMeta ? { libraryResolve: false } : undefined,
|
||||
);
|
||||
const activeCoverRef = showPreviewMeta ? previewCoverRef : playbackCoverRef;
|
||||
const directCoverUrl = !isRadio && !showPreviewMeta ? currentTrack?.directCoverArtUrl : undefined;
|
||||
const layoutItems = usePlayerBarLayoutStore(s => s.items);
|
||||
const isLayoutVisible = (id: PlayerBarLayoutItemId) =>
|
||||
layoutItems.find(i => i.id === id)?.visible !== false;
|
||||
@@ -96,6 +97,12 @@ export function PlayerTrackInfo({
|
||||
<Cast size={20} />
|
||||
</div>
|
||||
)
|
||||
) : !isRadio && directCoverUrl ? (
|
||||
<img
|
||||
className="player-album-art"
|
||||
src={directCoverUrl}
|
||||
alt={currentTrack?.album ? `${currentTrack.album} Cover` : ''}
|
||||
/>
|
||||
) : !isRadio && activeCoverRef ? (
|
||||
<CoverArtImage
|
||||
className="player-album-art"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Kept intentionally small — extend as other features need a symbol.
|
||||
*/
|
||||
export { usePlayerStore } from './store/playerStore';
|
||||
export { seedQueueResolver } from './store/queueTrackResolver';
|
||||
export { queueSongStar } from './store/pendingStarSync';
|
||||
export { getPlaybackProgressSnapshot, subscribePlaybackProgress } from './store/playbackProgress';
|
||||
export type { PlaybackProgressSnapshot } from './store/playbackProgress';
|
||||
|
||||
@@ -7,9 +7,13 @@ vi.mock('@/lib/api/subsonicPlayQueue', () => ({
|
||||
getPlayQueueForServer: (...args: unknown[]) => getPlayQueueForServerMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/server/serverLookup', () => ({
|
||||
resolveServerIdForIndexKey: (id: string) => id,
|
||||
}));
|
||||
vi.mock('@/lib/server/serverLookup', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/server/serverLookup')>();
|
||||
return {
|
||||
...actual,
|
||||
resolveServerIdForIndexKey: (id: string) => id,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/media/songToTrack', () => ({
|
||||
songToTrack: (s: { id: string }) => ({
|
||||
@@ -36,6 +40,7 @@ vi.mock('@/features/playback/store/queueSyncUiState', () => ({
|
||||
}));
|
||||
|
||||
const playerState = {
|
||||
queueServerId: null as string | null,
|
||||
queueItems: [] as QueueItemRef[],
|
||||
queueIndex: 0,
|
||||
currentTrack: null as { id: string; title: string; artist: string; album: string; albumId: string; duration: number } | null,
|
||||
@@ -65,6 +70,7 @@ describe('applyServerPlayQueue idle guards', () => {
|
||||
beforeEach(() => {
|
||||
_resetQueuePlaybackIdleForTest();
|
||||
getPlayQueueForServerMock.mockReset();
|
||||
playerState.queueServerId = null;
|
||||
playerState.queueItems = [{ serverId: 'srv-a', trackId: 'local-only' }];
|
||||
playerState.queueIndex = 0;
|
||||
playerState.currentTrack = {
|
||||
@@ -129,4 +135,44 @@ describe('applyServerPlayQueue idle guards', () => {
|
||||
expect(result).toBe('noop');
|
||||
expect(playerState.queueItems).toEqual([{ serverId: 'srv-a', trackId: 'local-only' }]);
|
||||
});
|
||||
|
||||
it('does not overwrite an active Navidrome public share queue in idle mode', async () => {
|
||||
playerState.queueServerId = 'navidrome-public-share';
|
||||
playerState.queueItems = [{
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:abc:0',
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||
}];
|
||||
getPlayQueueForServerMock.mockResolvedValue({
|
||||
songs: [{ id: 'remote-a' }],
|
||||
current: 'remote-a',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const result = await applyServerPlayQueue('srv-a', { mode: 'idle' });
|
||||
|
||||
expect(result).toBe('noop');
|
||||
expect(getPlayQueueForServerMock).not.toHaveBeenCalled();
|
||||
expect(playerState.queueItems[0]?.trackId).toBe('ndshare:abc:0');
|
||||
});
|
||||
|
||||
it('applies server queue on startup even when share refs are still in memory', async () => {
|
||||
playerState.queueServerId = 'navidrome-public-share';
|
||||
playerState.queueItems = [{
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:abc:0',
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||
}];
|
||||
getPlayQueueForServerMock.mockResolvedValue({
|
||||
songs: [{ id: 'remote-a' }],
|
||||
current: 'remote-a',
|
||||
position: 0,
|
||||
});
|
||||
|
||||
const result = await applyServerPlayQueue('srv-a', { mode: 'startup' });
|
||||
|
||||
expect(result).toBe('applied');
|
||||
expect(getPlayQueueForServerMock).toHaveBeenCalledWith('srv-a');
|
||||
expect(playerState.queueItems[0]?.trackId).toBe('remote-a');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { Track } from '@/lib/media/trackTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { preparePausedRestoreOnStartup } from '@/features/playback/store/pausedRestorePrepare';
|
||||
import { isActivePublicShareQueue } from '@/lib/share/navidromePublicSharePlayback';
|
||||
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import {
|
||||
@@ -133,6 +134,11 @@ export async function applyServerPlayQueue(
|
||||
const profileId = resolveServerProfileId(serverId);
|
||||
if (!profileId) return 'error';
|
||||
|
||||
const local = usePlayerStore.getState();
|
||||
if (options.mode !== 'startup' && isActivePublicShareQueue(local.queueServerId, local.queueItems)) {
|
||||
return 'noop';
|
||||
}
|
||||
|
||||
if (options.mode === 'idle' && (isIdleQueuePullSuspended() || isQueuePushFailed())) {
|
||||
return 'noop';
|
||||
}
|
||||
@@ -142,6 +148,14 @@ export async function applyServerPlayQueue(
|
||||
const q = await getPlayQueueForServer(profileId);
|
||||
if (q.songs.length === 0) return 'empty';
|
||||
|
||||
const localAfterFetch = usePlayerStore.getState();
|
||||
if (
|
||||
options.mode !== 'startup'
|
||||
&& isActivePublicShareQueue(localAfterFetch.queueServerId, localAfterFetch.queueItems)
|
||||
) {
|
||||
return 'noop';
|
||||
}
|
||||
|
||||
const preferServerPosition = options.preferServerPosition ?? options.mode !== 'startup';
|
||||
if (options.mode === 'idle') {
|
||||
if (isIdleQueuePullSuspended() || isQueuePushFailed()) return 'noop';
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
playbackProfileIdForRef,
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { requestGaplessChainPreload } from '@/features/playback/store/gaplessChainPreload';
|
||||
import {
|
||||
applyGaplessQueueAdvance,
|
||||
@@ -392,7 +392,7 @@ export function handleAudioProgress(
|
||||
const analysisServerId = nextRef
|
||||
? playbackCacheKeyForRef(nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
const nextUrl = resolvePlaybackUrlForTrack(nextTrack, serverId);
|
||||
|
||||
// Byte pre-download — gapless backup; runs early so bytes are ready by chain time.
|
||||
if (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { autodjMaxOverlapCapSec } from '@/lib/audio/autodjOverlapCap';
|
||||
import { computeWaveformSilence, planCrossfadeTransition } from '@/lib/waveform/waveformSilence';
|
||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||
import { playbackCacheKeyForRef } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
@@ -71,7 +71,7 @@ export function kickEagerCrossfadePreload(
|
||||
if (isCrossfadeNextReady(track.id, profileId, cacheKey)) return;
|
||||
if (track.id === getBytePreloadingId()) return;
|
||||
const serverId = cacheKey || profileId;
|
||||
const url = resolvePlaybackUrl(track.id, serverId ?? undefined);
|
||||
const url = resolvePlaybackUrlForTrack(track, serverId ?? undefined);
|
||||
setBytePreloadingId(track.id);
|
||||
void refreshLoudnessForTrack(track.id, { syncPlayingEngine: false });
|
||||
audioPreload({
|
||||
@@ -148,7 +148,7 @@ export function maybeCrossfadeBytePreload(currentTime: number, dur: number): voi
|
||||
if (!nextTrack || nextTrack.id === track.id) return;
|
||||
|
||||
const serverId = playbackCacheKeyForRef(nextRef);
|
||||
const nextUrl = resolvePlaybackUrl(nextTrack.id, serverId);
|
||||
const nextUrl = resolvePlaybackUrlForTrack(nextTrack, serverId);
|
||||
|
||||
// Byte pre-download — skipped when the hot cache is on (it already keeps the
|
||||
// upcoming queue on disk, which is also why hot cache makes the trim reliable:
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -40,7 +40,7 @@ export function engineLoadTrackAtPosition(opts: {
|
||||
const replayGainPeak = isReplayGainActive() ? (track.replayGainPeak ?? null) : null;
|
||||
const playbackCacheSid = playbackCacheKeyForTrack(track);
|
||||
const playbackIndexKey = playbackCacheKeyForTrack(track) || getPlaybackIndexKey();
|
||||
const url = resolvePlaybackUrl(track.id, playbackCacheSid);
|
||||
const url = resolvePlaybackUrlForTrack(track, playbackCacheSid);
|
||||
recordEnginePlayUrl(track.id, url);
|
||||
usePlayerStore.setState({
|
||||
currentPlaybackSource: playbackSourceHintForResolvedUrl(track.id, playbackCacheSid, url),
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getPlaybackIndexKey,
|
||||
playbackCacheKeyForRef,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import {
|
||||
getGaplessPreloadingId,
|
||||
@@ -53,7 +53,7 @@ function invokeGaplessChainPreload(
|
||||
const analysisServerId = ctx.nextRef
|
||||
? playbackCacheKeyForRef(ctx.nextRef)
|
||||
: getPlaybackIndexKey();
|
||||
const nextUrl = resolvePlaybackUrl(prepared.id, serverId);
|
||||
const nextUrl = resolvePlaybackUrlForTrack(prepared, serverId);
|
||||
const nextNeighbour = gaplessNextNeighbour(ctx);
|
||||
const replayGainDb = resolveReplayGainDb(
|
||||
prepared, ctx.currentTrack, nextNeighbour,
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
playbackCacheKeyForRef,
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { refreshWaveformForTrack } from '@/features/playback/store/waveformRefresh';
|
||||
import { syncQueueToServer } from '@/features/playback/store/queueSync';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -86,7 +86,7 @@ function applyGaplessSuccessorUi(
|
||||
): void {
|
||||
const switchRef = store.queueItems[newIndex];
|
||||
const switchServerId = playbackCacheKeyForRef(switchRef);
|
||||
const switchResolvedUrl = resolvePlaybackUrl(nextTrack.id, switchServerId);
|
||||
const switchResolvedUrl = resolvePlaybackUrlForTrack(nextTrack, switchServerId);
|
||||
const switchPlaybackSource = playbackSourceHintForResolvedUrl(
|
||||
nextTrack.id,
|
||||
switchServerId,
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
findLocalPlaybackUrl,
|
||||
hasLocalPersistentPlaybackBytes,
|
||||
} from '@/store/localPlaybackResolve';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { enrichTrackPlaybackMetadata } from '@/features/playback/utils/audio/enrichTrackReplayGainMetadata';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
@@ -63,7 +63,7 @@ import type { Track } from '@/lib/media/trackTypes';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { getQueueTracksView, resolveQueueTrack } from '@/features/playback/store/queueTrackView';
|
||||
import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver';
|
||||
import { getCachedTrack, seedQueueResolver, mergeDirectShareUrls } from '@/features/playback/store/queueTrackResolver';
|
||||
import { promoteCompletedStreamToHotCache } from '@/features/playback/store/promoteStreamCache';
|
||||
import { pushQueueOnPlaybackStart } from '@/features/playback/store/queueSync';
|
||||
import { playListenSessionFinalize } from '@/features/playback/store/playListenSession';
|
||||
@@ -201,7 +201,27 @@ export function runPlayTrack(
|
||||
);
|
||||
}
|
||||
|
||||
const scopedTrack = scopedTrackEarly;
|
||||
const stateBeforePlay = get();
|
||||
const replacingEarly = queue !== undefined;
|
||||
const srcLenEarly = replacingEarly ? (queue?.length ?? 0) : stateBeforePlay.queueItems.length;
|
||||
const playIdxEarly = (() => {
|
||||
if (typeof targetQueueIndex === 'number' && targetQueueIndex >= 0 && targetQueueIndex < srcLenEarly) {
|
||||
return targetQueueIndex;
|
||||
}
|
||||
if (replacingEarly && queue) {
|
||||
const i = queue.findIndex(t => sameQueueTrackId(t.id, scopedTrackEarly.id));
|
||||
return i >= 0 ? i : 0;
|
||||
}
|
||||
const i = stateBeforePlay.queueItems.findIndex(r => sameQueueTrackId(r.trackId, scopedTrackEarly.id));
|
||||
return i >= 0 ? i : 0;
|
||||
})();
|
||||
const playingRefEarly = replacingEarly ? undefined : stateBeforePlay.queueItems[playIdxEarly];
|
||||
const scopedTrack = playingRefEarly
|
||||
? mergeDirectShareUrls(
|
||||
(!replacingEarly ? getCachedTrack(playingRefEarly) : undefined) ?? scopedTrackEarly,
|
||||
playingRefEarly,
|
||||
)
|
||||
: scopedTrackEarly;
|
||||
const scopedQueue = queue ? stampTrackServerIds(queue) : queue;
|
||||
|
||||
clearAllPlaybackScheduleTimers();
|
||||
@@ -335,7 +355,7 @@ export function runPlayTrack(
|
||||
const url = libraryLocalUrl
|
||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'library')
|
||||
?? findLocalPlaybackUrl(trackForPlay.id, playbackSid, 'favorite-auto')
|
||||
?? resolvePlaybackUrl(trackForPlay.id, playbackCacheSid);
|
||||
?? resolvePlaybackUrlForTrack(trackForPlay, playbackCacheSid);
|
||||
recordEnginePlayUrl(trackForPlay.id, url);
|
||||
const preloadedTrackId = get().enginePreloadedTrackId;
|
||||
const keepPreloadHint = preloadedTrackId === trackForPlay.id;
|
||||
@@ -361,10 +381,15 @@ export function runPlayTrack(
|
||||
// resolver with those tracks so the UI / hot paths resolve them without a
|
||||
// network round-trip. No-arg jumps reuse already-cached refs.
|
||||
if (scopedQueue) {
|
||||
const bySid = new Map<string, Track[]>();
|
||||
for (const t of scopedQueue) {
|
||||
const sid = playbackCacheKeyForTrack(t);
|
||||
if (sid) seedQueueResolver(sid, [t]);
|
||||
if (!sid) continue;
|
||||
const bucket = bySid.get(sid);
|
||||
if (bucket) bucket.push(t);
|
||||
else bySid.set(sid, [t]);
|
||||
}
|
||||
for (const [sid, tracks] of bySid) seedQueueResolver(sid, tracks);
|
||||
} else if (queueSid) {
|
||||
seedQueueResolver(queueSid, [trackForPlay]);
|
||||
}
|
||||
|
||||
@@ -305,4 +305,24 @@ describe('merge: restores the queue from any old persisted blob', () => {
|
||||
expect(merged.queueItems).toEqual([]);
|
||||
expect(merged.queueItemsIndex).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops persisted Navidrome public share queues on rehydrate', () => {
|
||||
const merged = getMerge()(
|
||||
{
|
||||
queueServerId: 'navidrome-public-share',
|
||||
queueItems: [{
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:abc:0',
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||
}],
|
||||
queueItemsIndex: 0,
|
||||
currentTrack: makeTrack({ id: 'ndshare:abc:0', serverId: 'navidrome-public-share' }),
|
||||
},
|
||||
current(),
|
||||
);
|
||||
expect(merged.queueItems).toEqual([]);
|
||||
expect(merged.queueItemsIndex).toBeUndefined();
|
||||
expect(merged.currentTrack).toBeNull();
|
||||
expect(merged.queueServerId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import type { PlayerState } from '@/features/playback/store/playerStoreTypes';
|
||||
import { toQueueItemRefs } from '@/features/playback/store/queueItemRef';
|
||||
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
||||
import {
|
||||
isActivePublicShareQueue,
|
||||
isPublicSharePersistedTrack,
|
||||
NAVIDROME_PUBLIC_SHARE_SERVER_ID,
|
||||
} from '@/lib/share/navidromePublicSharePlayback';
|
||||
import { readInitialQueueVisibility } from '@/features/playback/store/queueVisibilityStorage';
|
||||
import { createNetworkLoveActions } from '@/features/playback/store/networkLoveActions';
|
||||
import { createMiscActions } from '@/features/playback/store/miscActions';
|
||||
@@ -152,6 +157,27 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
queueItemsIndex = typeof blob.queueIndex === 'number' ? blob.queueIndex : 0;
|
||||
}
|
||||
|
||||
const persistedTrack = blob.currentTrack as Track | null | undefined;
|
||||
let strippedPublicShare = false;
|
||||
if (queueItems?.length && isActivePublicShareQueue(canonicalSid, queueItems)) {
|
||||
strippedPublicShare = true;
|
||||
queueItems = undefined;
|
||||
queueItemsIndex = undefined;
|
||||
delete blob.queueItems;
|
||||
delete blob.queueItemsIndex;
|
||||
delete blob.queueRefs;
|
||||
delete blob.queueRefsIndex;
|
||||
delete blob.queueIndex;
|
||||
}
|
||||
if (isPublicSharePersistedTrack(persistedTrack)) {
|
||||
delete blob.currentTrack;
|
||||
}
|
||||
if (strippedPublicShare || canonicalSid === NAVIDROME_PUBLIC_SHARE_SERVER_ID) {
|
||||
blob.queueServerId = null;
|
||||
} else if (canonicalSid !== null) {
|
||||
blob.queueServerId = canonicalSid;
|
||||
}
|
||||
|
||||
// Drop the obsolete windowed fat-array key — `queueItems` is canonical.
|
||||
delete blob.queue;
|
||||
// volume/repeatMode are owned by `psysonic_player_prefs`; strip any legacy
|
||||
@@ -161,11 +187,6 @@ export const usePlayerStore = create<PlayerState>()(
|
||||
delete blob.isQueueVisible;
|
||||
delete blob.lastfmLovedCache;
|
||||
delete blob.networkLovedCache;
|
||||
// Persist the canonical form back onto the merged blob so subsequent
|
||||
// reads of state.queueServerId always see the index key.
|
||||
if (canonicalSid !== null) {
|
||||
blob.queueServerId = canonicalSid;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
|
||||
@@ -13,4 +13,20 @@ describe('toQueueItemRefs', () => {
|
||||
expect(refs[0].trackId).toBe('t1');
|
||||
expect(refs[1].trackId).toBe('t2');
|
||||
});
|
||||
|
||||
it('persists Navidrome public share direct URLs on refs', () => {
|
||||
const queue: Track[] = [{
|
||||
id: 'ndshare:Ab12:0',
|
||||
title: 'A',
|
||||
artist: '',
|
||||
album: '',
|
||||
albumId: '',
|
||||
duration: 1,
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||
directCoverArtUrl: 'https://music.example.com/share/img/jwt-a?size=300',
|
||||
}];
|
||||
const refs = toQueueItemRefs('navidrome-public-share', queue);
|
||||
expect(refs[0]?.directStreamUrl).toBe('https://music.example.com/share/s/jwt-a');
|
||||
expect(refs[0]?.directCoverArtUrl).toBe('https://music.example.com/share/img/jwt-a?size=300');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ export function toQueueItemRefs(serverId: string, queue: Track[]): QueueItemRef[
|
||||
if (t.autoAdded) ref.autoAdded = true;
|
||||
if (t.radioAdded) ref.radioAdded = true;
|
||||
if (t.playNextAdded) ref.playNextAdded = true;
|
||||
if (t.directStreamUrl) ref.directStreamUrl = t.directStreamUrl;
|
||||
if (t.directCoverArtUrl) ref.directCoverArtUrl = t.directCoverArtUrl;
|
||||
return ref;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID } from '@/lib/share/navidromePublicSharePlayback';
|
||||
import { savePlayQueue } from '@/lib/api/subsonicPlayQueue';
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import { isSubsonicServerReachable } from '@/lib/network/subsonicNetworkGuard';
|
||||
@@ -47,7 +48,8 @@ let lastQueueHeartbeatAt = 0;
|
||||
|
||||
function isPlaybackServerReachable(): boolean {
|
||||
const serverId = getPlaybackServerId();
|
||||
return serverId ? isSubsonicServerReachable(serverId) : false;
|
||||
if (!serverId || serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) return false;
|
||||
return isSubsonicServerReachable(serverId);
|
||||
}
|
||||
|
||||
/** @returns true when the server accepted the queue (or there was nothing to push). */
|
||||
|
||||
@@ -100,6 +100,50 @@ describe('queueTrackResolver', () => {
|
||||
expect(getCachedTrack(ref('t1'))?.title).toBe('Seeded');
|
||||
});
|
||||
|
||||
it('seedQueueResolver keeps an entire large replacement queue in cache', () => {
|
||||
const tracks = Array.from({ length: 555 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
title: `Track ${i}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: '',
|
||||
duration: 200,
|
||||
}));
|
||||
seedQueueResolver('navidrome-public-share', tracks);
|
||||
expect(getCachedTrack(ref('bulk-0', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 0');
|
||||
expect(getCachedTrack(ref('bulk-554', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 554');
|
||||
});
|
||||
|
||||
it('resolveBatch builds public share tracks from ref directStreamUrl', async () => {
|
||||
await resolveBatch([{
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:Ab12:0',
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-a',
|
||||
directCoverArtUrl: 'https://music.example.com/share/img/jwt-a?size=300',
|
||||
}]);
|
||||
expect(getCachedTrack({
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:Ab12:0',
|
||||
})?.directStreamUrl).toBe('https://music.example.com/share/s/jwt-a');
|
||||
});
|
||||
|
||||
it('single-track seed does not evict a bulk-seeded public share queue', () => {
|
||||
const tracks = Array.from({ length: 555 }, (_, i) => ({
|
||||
id: `bulk-${i}`,
|
||||
title: `Track ${i}`,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
albumId: '',
|
||||
duration: 200,
|
||||
directStreamUrl: `https://music.example.com/share/s/token-${i}`,
|
||||
}));
|
||||
seedQueueResolver('navidrome-public-share', tracks);
|
||||
seedQueueResolver('navidrome-public-share', [tracks[10]!]);
|
||||
expect(getCachedTrack(ref('bulk-0', { serverId: 'navidrome-public-share' }))?.title).toBe('Track 0');
|
||||
expect(getCachedTrack(ref('bulk-100', { serverId: 'navidrome-public-share' }))?.directStreamUrl)
|
||||
.toBe('https://music.example.com/share/s/token-100');
|
||||
});
|
||||
|
||||
it('invalidateQueueResolver drops the cached entry', async () => {
|
||||
ready();
|
||||
echoBatch();
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||
import { canonicalQueueServerKey } from '@/lib/server/serverIndexKey';
|
||||
import { trackToSong } from '@/lib/library/advancedSearchLocal';
|
||||
import { libraryIsReady } from '@/lib/library/libraryReady';
|
||||
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID } from '@/lib/share/navidromePublicSharePlayback';
|
||||
|
||||
/**
|
||||
* Queue track resolver (thin-state phase 2). Resolves `QueueItemRef`s to full
|
||||
@@ -50,9 +51,10 @@ export function subscribeQueueResolver(cb: () => void): () => void {
|
||||
return () => { listeners.delete(cb); };
|
||||
}
|
||||
|
||||
function cacheSet(key: string, track: Track): void {
|
||||
function cacheSet(key: string, track: Track, opts?: { skipCap?: boolean }): void {
|
||||
if (cache.has(key)) cache.delete(key);
|
||||
cache.set(key, track);
|
||||
if (opts?.skipCap) return;
|
||||
while (cache.size > CACHE_CAP) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
@@ -60,6 +62,13 @@ function cacheSet(key: string, track: Track): void {
|
||||
}
|
||||
}
|
||||
|
||||
function trimCacheExcept(preserve: Set<string>): void {
|
||||
for (const key of [...cache.keys()]) {
|
||||
if (cache.size <= CACHE_CAP) return;
|
||||
if (!preserve.has(key)) cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function carryFlags(track: Track, ref: QueueItemRef | undefined): Track {
|
||||
if (ref?.autoAdded) track.autoAdded = true;
|
||||
if (ref?.radioAdded) track.radioAdded = true;
|
||||
@@ -97,6 +106,23 @@ export function placeholderTrack(ref: QueueItemRef): Track {
|
||||
autoAdded: ref.autoAdded,
|
||||
radioAdded: ref.radioAdded,
|
||||
playNextAdded: ref.playNextAdded,
|
||||
directStreamUrl: ref.directStreamUrl,
|
||||
directCoverArtUrl: ref.directCoverArtUrl,
|
||||
serverId: ref.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeDirectShareUrls(track: Track, ref: QueueItemRef): Track {
|
||||
if (!ref.directStreamUrl && !ref.directCoverArtUrl) return track;
|
||||
if (track.directStreamUrl === ref.directStreamUrl
|
||||
&& track.directCoverArtUrl === ref.directCoverArtUrl) {
|
||||
return track;
|
||||
}
|
||||
return {
|
||||
...track,
|
||||
directStreamUrl: track.directStreamUrl ?? ref.directStreamUrl,
|
||||
directCoverArtUrl: track.directCoverArtUrl ?? ref.directCoverArtUrl,
|
||||
serverId: track.serverId ?? ref.serverId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -120,7 +146,15 @@ export function applyQueueOverrides(track: Track): Track {
|
||||
export function seedQueueResolver(serverId: string, tracks: Track[]): void {
|
||||
if (tracks.length === 0) return;
|
||||
const canonicalId = canonicalQueueServerKey(serverId);
|
||||
for (const t of tracks) cacheSet(refKey({ serverId: canonicalId, trackId: t.id }), t);
|
||||
const preserve = new Set<string>();
|
||||
for (const t of tracks) {
|
||||
const key = refKey({ serverId: canonicalId, trackId: t.id });
|
||||
preserve.add(key);
|
||||
cacheSet(key, t, { skipCap: true });
|
||||
}
|
||||
// Bulk queue replace: keep the whole incoming set (may exceed CACHE_CAP).
|
||||
// Single-track touch (queue navigation): no eviction — must not wipe siblings.
|
||||
if (tracks.length > 1) trimCacheExcept(preserve);
|
||||
notify();
|
||||
}
|
||||
|
||||
@@ -148,6 +182,45 @@ export async function resolveBatch(refs: QueueItemRef[]): Promise<void> {
|
||||
|
||||
for (const [serverId, serverRefs] of byServer) {
|
||||
if (!serverId) continue;
|
||||
|
||||
if (serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) {
|
||||
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
||||
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
||||
|
||||
if (await libraryIsReady(serverId)) {
|
||||
for (let i = 0; i < serverRefs.length; i += BATCH) {
|
||||
const chunk: TrackRefDto[] = serverRefs
|
||||
.slice(i, i + BATCH)
|
||||
.map(r => ({ serverId, trackId: r.trackId }));
|
||||
try {
|
||||
const dtos = await libraryGetTracksBatch(chunk);
|
||||
for (const d of dtos) {
|
||||
const ref = refByTrack.get(d.id);
|
||||
const track = carryFlags(
|
||||
mergeDirectShareUrls(
|
||||
{ ...songToTrack(trackToSong(d)), serverId: d.serverId ?? serverId },
|
||||
ref ?? { serverId, trackId: d.id },
|
||||
),
|
||||
ref,
|
||||
);
|
||||
cacheSet(refKey({ serverId, trackId: d.id }), track, { skipCap: true });
|
||||
stillMissing.delete(d.id);
|
||||
changed = true;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
}
|
||||
|
||||
for (const trackId of stillMissing) {
|
||||
const ref = refByTrack.get(trackId);
|
||||
if (!ref?.directStreamUrl) continue;
|
||||
const track = carryFlags(mergeDirectShareUrls(placeholderTrack(ref), ref), ref);
|
||||
cacheSet(refKey({ serverId, trackId }), track, { skipCap: true });
|
||||
changed = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const stillMissing = new Set(serverRefs.map(r => r.trackId));
|
||||
const refByTrack = new Map(serverRefs.map(r => [r.trackId, r]));
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { QueueItemRef, Track } from '@/lib/media/trackTypes';
|
||||
import { getCachedTrack, placeholderTrack, applyQueueOverrides } from './queueTrackResolver';
|
||||
import { getCachedTrack, placeholderTrack, applyQueueOverrides, mergeDirectShareUrls } from './queueTrackResolver';
|
||||
|
||||
/**
|
||||
* Dual-write bridge (thin-state phase 4): rebuild the legacy `queue: Track[]`
|
||||
@@ -43,7 +43,10 @@ export function bridgeQueueFromItems(items: QueueItemRef[], pools: Track[][]): T
|
||||
* for exactly this reason; see the freeze fix in queueTrackResolver).
|
||||
*/
|
||||
export function resolveQueueTrack(ref: QueueItemRef, fallback?: Track): Track {
|
||||
const base = getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref);
|
||||
const base = mergeDirectShareUrls(
|
||||
getCachedTrack(ref) ?? fallback ?? placeholderTrack(ref),
|
||||
ref,
|
||||
);
|
||||
// Carry the ref's queue-only flags onto the resolved track without mutating the
|
||||
// cached object (a render-time mutation is what caused the earlier render loop).
|
||||
const needsFlags =
|
||||
|
||||
@@ -39,7 +39,12 @@ const hoisted = vi.hoisted(() => {
|
||||
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invokeMock }));
|
||||
vi.mock('@/lib/cache/hotCacheGate', () => ({ setDeferHotCachePrefetch: hoisted.setDeferHotCachePrefetchMock }));
|
||||
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({ resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock }));
|
||||
vi.mock('@/features/playback/utils/playback/resolvePlaybackUrl', () => ({
|
||||
resolvePlaybackUrl: hoisted.resolvePlaybackUrlMock,
|
||||
resolvePlaybackUrlForTrack: (
|
||||
track: { id: string; directStreamUrl?: string },
|
||||
) => track.directStreamUrl ?? hoisted.resolvePlaybackUrlMock(track.id),
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/audio/resolveReplayGainDb', () => ({ resolveReplayGainDb: hoisted.resolveReplayGainDbMock }));
|
||||
vi.mock('@/store/authStore', () => ({ useAuthStore: { getState: () => hoisted.auth } }));
|
||||
vi.mock('@/features/playback/store/engineState', () => ({
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
getPlaybackCacheServerKey,
|
||||
getPlaybackIndexKey,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { resolvePlaybackUrl } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolvePlaybackUrlForTrack } from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { resolveReplayGainDb } from '@/features/playback/utils/audio/resolveReplayGainDb';
|
||||
import { audioPlayHiResBlendArgs } from '@/lib/audio/hiResCrossfadeResample';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
@@ -176,7 +176,7 @@ export function runResume(set: SetState, get: GetState): void {
|
||||
);
|
||||
const replayGainPeakCold = isReplayGainActive() ? (trackToPlay.replayGainPeak ?? null) : null;
|
||||
setDeferHotCachePrefetch(true);
|
||||
const coldUrl = resolvePlaybackUrl(trackToPlay.id, coldServerId);
|
||||
const coldUrl = resolvePlaybackUrlForTrack(trackToPlay, coldServerId);
|
||||
set({ currentPlaybackSource: playbackSourceHintForResolvedUrl(trackToPlay.id, coldServerId, coldUrl) });
|
||||
recordEnginePlayUrl(trackToPlay.id, coldUrl);
|
||||
touchHotCacheOnPlayback(trackToPlay.id, coldServerId);
|
||||
|
||||
@@ -24,6 +24,7 @@ vi.mock('@/store/localPlaybackStore', () => ({
|
||||
import {
|
||||
getPlaybackSourceKind,
|
||||
resolvePlaybackUrl,
|
||||
resolvePlaybackUrlForTrack,
|
||||
streamUrlTrackId,
|
||||
} from '@/features/playback/utils/playback/resolvePlaybackUrl';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -89,6 +90,19 @@ describe('resolvePlaybackUrl — precedence', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvePlaybackUrlForTrack', () => {
|
||||
it('returns directStreamUrl when set on the track', () => {
|
||||
const url = resolvePlaybackUrlForTrack(
|
||||
{
|
||||
id: 'ndshare:abc:0',
|
||||
directStreamUrl: 'https://music.example.com/share/s/jwt-token',
|
||||
},
|
||||
'navidrome-public-share',
|
||||
);
|
||||
expect(url).toBe('https://music.example.com/share/s/jwt-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPlaybackSourceKind', () => {
|
||||
it('returns "offline" when the library tier has the track', () => {
|
||||
seedLibraryEntry('t1', 'srv-1', '/library/t1.flac');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
|
||||
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
|
||||
import { resolveServerIdForIndexKey } from '@/lib/server/serverLookup';
|
||||
import { getPlaybackCacheServerKey, getPlaybackServerId } from '@/features/playback/utils/playback/playbackServer';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
|
||||
/** Same resolution order as {@link resolvePlaybackUrl} — for UI hints only. */
|
||||
export type PlaybackSourceKind = 'offline' | 'hot' | 'stream';
|
||||
@@ -72,3 +73,12 @@ export function resolvePlaybackUrl(trackId: string, serverId?: string): string {
|
||||
if (hot) return hot;
|
||||
return buildStreamUrlForServer(profileId, trackId);
|
||||
}
|
||||
|
||||
/** Like {@link resolvePlaybackUrl} but honours {@link Track.directStreamUrl}. */
|
||||
export function resolvePlaybackUrlForTrack(
|
||||
track: Pick<Track, 'id' | 'directStreamUrl'>,
|
||||
serverId?: string,
|
||||
): string {
|
||||
if (track.directStreamUrl) return track.directStreamUrl;
|
||||
return resolvePlaybackUrl(track.id, serverId);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function QueueCurrentTrack({
|
||||
}: Props) {
|
||||
const showBufferingOverlay = usePlayerStore(s => s.isPlaybackBuffering);
|
||||
const coverRef = usePlaybackTrackCoverRef(currentTrack);
|
||||
const directCoverUrl = currentTrack?.directCoverArtUrl;
|
||||
const artistRefs = resolveTrackArtistRefs(currentTrack);
|
||||
const enrichment = useQueueTrackEnrichment(currentTrack.id);
|
||||
const bpmTech = formatQueueBpmTech(enrichment, t);
|
||||
@@ -206,7 +207,13 @@ export function QueueCurrentTrack({
|
||||
})()}
|
||||
<div className="queue-current-track-body">
|
||||
<div className={`queue-current-cover${showBufferingOverlay ? ' playback-buffering' : ''}`}>
|
||||
{coverRef ? (
|
||||
{directCoverUrl ? (
|
||||
<img
|
||||
className="queue-current-cover-img"
|
||||
src={directCoverUrl}
|
||||
alt=""
|
||||
/>
|
||||
) : coverRef ? (
|
||||
<CoverArtImage
|
||||
coverRef={coverRef}
|
||||
displayCssPx={128}
|
||||
|
||||
@@ -143,6 +143,7 @@ export default function LiveSearch() {
|
||||
id: 'share-link',
|
||||
action: () => {
|
||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||
else if (share.canPlayNavidromePublic) void share.playNavidromePublic();
|
||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||
@@ -167,6 +168,7 @@ export default function LiveSearch() {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (share.canQueueShareMatch) void share.enqueueShareMatch();
|
||||
else if (share.canPlayNavidromePublic) void share.playNavidromePublic();
|
||||
else if (share.canOpenShareAlbum) share.openShareAlbum();
|
||||
else if (share.canOpenShareArtist) share.openShareArtist();
|
||||
else if (share.canOpenShareComposer) share.openShareComposer();
|
||||
|
||||
@@ -60,10 +60,15 @@ const shareStub = {
|
||||
shareComposerResolving: false,
|
||||
shareComposerUnavailable: false,
|
||||
canQueueShareMatch: false,
|
||||
canPlayNavidromePublic: false,
|
||||
canOpenShareAlbum: false,
|
||||
canOpenShareArtist: false,
|
||||
canOpenShareComposer: false,
|
||||
hasShareKeyboardTarget: false,
|
||||
playNavidromePublic: vi.fn(),
|
||||
navidromeShareInfo: null,
|
||||
navidromeShareResolving: false,
|
||||
navidromeShareError: null,
|
||||
} as ReturnType<typeof useShareSearch>;
|
||||
|
||||
const results: SearchResults = {
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function LiveSearchDropdown({
|
||||
activeIndex={activeIndex}
|
||||
shareQueueBusy={share.shareQueueBusy}
|
||||
onEnqueue={() => void share.enqueueShareMatch()}
|
||||
onPlayNavidromePublic={() => void share.playNavidromePublic()}
|
||||
onOpenAlbum={share.openShareAlbum}
|
||||
onOpenArtist={share.openShareArtist}
|
||||
onOpenComposer={share.openShareComposer}
|
||||
@@ -119,6 +120,9 @@ export default function LiveSearchDropdown({
|
||||
shareComposer={share.shareComposer}
|
||||
shareComposerResolving={share.shareComposerResolving}
|
||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||
navidromeShareInfo={share.navidromeShareInfo}
|
||||
navidromeShareResolving={share.navidromeShareResolving}
|
||||
navidromeShareError={share.navidromeShareError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -339,6 +339,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
shareCoverServer={share.shareCoverServer}
|
||||
shareQueueBusy={share.shareQueueBusy}
|
||||
onEnqueue={() => void share.enqueueShareMatch()}
|
||||
onPlayNavidromePublic={() => void share.playNavidromePublic()}
|
||||
onOpenAlbum={share.openShareAlbum}
|
||||
onOpenArtist={share.openShareArtist}
|
||||
onOpenComposer={share.openShareComposer}
|
||||
@@ -354,6 +355,9 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
shareComposer={share.shareComposer}
|
||||
shareComposerResolving={share.shareComposerResolving}
|
||||
shareComposerUnavailable={share.shareComposerUnavailable}
|
||||
navidromeShareInfo={share.navidromeShareInfo}
|
||||
navidromeShareResolving={share.navidromeShareResolving}
|
||||
navidromeShareError={share.navidromeShareError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Music, X } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
import type { NavidromePublicSharePreviewState } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||
import { formatTrackTime } from '@/lib/format/formatDuration';
|
||||
import OverlayScrollArea from '@/ui/OverlayScrollArea';
|
||||
import { usePlayerStore } from '@/features/playback';
|
||||
|
||||
type NavidromePublicShareModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
publicShareRef: NavidromePublicShareRef;
|
||||
preview: NavidromePublicSharePreviewState;
|
||||
hostLabel?: string | null;
|
||||
onPlay: () => void;
|
||||
playBusy: boolean;
|
||||
};
|
||||
|
||||
function navidromeShareErrorMessage(
|
||||
reason: NonNullable<NavidromePublicSharePreviewState['navidromeShareError']>,
|
||||
t: (key: string) => string,
|
||||
): string {
|
||||
switch (reason) {
|
||||
case 'not-found':
|
||||
return t('sharePaste.navidromeShareNotFound');
|
||||
case 'expired':
|
||||
return t('sharePaste.navidromeShareExpired');
|
||||
case 'unreachable':
|
||||
return t('sharePaste.navidromeShareUnreachable');
|
||||
default:
|
||||
return t('sharePaste.navidromeShareMalformed');
|
||||
}
|
||||
}
|
||||
|
||||
function PreviewBody({
|
||||
preview,
|
||||
}: {
|
||||
preview: NavidromePublicSharePreviewState;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (preview.navidromeShareResolving) {
|
||||
return <div className="share-queue-preview-modal__status">{t('sharePaste.navidromeShareLoading')}</div>;
|
||||
}
|
||||
|
||||
if (preview.navidromeShareError) {
|
||||
return (
|
||||
<div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">
|
||||
{navidromeShareErrorMessage(preview.navidromeShareError, t)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const info = preview.navidromeShareInfo;
|
||||
if (!info) {
|
||||
return (
|
||||
<div className="share-queue-preview-modal__status share-queue-preview-modal__status--error">
|
||||
{t('sharePaste.navidromeShareMalformed')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{info.imageUrl && (
|
||||
<div className="share-queue-preview-modal__cover">
|
||||
<img src={info.imageUrl} alt="" className="share-queue-preview-modal__cover-img" />
|
||||
</div>
|
||||
)}
|
||||
<OverlayScrollArea
|
||||
className="share-queue-preview-modal__list-wrap"
|
||||
viewportClassName="share-queue-preview-modal__list-viewport"
|
||||
measureDeps={[info.tracks.length]}
|
||||
railInset="panel"
|
||||
>
|
||||
<ul className="share-queue-preview-modal__list">
|
||||
{info.tracks.map(track => (
|
||||
<li key={track.id} className="share-queue-preview-track">
|
||||
<div className="share-queue-preview-track__icon">
|
||||
<Music size={16} />
|
||||
</div>
|
||||
<div className="share-queue-preview-track__meta">
|
||||
<div className="share-queue-preview-track__title">{track.title}</div>
|
||||
<div className="share-queue-preview-track__sub">
|
||||
{track.artist}{track.album ? ` · ${track.album}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
{track.duration > 0 && (
|
||||
<span className="share-queue-preview-track__dur">{formatTrackTime(track.duration)}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</OverlayScrollArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NavidromePublicShareModal({
|
||||
open,
|
||||
onClose,
|
||||
publicShareRef: shareRef,
|
||||
preview,
|
||||
hostLabel,
|
||||
onPlay,
|
||||
playBusy,
|
||||
}: NavidromePublicShareModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const count = preview.navidromeShareInfo?.tracks.length ?? 0;
|
||||
const title = preview.navidromeShareInfo?.description?.trim()
|
||||
|| t('sharePaste.navidromeShareTitle', { count: count || 1 });
|
||||
const canPlay = !!preview.navidromeShareInfo && preview.navidromeShareInfo.tracks.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
const blockContextMenu = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
usePlayerStore.getState().closeContextMenu();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
document.addEventListener('contextmenu', blockContextMenu, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handler);
|
||||
document.removeEventListener('contextmenu', blockContextMenu, true);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal-overlay share-queue-preview-modal-overlay"
|
||||
role="presentation"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-content share-queue-preview-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="navidrome-share-preview-title"
|
||||
onContextMenu={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<button type="button" className="modal-close" onClick={onClose} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
|
||||
<header className="share-queue-preview-modal__header">
|
||||
<h2 id="navidrome-share-preview-title" className="share-queue-preview-modal__title">
|
||||
{title}
|
||||
</h2>
|
||||
{hostLabel && (
|
||||
<p className="share-queue-preview-modal__server">
|
||||
{t('search.shareFromServer', { server: hostLabel })}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="share-queue-preview-modal__body">
|
||||
<PreviewBody preview={preview} />
|
||||
</div>
|
||||
|
||||
<footer className="share-queue-preview-modal__footer">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void openUrl(shareRef.pageUrl)}
|
||||
>
|
||||
{t('sharePaste.openInBrowser')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canPlay || playBusy}
|
||||
onClick={() => void onPlay()}
|
||||
>
|
||||
{playBusy ? t('sharePaste.navidromeSharePlaying') : t('sharePaste.navidromeSharePlay')}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { activateShareSearchServer } from '@/features/share/enqueueShareSearchPayload';
|
||||
import { sharePayloadTotal, type ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||
import type { ShareSearchPreviewState } from '@/features/search/hooks/useShareSearchPreview';
|
||||
import type { NavidromePublicSharePreviewState } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '@/ui/CachedImage';
|
||||
import { AlbumCoverArtImage } from '@/cover/AlbumCoverArtImage';
|
||||
import { ArtistCoverArtImage } from '@/cover/ArtistCoverArtImage';
|
||||
@@ -15,6 +16,7 @@ import { COVER_DENSE_SEARCH_CSS_PX } from '@/cover/layoutSizes';
|
||||
import { COVER_SCOPE_ACTIVE, type CoverServerScope } from '@/cover/types';
|
||||
import { useShareQueuePreview } from '@/features/search/hooks/useShareQueuePreview';
|
||||
import ShareQueuePreviewModal from '@/features/search/components/ShareQueuePreviewModal';
|
||||
import NavidromePublicShareModal from '@/features/search/components/NavidromePublicShareModal';
|
||||
|
||||
type ShareSearchResultsProps = {
|
||||
variant: 'desktop' | 'mobile';
|
||||
@@ -26,11 +28,12 @@ type ShareSearchResultsProps = {
|
||||
activeIndex?: number;
|
||||
shareQueueBusy: boolean;
|
||||
onEnqueue: () => void | Promise<boolean>;
|
||||
onPlayNavidromePublic?: () => void | Promise<boolean>;
|
||||
onOpenAlbum: () => void;
|
||||
onOpenArtist: () => void;
|
||||
onOpenComposer: () => void;
|
||||
onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void;
|
||||
} & ShareSearchPreviewState;
|
||||
} & ShareSearchPreviewState & NavidromePublicSharePreviewState;
|
||||
|
||||
function shareCoverServerScope(coverServer?: ServerProfile | null): CoverServerScope {
|
||||
if (coverServer) {
|
||||
@@ -128,7 +131,7 @@ function withShareServer(
|
||||
t: TFunction,
|
||||
fn: () => void,
|
||||
): void {
|
||||
if (shareMatch.type === 'unsupported') return;
|
||||
if (shareMatch.type === 'unsupported' || shareMatch.type === 'navidrome-public') return;
|
||||
if (!activateShareSearchServer(shareMatch.payload.srv, t)) return;
|
||||
fn();
|
||||
}
|
||||
@@ -148,6 +151,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
activeIndex = 0,
|
||||
shareQueueBusy,
|
||||
onEnqueue,
|
||||
onPlayNavidromePublic,
|
||||
onOpenAlbum,
|
||||
onOpenArtist,
|
||||
onOpenComposer,
|
||||
@@ -164,6 +168,9 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
shareComposer,
|
||||
shareComposerResolving,
|
||||
shareComposerUnavailable,
|
||||
navidromeShareInfo,
|
||||
navidromeShareResolving,
|
||||
navidromeShareError,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation();
|
||||
@@ -192,6 +199,7 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t);
|
||||
const showEntityKindSub = !desktop || !!shareServerLabel;
|
||||
const [queuePreviewOpen, setQueuePreviewOpen] = useState(false);
|
||||
const [navidromePreviewOpen, setNavidromePreviewOpen] = useState(false);
|
||||
const queuePayload =
|
||||
shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null;
|
||||
const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen);
|
||||
@@ -210,6 +218,101 @@ export default function ShareSearchResults(props: ShareSearchResultsProps) {
|
||||
return wrap(unsupportedRow);
|
||||
}
|
||||
|
||||
if (shareMatch.type === 'navidrome-public') {
|
||||
const count = navidromeShareInfo?.tracks.length ?? 0;
|
||||
const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row';
|
||||
const title = navidromeShareInfo?.description?.trim()
|
||||
|| t('sharePaste.navidromeShareTitle', { count: count || 1 });
|
||||
|
||||
if (navidromeShareResolving) {
|
||||
return wrap(
|
||||
<div className={mutedCls}>
|
||||
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{t('common.loading')}</div>
|
||||
<div className={subCls}>{sub(t('sharePaste.navidromeShareLoading'))}</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (navidromeShareError) {
|
||||
const errMsg =
|
||||
navidromeShareError === 'not-found'
|
||||
? t('sharePaste.navidromeShareNotFound')
|
||||
: navidromeShareError === 'expired'
|
||||
? t('sharePaste.navidromeShareExpired')
|
||||
: navidromeShareError === 'unreachable'
|
||||
? t('sharePaste.navidromeShareUnreachable')
|
||||
: t('sharePaste.navidromeShareMalformed');
|
||||
return wrap(
|
||||
<div className={mutedCls}>
|
||||
<StaticIcon className={iconCls}><Link2 size={desktop ? 14 : 20} /></StaticIcon>
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{errMsg}</div>
|
||||
<div className={subCls}>{sub('')}</div>
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
}
|
||||
|
||||
if (navidromeShareInfo) {
|
||||
const handlePlay = () => {
|
||||
void Promise.resolve(onPlayNavidromePublic?.()).then(ok => {
|
||||
if (ok) setNavidromePreviewOpen(false);
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{wrap(
|
||||
<div
|
||||
className={`${rowCls}${activeIndex === 0 ? ' active' : ''}`}
|
||||
role={desktop ? 'option' : undefined}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={desktop ? 'search-share-queue-main' : 'mobile-search-item search-share-queue-main'}
|
||||
onClick={handlePlay}
|
||||
disabled={shareQueueBusy}
|
||||
aria-selected={desktop ? activeIndex === 0 : undefined}
|
||||
>
|
||||
<StaticIcon className={iconCls}><ListPlus size={desktop ? 14 : 20} /></StaticIcon>
|
||||
<div className={infoWrap}>
|
||||
<div className={nameCls}>{title}</div>
|
||||
<div className={subCls}>
|
||||
{shareQueueBusy
|
||||
? sub(t('sharePaste.navidromeSharePlaying'))
|
||||
: sub(t('search.shareQueueTitle', { count }))}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="search-share-queue-preview-btn"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setNavidromePreviewOpen(true);
|
||||
}}
|
||||
aria-label={t('sharePaste.navidromeSharePreview')}
|
||||
>
|
||||
<Eye size={desktop ? 16 : 18} />
|
||||
</button>
|
||||
</div>,
|
||||
)}
|
||||
<NavidromePublicShareModal
|
||||
open={navidromePreviewOpen}
|
||||
onClose={() => setNavidromePreviewOpen(false)}
|
||||
publicShareRef={shareMatch.publicShareRef}
|
||||
preview={{ navidromeShareInfo, navidromeShareResolving, navidromeShareError }}
|
||||
hostLabel={shareServerLabel}
|
||||
onPlay={handlePlay}
|
||||
playBusy={shareQueueBusy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (shareMatch.type === 'artist') {
|
||||
if (shareArtistResolving) {
|
||||
return wrap(
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { fetchNavidromePublicShare } from '@/lib/share/fetchNavidromePublicShare';
|
||||
import type { FetchNavidromePublicShareError, NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
|
||||
export type NavidromePublicSharePreviewState = {
|
||||
navidromeShareInfo: NavidromePublicShareInfo | null;
|
||||
navidromeShareResolving: boolean;
|
||||
navidromeShareError: FetchNavidromePublicShareError | null;
|
||||
};
|
||||
|
||||
const EMPTY: NavidromePublicSharePreviewState = {
|
||||
navidromeShareInfo: null,
|
||||
navidromeShareResolving: false,
|
||||
navidromeShareError: null,
|
||||
};
|
||||
|
||||
export function useNavidromePublicSharePreview(
|
||||
ref: NavidromePublicShareRef | null,
|
||||
enabled = true,
|
||||
): NavidromePublicSharePreviewState {
|
||||
const [state, setState] = useState<NavidromePublicSharePreviewState>(EMPTY);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !ref) {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setState(EMPTY);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setState({ ...EMPTY, navidromeShareResolving: true });
|
||||
|
||||
void fetchNavidromePublicShare(ref)
|
||||
.then(result => {
|
||||
if (cancelled) return;
|
||||
if (result.type === 'ok') {
|
||||
setState({
|
||||
navidromeShareInfo: result.info,
|
||||
navidromeShareResolving: false,
|
||||
navidromeShareError: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState({
|
||||
navidromeShareInfo: null,
|
||||
navidromeShareResolving: false,
|
||||
navidromeShareError: result.reason,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
navidromeShareInfo: null,
|
||||
navidromeShareResolving: false,
|
||||
navidromeShareError: 'unreachable',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, ref]);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { shareServerOriginLabel } from '@/lib/share/shareServerOriginLabel';
|
||||
import { parseShareSearchText } from '@/lib/share/shareSearch';
|
||||
import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
||||
import { useShareSearchPreview } from '@/features/search/hooks/useShareSearchPreview';
|
||||
import { useNavidromePublicSharePreview } from '@/features/search/hooks/useNavidromePublicSharePreview';
|
||||
import { playNavidromePublicShare } from '@/features/share';
|
||||
|
||||
export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,7 +28,9 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
[shareMatch, servers, activeServerId],
|
||||
);
|
||||
const shareCoverServer = useMemo((): ServerProfile | null => {
|
||||
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
||||
if (!shareMatch || shareMatch.type === 'unsupported' || shareMatch.type === 'navidrome-public') {
|
||||
return null;
|
||||
}
|
||||
const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||
if (!serverId || serverId === activeServerId) return null;
|
||||
return servers.find(s => s.id === serverId)
|
||||
@@ -34,6 +38,8 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
?? null;
|
||||
}, [shareMatch, servers, activeServerId]);
|
||||
const preview = useShareSearchPreview(shareMatch);
|
||||
const navidromeRef = shareMatch?.type === 'navidrome-public' ? shareMatch.publicShareRef : null;
|
||||
const navidromePreview = useNavidromePublicSharePreview(navidromeRef);
|
||||
const [shareQueueBusy, setShareQueueBusy] = useState(false);
|
||||
|
||||
const canQueueShareMatch =
|
||||
@@ -41,6 +47,11 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
(shareMatch.payload.k === 'queue' ||
|
||||
(!preview.shareTrackResolving && !!preview.shareTrackSong));
|
||||
|
||||
const canPlayNavidromePublic =
|
||||
shareMatch?.type === 'navidrome-public' &&
|
||||
!!navidromePreview.navidromeShareInfo &&
|
||||
!navidromePreview.navidromeShareResolving;
|
||||
|
||||
const canOpenShareAlbum =
|
||||
shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving;
|
||||
const canOpenShareArtist =
|
||||
@@ -49,7 +60,11 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving;
|
||||
|
||||
const hasShareKeyboardTarget =
|
||||
canQueueShareMatch || canOpenShareAlbum || canOpenShareArtist || canOpenShareComposer;
|
||||
canQueueShareMatch ||
|
||||
canPlayNavidromePublic ||
|
||||
canOpenShareAlbum ||
|
||||
canOpenShareArtist ||
|
||||
canOpenShareComposer;
|
||||
|
||||
const openShareAlbum = useCallback(() => {
|
||||
if (shareMatch?.type !== 'album' || !preview.shareAlbum) return;
|
||||
@@ -72,6 +87,25 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
onSuccess?.();
|
||||
}, [shareMatch, preview.shareComposer, navigate, t, onSuccess]);
|
||||
|
||||
const playNavidromePublic = useCallback(async () => {
|
||||
if (
|
||||
shareMatch?.type !== 'navidrome-public' ||
|
||||
!navidromePreview.navidromeShareInfo ||
|
||||
shareQueueBusy
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
setShareQueueBusy(true);
|
||||
const ok = await playNavidromePublicShare(
|
||||
shareMatch.publicShareRef,
|
||||
navidromePreview.navidromeShareInfo,
|
||||
t,
|
||||
);
|
||||
setShareQueueBusy(false);
|
||||
if (ok) onSuccess?.();
|
||||
return ok;
|
||||
}, [shareMatch, navidromePreview.navidromeShareInfo, shareQueueBusy, t, onSuccess]);
|
||||
|
||||
const enqueueShareMatch = useCallback(async () => {
|
||||
if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false;
|
||||
if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) {
|
||||
@@ -90,6 +124,7 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
shareCoverServer,
|
||||
shareQueueBusy,
|
||||
canQueueShareMatch,
|
||||
canPlayNavidromePublic,
|
||||
canOpenShareAlbum,
|
||||
canOpenShareArtist,
|
||||
canOpenShareComposer,
|
||||
@@ -98,6 +133,8 @@ export function useShareSearch(query: string, onSuccess?: () => void) {
|
||||
openShareArtist,
|
||||
openShareComposer,
|
||||
enqueueShareMatch,
|
||||
playNavidromePublic,
|
||||
...preview,
|
||||
...navidromePreview,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,5 +11,7 @@
|
||||
export { default as LiveSearch } from './components/LiveSearch';
|
||||
export { default as MobileSearchOverlay } from './components/MobileSearchOverlay';
|
||||
export { default as ShareQueuePreviewModal } from './components/ShareQueuePreviewModal';
|
||||
export { default as NavidromePublicShareModal } from './components/NavidromePublicShareModal';
|
||||
export { useLiveSearchRouteScope } from './hooks/useLiveSearchRouteScope';
|
||||
export { useShareQueuePreview } from './hooks/useShareQueuePreview';
|
||||
export { useNavidromePublicSharePreview } from './hooks/useNavidromePublicSharePreview';
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import type { EntitySharePayloadV1 } from '@/lib/share/shareLink';
|
||||
import { extractNavidromePublicShareFromText } from '@/lib/share/navidromePublicShareUrl';
|
||||
import { decodeSharePayloadFromText } from '@/lib/share/shareLink';
|
||||
import { decodeServerMagicStringFromText } from '@/lib/server/serverMagicString';
|
||||
import { applySharePastePayload, applySharePasteQueue } from '@/features/share/applySharePaste';
|
||||
import { shareQueueServerContext } from '@/lib/share/shareServerOriginLabel';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
import { useShareQueuePreview } from '@/features/search';
|
||||
import { ShareQueuePreviewModal } from '@/features/search';
|
||||
import { useShareQueuePreview, ShareQueuePreviewModal, NavidromePublicShareModal, useNavidromePublicSharePreview } from '@/features/search';
|
||||
import { playNavidromePublicShare } from '@/features/share';
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
import {
|
||||
parseOrbitShareLink,
|
||||
joinOrbitSession,
|
||||
@@ -34,7 +35,10 @@ const ORBIT_JOIN_ERROR_KEYS: Record<string, string> = {
|
||||
* Global paste: library share links (`psysonic2-`) and server invites (`psysonic1-`)
|
||||
* outside text fields. Shares require login; invites open add-server (settings or login).
|
||||
*/
|
||||
type QueuePastePayload = Extract<EntitySharePayloadV1, { k: 'queue' }>;
|
||||
type QueuePastePayload = Extract<
|
||||
NonNullable<ReturnType<typeof decodeSharePayloadFromText>>,
|
||||
{ k: 'queue' }
|
||||
>;
|
||||
|
||||
export default function PasteClipboardHandler() {
|
||||
const navigate = useNavigate();
|
||||
@@ -47,8 +51,11 @@ export default function PasteClipboardHandler() {
|
||||
const [orbitConfirm, setOrbitConfirm] = useState<{ sid: string; host: string; name: string } | null>(null);
|
||||
const [orbitInvalid, setOrbitInvalid] = useState(false);
|
||||
const [queuePaste, setQueuePaste] = useState<QueuePastePayload | null>(null);
|
||||
const [navidromePaste, setNavidromePaste] = useState<NavidromePublicShareRef | null>(null);
|
||||
const [queuePasteBusy, setQueuePasteBusy] = useState(false);
|
||||
const [navidromePasteBusy, setNavidromePasteBusy] = useState(false);
|
||||
const queuePreview = useShareQueuePreview(queuePaste, !!queuePaste);
|
||||
const navidromePreview = useNavidromePublicSharePreview(navidromePaste, !!navidromePaste);
|
||||
const { label: queuePasteServerLabel, coverServer: queuePasteCoverServer } = useMemo(
|
||||
() =>
|
||||
queuePaste
|
||||
@@ -148,6 +155,15 @@ export default function PasteClipboardHandler() {
|
||||
return;
|
||||
}
|
||||
|
||||
const navidromeShare = extractNavidromePublicShareFromText(text);
|
||||
if (navidromeShare) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (busy.current || queuePaste || navidromePaste) return;
|
||||
setNavidromePaste(navidromeShare);
|
||||
return;
|
||||
}
|
||||
|
||||
const share = decodeSharePayloadFromText(text);
|
||||
if (share) {
|
||||
if (!isLoggedIn) {
|
||||
@@ -202,13 +218,18 @@ export default function PasteClipboardHandler() {
|
||||
// the global paste listener is intentionally not re-registered on every render
|
||||
// or navigation, only when the auth/handler inputs below change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [navigate, t, isLoggedIn, queuePaste]);
|
||||
}, [navigate, t, isLoggedIn, queuePaste, navidromePaste]);
|
||||
|
||||
const closeQueuePaste = () => {
|
||||
if (queuePasteBusy) return;
|
||||
setQueuePaste(null);
|
||||
};
|
||||
|
||||
const closeNavidromePaste = () => {
|
||||
if (navidromePasteBusy) return;
|
||||
setNavidromePaste(null);
|
||||
};
|
||||
|
||||
const confirmQueuePaste = async () => {
|
||||
if (!queuePaste || queuePasteBusy) return;
|
||||
setQueuePasteBusy(true);
|
||||
@@ -217,8 +238,40 @@ export default function PasteClipboardHandler() {
|
||||
if (ok) setQueuePaste(null);
|
||||
};
|
||||
|
||||
const confirmNavidromePaste = async () => {
|
||||
if (!navidromePaste || navidromePasteBusy || !navidromePreview.navidromeShareInfo) return;
|
||||
setNavidromePasteBusy(true);
|
||||
const ok = await playNavidromePublicShare(
|
||||
navidromePaste,
|
||||
navidromePreview.navidromeShareInfo,
|
||||
t,
|
||||
);
|
||||
setNavidromePasteBusy(false);
|
||||
if (ok) setNavidromePaste(null);
|
||||
};
|
||||
|
||||
const navidromeHostLabel = useMemo(() => {
|
||||
if (!navidromePaste) return null;
|
||||
try {
|
||||
return new URL(navidromePaste.origin).host;
|
||||
} catch {
|
||||
return navidromePaste.origin;
|
||||
}
|
||||
}, [navidromePaste]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{navidromePaste && (
|
||||
<NavidromePublicShareModal
|
||||
open
|
||||
onClose={closeNavidromePaste}
|
||||
publicShareRef={navidromePaste}
|
||||
preview={navidromePreview}
|
||||
hostLabel={navidromeHostLabel}
|
||||
onPlay={() => void confirmNavidromePaste()}
|
||||
playBusy={navidromePasteBusy}
|
||||
/>
|
||||
)}
|
||||
{queuePaste && (
|
||||
<ShareQueuePreviewModal
|
||||
open
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
*/
|
||||
export * from './applySharePaste';
|
||||
export * from './enqueueShareSearchPayload';
|
||||
export * from './playNavidromePublicShare';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
import type { NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||
import { NAVIDROME_PUBLIC_SHARE_SERVER_ID, navidromePublicShareToTracks } from '@/lib/share/navidromePublicSharePlayback';
|
||||
import { seedQueueResolver, usePlayerStore } from '@/features/playback';
|
||||
import { showToast } from '@/lib/dom/toast';
|
||||
|
||||
export async function playNavidromePublicShare(
|
||||
ref: NavidromePublicShareRef,
|
||||
info: NavidromePublicShareInfo,
|
||||
t: TFunction,
|
||||
): Promise<boolean> {
|
||||
const tracks = navidromePublicShareToTracks(ref, info);
|
||||
if (tracks.length === 0) {
|
||||
showToast(t('sharePaste.genericError'), 5000, 'error');
|
||||
return false;
|
||||
}
|
||||
|
||||
usePlayerStore.getState().clearQueue();
|
||||
seedQueueResolver(NAVIDROME_PUBLIC_SHARE_SERVER_ID, tracks);
|
||||
usePlayerStore.getState().playTrack(tracks[0]!, tracks);
|
||||
showToast(t('sharePaste.openedNavidromePublic', { count: tracks.length }), 3000, 'info');
|
||||
return true;
|
||||
}
|
||||
@@ -42,6 +42,10 @@ export interface Track {
|
||||
* end of the current Play-Next streak. Stale flags behind queueIndex are
|
||||
* harmless — the streak scan only looks forward from queueIndex+1. */
|
||||
playNextAdded?: boolean;
|
||||
/** Absolute HTTP(S) stream URL — bypasses Subsonic `stream.view` (Navidrome public share). */
|
||||
directStreamUrl?: string;
|
||||
/** Absolute cover art URL — bypasses Subsonic `getCoverArt` (Navidrome public share). */
|
||||
directCoverArtUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,4 +61,8 @@ export interface QueueItemRef {
|
||||
autoAdded?: boolean;
|
||||
radioAdded?: boolean;
|
||||
playNextAdded?: boolean;
|
||||
/** Persisted for Navidrome public share restore (JWT stream URL). */
|
||||
directStreamUrl?: string;
|
||||
/** Persisted for Navidrome public share restore (JWT cover URL). */
|
||||
directCoverArtUrl?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
fetchNavidromePublicShare,
|
||||
parseShareInfoFromHtml,
|
||||
parseShareInfoFromM3u,
|
||||
} from '@/lib/share/fetchNavidromePublicShare';
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
|
||||
const ref: NavidromePublicShareRef = {
|
||||
pageUrl: 'https://music.example.com/share/Ab12Cd34Ef',
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '',
|
||||
shareId: 'Ab12Cd34Ef',
|
||||
};
|
||||
|
||||
const shareInfoJson = {
|
||||
id: 'Ab12Cd34Ef',
|
||||
description: 'Weekend mix',
|
||||
downloadable: true,
|
||||
tracks: [
|
||||
{
|
||||
id: 'stream-jwt-1',
|
||||
title: 'Track One',
|
||||
artist: 'Artist A',
|
||||
album: 'Album X',
|
||||
duration: 245,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('parseShareInfoFromHtml', () => {
|
||||
it('extracts __SHARE_INFO__ JSON from HTML', () => {
|
||||
const html = `<html><script>window.__SHARE_INFO__ = ${JSON.stringify(shareInfoJson)}</script></html>`;
|
||||
expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
|
||||
});
|
||||
|
||||
it('extracts Navidrome string-assigned __SHARE_INFO__ (escaped JSON in JS quotes)', () => {
|
||||
const html = `<html><script>window.__SHARE_INFO__ = ${JSON.stringify(JSON.stringify(shareInfoJson))}</script></html>`;
|
||||
expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
|
||||
});
|
||||
|
||||
it('parses real Navidrome v0.63 HTML assignment shape', () => {
|
||||
const html = `<script>window.__SHARE_INFO__ = "{\\"id\\":\\"m4dSzkJhZc\\",\\"description\\":\\"testo\\",\\"downloadable\\":false,\\"tracks\\":[{\\"id\\":\\"jwt\\",\\"title\\":\\"Обман\\",\\"artist\\":\\"Ария\\",\\"album\\":\\"Колизей\\",\\"duration\\":310.15}]}"</script>`;
|
||||
expect(parseShareInfoFromHtml(html)).toEqual({
|
||||
id: 'm4dSzkJhZc',
|
||||
description: 'testo',
|
||||
downloadable: false,
|
||||
tracks: [{
|
||||
id: 'jwt',
|
||||
title: 'Обман',
|
||||
artist: 'Ария',
|
||||
album: 'Колизей',
|
||||
duration: 310.15,
|
||||
}],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when marker is missing', () => {
|
||||
expect(parseShareInfoFromHtml('<html></html>')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShareInfoFromM3u', () => {
|
||||
it('parses extended m3u entries', () => {
|
||||
const body = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:245,Track One',
|
||||
'https://music.example.com/share/s/stream-jwt-1',
|
||||
].join('\n');
|
||||
expect(parseShareInfoFromM3u(body)).toEqual([
|
||||
{
|
||||
id: 'stream-jwt-1',
|
||||
title: 'Track One',
|
||||
artist: '',
|
||||
album: '',
|
||||
duration: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchNavidromePublicShare', () => {
|
||||
it('maps 404 to not-found', async () => {
|
||||
const result = await fetchNavidromePublicShare(ref, async () => new Response('', { status: 404 }));
|
||||
expect(result).toEqual({ type: 'error', reason: 'not-found' });
|
||||
});
|
||||
|
||||
it('maps 410 to expired', async () => {
|
||||
const result = await fetchNavidromePublicShare(ref, async () => new Response('', { status: 410 }));
|
||||
expect(result).toEqual({ type: 'error', reason: 'expired' });
|
||||
});
|
||||
|
||||
it('returns parsed HTML share info', async () => {
|
||||
const html = `<html><meta property="og:image" content="https://music.example.com/cover.jpg"><script>window.__SHARE_INFO__ = ${JSON.stringify(shareInfoJson)}</script></html>`;
|
||||
const result = await fetchNavidromePublicShare(ref, async () => new Response(html, { status: 200 }));
|
||||
expect(result).toEqual({
|
||||
type: 'ok',
|
||||
info: {
|
||||
...shareInfoJson,
|
||||
imageUrl: 'https://music.example.com/share/img/stream-jwt-1?size=300',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to m3u when HTML has no share info', async () => {
|
||||
const m3u = [
|
||||
'#EXTM3U',
|
||||
'#EXTINF:120,Fallback Track',
|
||||
'https://music.example.com/share/s/token-2',
|
||||
].join('\n');
|
||||
const fetchImpl = async (url: string) => {
|
||||
if (url.endsWith('/m3u')) return new Response(m3u, { status: 200 });
|
||||
return new Response('<html></html>', { status: 200 });
|
||||
};
|
||||
const result = await fetchNavidromePublicShare(ref, fetchImpl);
|
||||
expect(result.type).toBe('ok');
|
||||
if (result.type === 'ok') {
|
||||
expect(result.info.tracks).toHaveLength(1);
|
||||
expect(result.info.tracks[0]?.title).toBe('Fallback Track');
|
||||
expect(result.info.imageUrl).toBe('https://music.example.com/share/img/token-2?size=300');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { NavidromePublicShareRef } from '@/lib/share/navidromePublicShareUrl';
|
||||
import {
|
||||
buildNavidromePublicShareM3uUrl,
|
||||
buildNavidromePublicCoverUrl,
|
||||
} from '@/lib/share/navidromePublicShareUrl';
|
||||
import type {
|
||||
FetchNavidromePublicShareResult,
|
||||
NavidromePublicShareInfo,
|
||||
NavidromePublicShareTrack,
|
||||
} from '@/lib/share/navidromePublicShareTypes';
|
||||
|
||||
export type FetchNavidromePublicShareFn = (
|
||||
url: string,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
const defaultFetch: FetchNavidromePublicShareFn = (url, init) => fetch(url, init);
|
||||
|
||||
function parseShareInfoJson(raw: unknown): NavidromePublicShareInfo | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
const id = typeof obj.id === 'string' ? obj.id.trim() : '';
|
||||
if (!id) return null;
|
||||
|
||||
const description = typeof obj.description === 'string' ? obj.description : '';
|
||||
const downloadable = obj.downloadable === true;
|
||||
const tracksRaw = obj.tracks;
|
||||
if (!Array.isArray(tracksRaw) || tracksRaw.length === 0) return null;
|
||||
|
||||
const tracks: NavidromePublicShareTrack[] = [];
|
||||
for (const row of tracksRaw) {
|
||||
if (!row || typeof row !== 'object') continue;
|
||||
const t = row as Record<string, unknown>;
|
||||
const token = typeof t.id === 'string' ? t.id.trim() : '';
|
||||
const title = typeof t.title === 'string' ? t.title : '';
|
||||
if (!token || !title) continue;
|
||||
tracks.push({
|
||||
id: token,
|
||||
title,
|
||||
artist: typeof t.artist === 'string' ? t.artist : '',
|
||||
album: typeof t.album === 'string' ? t.album : '',
|
||||
duration: typeof t.duration === 'number' && Number.isFinite(t.duration) ? t.duration : 0,
|
||||
});
|
||||
}
|
||||
|
||||
if (tracks.length === 0) return null;
|
||||
return { id, description, downloadable, tracks };
|
||||
}
|
||||
|
||||
/** Navidrome assigns `window.__SHARE_INFO__ = "{...json...}"` (escaped JSON in JS quotes). */
|
||||
function decodeJsStringLiteral(raw: string): string {
|
||||
return raw.replace(/\\(.)/g, (_, ch: string) => {
|
||||
switch (ch) {
|
||||
case '"':
|
||||
return '"';
|
||||
case '\\':
|
||||
return '\\';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 't':
|
||||
return '\t';
|
||||
default:
|
||||
return ch;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseShareInfoFromJsonText(jsonText: string): NavidromePublicShareInfo | null {
|
||||
try {
|
||||
return parseShareInfoJson(JSON.parse(jsonText));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseShareInfoFromHtml(html: string): NavidromePublicShareInfo | null {
|
||||
const marker = 'window.__SHARE_INFO__';
|
||||
const idx = html.indexOf(marker);
|
||||
if (idx < 0) return null;
|
||||
|
||||
const afterMarker = html.slice(idx + marker.length);
|
||||
|
||||
const stringAssign = afterMarker.match(/^\s*=\s*"((?:\\.|[^"\\])*)"/);
|
||||
if (stringAssign?.[1]) {
|
||||
const fromString = parseShareInfoFromJsonText(decodeJsStringLiteral(stringAssign[1]));
|
||||
if (fromString) return fromString;
|
||||
}
|
||||
|
||||
const start = afterMarker.indexOf('{');
|
||||
if (start < 0) return null;
|
||||
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < afterMarker.length; i++) {
|
||||
const ch = afterMarker[i]!;
|
||||
if (inString) {
|
||||
if (escape) {
|
||||
escape = false;
|
||||
} else if (ch === '\\') {
|
||||
escape = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '{') depth++;
|
||||
if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
const jsonText = afterMarker.slice(start, i + 1);
|
||||
return parseShareInfoFromJsonText(jsonText);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseShareInfoFromM3u(body: string): NavidromePublicShareTrack[] {
|
||||
const lines = body.split(/\r?\n/);
|
||||
const tracks: NavidromePublicShareTrack[] = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!.trim();
|
||||
if (!line.startsWith('#EXTINF:')) continue;
|
||||
const meta = line.slice('#EXTINF:'.length);
|
||||
const comma = meta.lastIndexOf(',');
|
||||
const title = comma >= 0 ? meta.slice(comma + 1).trim() : meta.trim();
|
||||
const urlLine = lines.slice(i + 1).find(l => l.trim() && !l.trim().startsWith('#'))?.trim();
|
||||
if (!urlLine || !title) continue;
|
||||
const token = urlLine.split('/').pop() ?? '';
|
||||
if (!token) continue;
|
||||
tracks.push({
|
||||
id: token,
|
||||
title,
|
||||
artist: '',
|
||||
album: '',
|
||||
duration: 0,
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
function mapHttpStatus(status: number): FetchNavidromePublicShareResult {
|
||||
if (status === 404) return { type: 'error', reason: 'not-found' };
|
||||
if (status === 410) return { type: 'error', reason: 'expired' };
|
||||
return { type: 'error', reason: 'malformed' };
|
||||
}
|
||||
|
||||
function parseOgImage(html: string): string | undefined {
|
||||
const m = html.match(/<meta\s+property="og:image"\s+content="([^"]+)"/i);
|
||||
return m?.[1];
|
||||
}
|
||||
|
||||
function sharePreviewImageUrl(
|
||||
ref: NavidromePublicShareRef,
|
||||
info: NavidromePublicShareInfo,
|
||||
ogImage?: string,
|
||||
): string | undefined {
|
||||
const firstTrack = info.tracks[0];
|
||||
if (firstTrack) return buildNavidromePublicCoverUrl(ref, firstTrack.id);
|
||||
return ogImage;
|
||||
}
|
||||
|
||||
export async function fetchNavidromePublicShare(
|
||||
ref: NavidromePublicShareRef,
|
||||
fetchImpl: FetchNavidromePublicShareFn = defaultFetch,
|
||||
): Promise<FetchNavidromePublicShareResult> {
|
||||
let htmlResp: Response;
|
||||
try {
|
||||
htmlResp = await fetchImpl(ref.pageUrl, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'text/html,*/*' },
|
||||
});
|
||||
} catch {
|
||||
return { type: 'error', reason: 'unreachable' };
|
||||
}
|
||||
|
||||
if (htmlResp.status === 404) return { type: 'error', reason: 'not-found' };
|
||||
if (htmlResp.status === 410) return { type: 'error', reason: 'expired' };
|
||||
if (!htmlResp.ok) return mapHttpStatus(htmlResp.status);
|
||||
|
||||
const html = await htmlResp.text();
|
||||
const fromHtml = parseShareInfoFromHtml(html);
|
||||
if (fromHtml) {
|
||||
const ogImage = parseOgImage(html);
|
||||
return {
|
||||
type: 'ok',
|
||||
info: {
|
||||
...fromHtml,
|
||||
imageUrl: sharePreviewImageUrl(ref, fromHtml, ogImage),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const m3uResp = await fetchImpl(buildNavidromePublicShareM3uUrl(ref), {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'audio/x-mpegurl,*/*' },
|
||||
});
|
||||
if (m3uResp.status === 404) return { type: 'error', reason: 'not-found' };
|
||||
if (m3uResp.status === 410) return { type: 'error', reason: 'expired' };
|
||||
if (!m3uResp.ok) return mapHttpStatus(m3uResp.status);
|
||||
|
||||
const tracks = parseShareInfoFromM3u(await m3uResp.text());
|
||||
if (tracks.length === 0) return { type: 'error', reason: 'malformed' };
|
||||
|
||||
return {
|
||||
type: 'ok',
|
||||
info: {
|
||||
id: ref.shareId,
|
||||
description: '',
|
||||
downloadable: false,
|
||||
tracks,
|
||||
imageUrl: sharePreviewImageUrl(ref, { id: ref.shareId, description: '', downloadable: false, tracks }),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { type: 'error', reason: 'unreachable' };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
isActivePublicShareQueue,
|
||||
isPublicSharePersistedTrack,
|
||||
isPublicShareTrackId,
|
||||
} from '@/lib/share/navidromePublicSharePlayback';
|
||||
|
||||
describe('isActivePublicShareQueue', () => {
|
||||
it('detects share queue by queueServerId', () => {
|
||||
expect(isActivePublicShareQueue('navidrome-public-share', [
|
||||
{ serverId: 'navidrome-public-share', trackId: 'ndshare:abc:0' },
|
||||
])).toBe(true);
|
||||
});
|
||||
|
||||
it('detects ndshare refs on another queueServerId', () => {
|
||||
expect(isActivePublicShareQueue('music.test', [{
|
||||
serverId: 'navidrome-public-share',
|
||||
trackId: 'ndshare:abc:0',
|
||||
}])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a normal server queue', () => {
|
||||
expect(isActivePublicShareQueue('music.test', [
|
||||
{ serverId: 'music.test', trackId: 'real-track-id' },
|
||||
])).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for an empty queue', () => {
|
||||
expect(isActivePublicShareQueue('navidrome-public-share', [])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicShareTrackId', () => {
|
||||
it('matches ndshare synthetic ids', () => {
|
||||
expect(isPublicShareTrackId('ndshare:Ab12:0')).toBe(true);
|
||||
expect(isPublicShareTrackId('real-id')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicSharePersistedTrack', () => {
|
||||
it('matches share currentTrack blobs', () => {
|
||||
expect(isPublicSharePersistedTrack({
|
||||
id: 'ndshare:abc:0',
|
||||
title: 't',
|
||||
artist: 'a',
|
||||
album: 'al',
|
||||
albumId: '',
|
||||
duration: 1,
|
||||
serverId: 'navidrome-public-share',
|
||||
})).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import type { QueueItemRef } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
buildNavidromePublicStreamUrl,
|
||||
buildNavidromePublicCoverUrl,
|
||||
type NavidromePublicShareRef,
|
||||
} from '@/lib/share/navidromePublicShareUrl';
|
||||
import type { NavidromePublicShareInfo } from '@/lib/share/navidromePublicShareTypes';
|
||||
|
||||
/** Synthetic queue server bucket for anonymous Navidrome public shares. */
|
||||
export const NAVIDROME_PUBLIC_SHARE_SERVER_ID = 'navidrome-public-share';
|
||||
|
||||
export function isPublicShareTrackId(trackId: string | null | undefined): boolean {
|
||||
return typeof trackId === 'string' && trackId.startsWith('ndshare:');
|
||||
}
|
||||
|
||||
export function navidromePublicShareToTracks(
|
||||
ref: NavidromePublicShareRef,
|
||||
info: NavidromePublicShareInfo,
|
||||
): Track[] {
|
||||
return info.tracks.map((t, index) => ({
|
||||
id: `ndshare:${info.id}:${index}`,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
album: t.album,
|
||||
albumId: '',
|
||||
duration: t.duration,
|
||||
serverId: NAVIDROME_PUBLIC_SHARE_SERVER_ID,
|
||||
directStreamUrl: buildNavidromePublicStreamUrl(ref, t.id),
|
||||
directCoverArtUrl: buildNavidromePublicCoverUrl(ref, t.id),
|
||||
}));
|
||||
}
|
||||
|
||||
/** True while a Navidrome public share queue is live in this session. */
|
||||
export function isActivePublicShareQueue(
|
||||
queueServerId: string | null | undefined,
|
||||
queueItems: QueueItemRef[],
|
||||
): boolean {
|
||||
if (queueItems.length === 0) return false;
|
||||
if (queueServerId === NAVIDROME_PUBLIC_SHARE_SERVER_ID) return true;
|
||||
return queueItems.some(
|
||||
r => r.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(r.trackId),
|
||||
);
|
||||
}
|
||||
|
||||
export function isPublicSharePersistedTrack(track: Track | null | undefined): boolean {
|
||||
if (!track) return false;
|
||||
return track.serverId === NAVIDROME_PUBLIC_SHARE_SERVER_ID || isPublicShareTrackId(track.id);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type NavidromePublicShareTrack = {
|
||||
id: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
export type NavidromePublicShareInfo = {
|
||||
id: string;
|
||||
description: string;
|
||||
downloadable: boolean;
|
||||
tracks: NavidromePublicShareTrack[];
|
||||
imageUrl?: string;
|
||||
};
|
||||
|
||||
export type FetchNavidromePublicShareError =
|
||||
| 'not-found'
|
||||
| 'expired'
|
||||
| 'unreachable'
|
||||
| 'malformed';
|
||||
|
||||
export type FetchNavidromePublicShareResult =
|
||||
| { type: 'ok'; info: NavidromePublicShareInfo }
|
||||
| { type: 'error'; reason: FetchNavidromePublicShareError };
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildNavidromePublicCoverUrl,
|
||||
buildNavidromePublicShareM3uUrl,
|
||||
buildNavidromePublicStreamUrl,
|
||||
extractNavidromePublicShareFromText,
|
||||
parseNavidromePublicShareUrl,
|
||||
} from '@/lib/share/navidromePublicShareUrl';
|
||||
|
||||
const SHARE_ID = 'Ab12Cd34Ef';
|
||||
|
||||
describe('parseNavidromePublicShareUrl', () => {
|
||||
it('parses a root-level share URL', () => {
|
||||
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/${SHARE_ID}`)).toEqual({
|
||||
pageUrl: `https://music.example.com/share/${SHARE_ID}`,
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '',
|
||||
shareId: SHARE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a share URL with base path', () => {
|
||||
expect(parseNavidromePublicShareUrl(`https://music.example.com/navidrome/share/${SHARE_ID}/`)).toEqual({
|
||||
pageUrl: `https://music.example.com/navidrome/share/${SHARE_ID}`,
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '/navidrome',
|
||||
shareId: SHARE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects stream token URLs', () => {
|
||||
expect(parseNavidromePublicShareUrl('https://music.example.com/share/s/jwt.token.here')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects m3u and image paths', () => {
|
||||
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/${SHARE_ID}/m3u`)).toBeNull();
|
||||
expect(parseNavidromePublicShareUrl(`https://music.example.com/share/img/${SHARE_ID}`)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects invalid share ids', () => {
|
||||
expect(parseNavidromePublicShareUrl('https://music.example.com/share/tooshort')).toBeNull();
|
||||
expect(parseNavidromePublicShareUrl('https://music.example.com/share/12345678901')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractNavidromePublicShareFromText', () => {
|
||||
it('finds a share URL embedded in text', () => {
|
||||
const url = `https://music.example.com/share/${SHARE_ID}`;
|
||||
expect(extractNavidromePublicShareFromText(`check this out: ${url}.`)).toEqual({
|
||||
pageUrl: url,
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '',
|
||||
shareId: SHARE_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('stream and m3u URL builders', () => {
|
||||
const ref = {
|
||||
pageUrl: `https://music.example.com/navidrome/share/${SHARE_ID}`,
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '/navidrome',
|
||||
shareId: SHARE_ID,
|
||||
};
|
||||
|
||||
it('builds public stream URLs', () => {
|
||||
expect(buildNavidromePublicStreamUrl(ref, 'jwt-token')).toBe(
|
||||
'https://music.example.com/navidrome/share/s/jwt-token',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds public cover URLs from track stream tokens', () => {
|
||||
expect(buildNavidromePublicCoverUrl(ref, 'jwt-token')).toBe(
|
||||
'https://music.example.com/navidrome/share/img/jwt-token?size=300',
|
||||
);
|
||||
expect(buildNavidromePublicCoverUrl(ref, 'jwt-token', 512)).toBe(
|
||||
'https://music.example.com/navidrome/share/img/jwt-token?size=512',
|
||||
);
|
||||
});
|
||||
|
||||
it('builds m3u URLs', () => {
|
||||
expect(buildNavidromePublicShareM3uUrl(ref)).toBe(
|
||||
`https://music.example.com/navidrome/share/${SHARE_ID}/m3u`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/** Navidrome public share page: `{origin}{basePath}/share/{shareId}` (no auth). */
|
||||
export type NavidromePublicShareRef = {
|
||||
pageUrl: string;
|
||||
origin: string;
|
||||
basePath: string;
|
||||
shareId: string;
|
||||
};
|
||||
|
||||
const SHARE_ID_RE = /^[0-9A-Za-z]{10}$/;
|
||||
|
||||
function normalizeOrigin(url: URL): string {
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
function buildRef(url: URL, basePath: string, shareId: string): NavidromePublicShareRef {
|
||||
const origin = normalizeOrigin(url);
|
||||
const pageUrl = `${origin}${basePath}/share/${shareId}`;
|
||||
return { pageUrl, origin, basePath, shareId };
|
||||
}
|
||||
|
||||
/** Parse a single URL string (must be the share page, not stream/img/m3u). */
|
||||
export function parseNavidromePublicShareUrl(text: string): NavidromePublicShareRef | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(trimmed.includes('://') ? trimmed : `https://${trimmed}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
|
||||
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const shareIdx = parts.lastIndexOf('share');
|
||||
if (shareIdx < 0 || shareIdx !== parts.length - 2) return null;
|
||||
|
||||
const shareId = parts[shareIdx + 1] ?? '';
|
||||
if (!SHARE_ID_RE.test(shareId)) return null;
|
||||
|
||||
const baseParts = parts.slice(0, shareIdx);
|
||||
const basePath = baseParts.length ? `/${baseParts.join('/')}` : '';
|
||||
return buildRef(url, basePath, shareId);
|
||||
}
|
||||
|
||||
/** Find a Navidrome public share URL inside pasted or search text. */
|
||||
export function extractNavidromePublicShareFromText(text: string): NavidromePublicShareRef | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const direct = parseNavidromePublicShareUrl(trimmed);
|
||||
if (direct) return direct;
|
||||
|
||||
const urlRe = /https?:\/\/[^\s<>"']+/gi;
|
||||
for (const match of trimmed.matchAll(urlRe)) {
|
||||
const candidate = match[0]!.replace(/[),.;!?]+$/g, '');
|
||||
const parsed = parseNavidromePublicShareUrl(candidate);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildNavidromePublicStreamUrl(
|
||||
ref: NavidromePublicShareRef,
|
||||
streamToken: string,
|
||||
): string {
|
||||
const prefix = ref.basePath ? `${ref.origin}${ref.basePath}` : ref.origin;
|
||||
return `${prefix}/share/s/${streamToken}`;
|
||||
}
|
||||
|
||||
/** Public artwork — reuses the track stream JWT (`/share/img/{token}`). */
|
||||
export function buildNavidromePublicCoverUrl(
|
||||
ref: NavidromePublicShareRef,
|
||||
streamToken: string,
|
||||
size = 300,
|
||||
): string {
|
||||
const prefix = ref.basePath ? `${ref.origin}${ref.basePath}` : ref.origin;
|
||||
return `${prefix}/share/img/${streamToken}?size=${size}`;
|
||||
}
|
||||
|
||||
export function buildNavidromePublicShareM3uUrl(ref: NavidromePublicShareRef): string {
|
||||
return `${ref.pageUrl}/m3u`;
|
||||
}
|
||||
@@ -122,4 +122,28 @@ describe('share search parsing', () => {
|
||||
|
||||
expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' });
|
||||
});
|
||||
|
||||
it('detects Navidrome public share URLs before psysonic2 links', () => {
|
||||
const url = 'https://music.example.com/navidrome/share/Ab12Cd34Ef';
|
||||
expect(parseShareSearchText(url)).toEqual({
|
||||
type: 'navidrome-public',
|
||||
publicShareRef: {
|
||||
pageUrl: url,
|
||||
origin: 'https://music.example.com',
|
||||
basePath: '/navidrome',
|
||||
shareId: 'Ab12Cd34Ef',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers Navidrome URL when both patterns appear in text', () => {
|
||||
const magic = encodeSharePayload({
|
||||
srv: 'https://music.example.com',
|
||||
k: 'track',
|
||||
id: 'song-1',
|
||||
});
|
||||
const url = 'https://music.example.com/share/Ab12Cd34Ef';
|
||||
const match = parseShareSearchText(`${url} ${magic}`);
|
||||
expect(match?.type).toBe('navidrome-public');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
decodeSharePayloadFromText,
|
||||
PSYSONIC_SHARE_PREFIX,
|
||||
} from '@/lib/share/shareLink';
|
||||
import {
|
||||
extractNavidromePublicShareFromText,
|
||||
type NavidromePublicShareRef,
|
||||
} from '@/lib/share/navidromePublicShareUrl';
|
||||
|
||||
export type QueueableShareSearchPayload =
|
||||
| { srv: string; k: 'track'; id: string }
|
||||
@@ -12,6 +16,7 @@ export type ArtistShareSearchPayload = { srv: string; k: 'artist'; id: string };
|
||||
export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string };
|
||||
|
||||
export type ShareSearchMatch =
|
||||
| { type: 'navidrome-public'; publicShareRef: NavidromePublicShareRef }
|
||||
| { type: 'queueable'; payload: QueueableShareSearchPayload }
|
||||
| { type: 'album'; payload: AlbumShareSearchPayload }
|
||||
| { type: 'artist'; payload: ArtistShareSearchPayload }
|
||||
@@ -20,6 +25,11 @@ export type ShareSearchMatch =
|
||||
|
||||
export function parseShareSearchText(text: string): ShareSearchMatch | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const navidromeRef = extractNavidromePublicShareFromText(trimmed);
|
||||
if (navidromeRef) return { type: 'navidrome-public', publicShareRef: navidromeRef };
|
||||
|
||||
if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null;
|
||||
|
||||
const payload = decodeSharePayloadFromText(trimmed);
|
||||
|
||||
@@ -4,6 +4,14 @@ import { serverIndexKeyFromUrl } from '@/lib/server/serverIndexKey';
|
||||
import { findServerIdForShareUrl } from '@/lib/share/shareLink';
|
||||
import type { ShareSearchMatch } from '@/lib/share/shareSearch';
|
||||
|
||||
function hostFromOrigin(origin: string): string {
|
||||
try {
|
||||
return new URL(origin).host;
|
||||
} catch {
|
||||
return origin;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display name for the share link's origin server when it differs from the
|
||||
* active server. Returns null when the link targets the active server, is
|
||||
@@ -16,6 +24,10 @@ export function shareServerOriginLabel(
|
||||
): string | null {
|
||||
if (!shareMatch || shareMatch.type === 'unsupported') return null;
|
||||
|
||||
if (shareMatch.type === 'navidrome-public') {
|
||||
return hostFromOrigin(shareMatch.publicShareRef.origin);
|
||||
}
|
||||
|
||||
const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv);
|
||||
if (!shareServerId || shareServerId === activeServerId) return null;
|
||||
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Пусни опашка',
|
||||
playQueueing: 'Стартиране на възпроизвеждане…',
|
||||
genericError: 'Линкът за споделяне не може да бъде отворен.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'Freigabe-Link konnte nicht geöffnet werden.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'Could not open the share link.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'No se pudo abrir el enlace para compartir.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'Impossible d’ouvrir le lien de partage.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Sor lejátszása',
|
||||
playQueueing: 'Lejátszás indítása…',
|
||||
genericError: 'A megosztási link megnyitása nem sikerült.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Riproduci coda',
|
||||
playQueueing: 'Avvio della riproduzione…',
|
||||
genericError: 'Impossibile aprire il link di condivisione.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: '再生キュー',
|
||||
playQueueing: '再生を開始中…',
|
||||
genericError: '共有リンクを開けませんでした。',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'Klarte ikke å åpne delingslenken.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'De deellink kon niet worden geopend.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Odtwórz kolejki',
|
||||
playQueueing: 'Rozpoczynanie odtwarzania…',
|
||||
genericError: 'Nie można otworzyć linku udostępniania.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -17,4 +17,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: 'Nu s-a putut deschide link-ul distribuit.',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
@@ -19,4 +19,18 @@ export const sharePaste = {
|
||||
playQueue: 'Воспроизвести очередь',
|
||||
playQueueing: 'Запуск воспроизведения…',
|
||||
genericError: 'Не удалось открыть ссылку для обмена.',
|
||||
navidromeShareTitle: 'Публичная ссылка Navidrome ({{count}} треков)',
|
||||
navidromeSharePreview: 'Предпросмотр',
|
||||
navidromeSharePlay: 'Воспроизвести',
|
||||
navidromeSharePlaying: 'Запуск воспроизведения…',
|
||||
navidromeShareLoading: 'Загрузка публичной ссылки…',
|
||||
navidromeShareNotFound: 'Публичная ссылка не найдена.',
|
||||
navidromeShareExpired: 'Срок действия публичной ссылки истёк.',
|
||||
navidromeShareUnreachable: 'Не удалось связаться с сервером. Проверьте ссылку или откройте её в браузере.',
|
||||
navidromeShareMalformed: 'Не удалось прочитать метаданные ссылки.',
|
||||
openInBrowser: 'Открыть в браузере',
|
||||
openedNavidromePublic_one: 'Воспроизводится {{count}} трек из публичной ссылки.',
|
||||
openedNavidromePublic_few: 'Воспроизводится {{count}} трека из публичной ссылки.',
|
||||
openedNavidromePublic_many: 'Воспроизводится {{count}} треков из публичной ссылки.',
|
||||
openedNavidromePublic_other: 'Воспроизводится {{count}} треков из публичной ссылки.',
|
||||
};
|
||||
|
||||
@@ -16,4 +16,16 @@ export const sharePaste = {
|
||||
playQueue: 'Play queue',
|
||||
playQueueing: 'Starting playback…',
|
||||
genericError: '无法打开分享链接。',
|
||||
navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
|
||||
navidromeSharePreview: 'Preview share',
|
||||
navidromeSharePlay: 'Play share',
|
||||
navidromeSharePlaying: 'Starting playback…',
|
||||
navidromeShareLoading: 'Loading public share…',
|
||||
navidromeShareNotFound: 'This public share link was not found.',
|
||||
navidromeShareExpired: 'This public share link has expired.',
|
||||
navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
|
||||
navidromeShareMalformed: 'Could not read share metadata from this page.',
|
||||
openInBrowser: 'Open in browser',
|
||||
openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
|
||||
openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user