feat(share): open Navidrome public share links for anonymous playback (#1275)

This commit is contained in:
cucadmuh
2026-07-12 17:21:03 +03:00
committed by GitHub
parent 83aaf253cc
commit 097438f9da
68 changed files with 1754 additions and 53 deletions
+7
View File
@@ -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
+1
View File
@@ -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
+5 -2
View File
@@ -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"
+1
View File
@@ -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,
+30 -5
View File
@@ -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();
});
});
+26 -5
View File
@@ -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;
});
}
+3 -1
View File
@@ -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', () => ({
+2 -2
View File
@@ -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;
}
+39 -2
View File
@@ -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,
};
}
+2
View File
@@ -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
+1
View File
@@ -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;
}
+8
View File
@@ -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');
}
});
});
+227
View File
@@ -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`,
);
});
});
+85
View File
@@ -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`;
}
+24
View File
@@ -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');
});
});
+10
View File
@@ -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);
+12
View File
@@ -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;
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -17,4 +17,16 @@ export const sharePaste = {
playQueue: 'Play queue',
playQueueing: 'Starting playback…',
genericError: 'Impossible douvrir 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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+12
View File
@@ -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.',
};
+14
View File
@@ -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}} треков из публичной ссылки.',
};
+12
View File
@@ -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.',
};