diff --git a/CHANGELOG.md b/CHANGELOG.md
index 708ea117..24c5f456 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts
index 490648e2..5f694345 100644
--- a/src/config/settingsCredits.ts
+++ b/src/config/settingsCredits.ts
@@ -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)',
],
},
{
diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx
index 3edd55a5..65eb69fa 100644
--- a/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx
+++ b/src/features/fullscreenPlayer/components/FullscreenPlayerImmersive.tsx
@@ -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
diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerPrism.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerPrism.tsx
index 8e845bdc..8c602669 100644
--- a/src/features/fullscreenPlayer/components/FullscreenPlayerPrism.tsx
+++ b/src/features/fullscreenPlayer/components/FullscreenPlayerPrism.tsx
@@ -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);
diff --git a/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx b/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx
index 73cda507..65b04669 100644
--- a/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx
+++ b/src/features/fullscreenPlayer/components/FullscreenPlayerStatic.tsx
@@ -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
diff --git a/src/features/nowPlaying/components/Hero.tsx b/src/features/nowPlaying/components/Hero.tsx
index 066e2167..6fb1cbbe 100644
--- a/src/features/nowPlaying/components/Hero.tsx
+++ b/src/features/nowPlaying/components/Hero.tsx
@@ -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 (
- {coverRef ? (
+ {directCoverArtUrl ? (
+
+ ) : coverRef ? (
- {resolvedCover ? (
-
+ {displayCover ? (
+
) : (
diff --git a/src/features/nowPlaying/pages/NowPlaying.tsx b/src/features/nowPlaying/pages/NowPlaying.tsx
index b5e183b7..119db17f 100644
--- a/src/features/nowPlaying/pages/NowPlaying.tsx
+++ b/src/features/nowPlaying/pages/NowPlaying.tsx
@@ -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}
diff --git a/src/features/playback/components/playerBar/PlayerTrackInfo.tsx b/src/features/playback/components/playerBar/PlayerTrackInfo.tsx
index a91512dc..2b82fbc2 100644
--- a/src/features/playback/components/playerBar/PlayerTrackInfo.tsx
+++ b/src/features/playback/components/playerBar/PlayerTrackInfo.tsx
@@ -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({
)
+ ) : !isRadio && directCoverUrl ? (
+
) : !isRadio && activeCoverRef ? (
({
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();
+ 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');
+ });
});
diff --git a/src/features/playback/store/applyServerPlayQueue.ts b/src/features/playback/store/applyServerPlayQueue.ts
index 858f8b3e..f9adeb53 100644
--- a/src/features/playback/store/applyServerPlayQueue.ts
+++ b/src/features/playback/store/applyServerPlayQueue.ts
@@ -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';
diff --git a/src/features/playback/store/audioEventHandlers.ts b/src/features/playback/store/audioEventHandlers.ts
index caf62565..124f2c27 100644
--- a/src/features/playback/store/audioEventHandlers.ts
+++ b/src/features/playback/store/audioEventHandlers.ts
@@ -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 (
diff --git a/src/features/playback/store/crossfadePreload.ts b/src/features/playback/store/crossfadePreload.ts
index 45d568a1..62ebff9d 100644
--- a/src/features/playback/store/crossfadePreload.ts
+++ b/src/features/playback/store/crossfadePreload.ts
@@ -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:
diff --git a/src/features/playback/store/engineLoadTrackAtPosition.ts b/src/features/playback/store/engineLoadTrackAtPosition.ts
index 43273b40..8af87e95 100644
--- a/src/features/playback/store/engineLoadTrackAtPosition.ts
+++ b/src/features/playback/store/engineLoadTrackAtPosition.ts
@@ -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),
diff --git a/src/features/playback/store/gaplessChainPreload.ts b/src/features/playback/store/gaplessChainPreload.ts
index 7dddaab3..6e9e2999 100644
--- a/src/features/playback/store/gaplessChainPreload.ts
+++ b/src/features/playback/store/gaplessChainPreload.ts
@@ -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,
diff --git a/src/features/playback/store/gaplessQueueAdvance.ts b/src/features/playback/store/gaplessQueueAdvance.ts
index 7322daa1..cdd93a30 100644
--- a/src/features/playback/store/gaplessQueueAdvance.ts
+++ b/src/features/playback/store/gaplessQueueAdvance.ts
@@ -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,
diff --git a/src/features/playback/store/playTrackAction.ts b/src/features/playback/store/playTrackAction.ts
index 9d1ce902..2f920099 100644
--- a/src/features/playback/store/playTrackAction.ts
+++ b/src/features/playback/store/playTrackAction.ts
@@ -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();
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]);
}
diff --git a/src/features/playback/store/playerStore.persistence.test.ts b/src/features/playback/store/playerStore.persistence.test.ts
index 7f681643..99249964 100644
--- a/src/features/playback/store/playerStore.persistence.test.ts
+++ b/src/features/playback/store/playerStore.persistence.test.ts
@@ -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();
+ });
});
diff --git a/src/features/playback/store/playerStore.ts b/src/features/playback/store/playerStore.ts
index aab43117..e69796cb 100644
--- a/src/features/playback/store/playerStore.ts
+++ b/src/features/playback/store/playerStore.ts
@@ -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()(
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()(
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,
diff --git a/src/features/playback/store/queueItemRef.test.ts b/src/features/playback/store/queueItemRef.test.ts
index 870457d8..a19cbf35 100644
--- a/src/features/playback/store/queueItemRef.test.ts
+++ b/src/features/playback/store/queueItemRef.test.ts
@@ -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');
+ });
});
diff --git a/src/features/playback/store/queueItemRef.ts b/src/features/playback/store/queueItemRef.ts
index 191cab79..014218fc 100644
--- a/src/features/playback/store/queueItemRef.ts
+++ b/src/features/playback/store/queueItemRef.ts
@@ -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;
});
}
diff --git a/src/features/playback/store/queueSync.ts b/src/features/playback/store/queueSync.ts
index 9491e738..d52c915b 100644
--- a/src/features/playback/store/queueSync.ts
+++ b/src/features/playback/store/queueSync.ts
@@ -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). */
diff --git a/src/features/playback/store/queueTrackResolver.test.ts b/src/features/playback/store/queueTrackResolver.test.ts
index 62803c9d..4bb3777e 100644
--- a/src/features/playback/store/queueTrackResolver.test.ts
+++ b/src/features/playback/store/queueTrackResolver.test.ts
@@ -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();
diff --git a/src/features/playback/store/queueTrackResolver.ts b/src/features/playback/store/queueTrackResolver.ts
index c855e28d..781e8a79 100644
--- a/src/features/playback/store/queueTrackResolver.ts
+++ b/src/features/playback/store/queueTrackResolver.ts
@@ -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): 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();
+ 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 {
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]));
diff --git a/src/features/playback/store/queueTrackView.ts b/src/features/playback/store/queueTrackView.ts
index 23540d50..7081a2a0 100644
--- a/src/features/playback/store/queueTrackView.ts
+++ b/src/features/playback/store/queueTrackView.ts
@@ -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 =
diff --git a/src/features/playback/store/queueUndoAudioRestore.test.ts b/src/features/playback/store/queueUndoAudioRestore.test.ts
index 1a5f3978..07c7f8e8 100644
--- a/src/features/playback/store/queueUndoAudioRestore.test.ts
+++ b/src/features/playback/store/queueUndoAudioRestore.test.ts
@@ -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', () => ({
diff --git a/src/features/playback/store/resumeAction.ts b/src/features/playback/store/resumeAction.ts
index dcd2423a..57ec31f1 100644
--- a/src/features/playback/store/resumeAction.ts
+++ b/src/features/playback/store/resumeAction.ts
@@ -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);
diff --git a/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts b/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts
index 6215fb31..c10caa72 100644
--- a/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts
+++ b/src/features/playback/utils/playback/resolvePlaybackUrl.test.ts
@@ -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');
diff --git a/src/features/playback/utils/playback/resolvePlaybackUrl.ts b/src/features/playback/utils/playback/resolvePlaybackUrl.ts
index 98db5d20..9588cb7e 100644
--- a/src/features/playback/utils/playback/resolvePlaybackUrl.ts
+++ b/src/features/playback/utils/playback/resolvePlaybackUrl.ts
@@ -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,
+ serverId?: string,
+): string {
+ if (track.directStreamUrl) return track.directStreamUrl;
+ return resolvePlaybackUrl(track.id, serverId);
+}
diff --git a/src/features/queue/components/QueueCurrentTrack.tsx b/src/features/queue/components/QueueCurrentTrack.tsx
index 716c78ef..53bb4370 100644
--- a/src/features/queue/components/QueueCurrentTrack.tsx
+++ b/src/features/queue/components/QueueCurrentTrack.tsx
@@ -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({
})()}
- {coverRef ? (
+ {directCoverUrl ? (
+
+ ) : coverRef ? (
{
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();
diff --git a/src/features/search/components/LiveSearchDropdown.test.tsx b/src/features/search/components/LiveSearchDropdown.test.tsx
index df25c8e2..67bda62e 100644
--- a/src/features/search/components/LiveSearchDropdown.test.tsx
+++ b/src/features/search/components/LiveSearchDropdown.test.tsx
@@ -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;
const results: SearchResults = {
diff --git a/src/features/search/components/LiveSearchDropdown.tsx b/src/features/search/components/LiveSearchDropdown.tsx
index 9018067e..d8800179 100644
--- a/src/features/search/components/LiveSearchDropdown.tsx
+++ b/src/features/search/components/LiveSearchDropdown.tsx
@@ -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}
/>
)}
diff --git a/src/features/search/components/MobileSearchOverlay.tsx b/src/features/search/components/MobileSearchOverlay.tsx
index d98aafb0..36f34cd0 100644
--- a/src/features/search/components/MobileSearchOverlay.tsx
+++ b/src/features/search/components/MobileSearchOverlay.tsx
@@ -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}
/>
)}
diff --git a/src/features/search/components/NavidromePublicShareModal.tsx b/src/features/search/components/NavidromePublicShareModal.tsx
new file mode 100644
index 00000000..7a70a8ec
--- /dev/null
+++ b/src/features/search/components/NavidromePublicShareModal.tsx
@@ -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,
+ 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 {t('sharePaste.navidromeShareLoading')}
;
+ }
+
+ if (preview.navidromeShareError) {
+ return (
+
+ {navidromeShareErrorMessage(preview.navidromeShareError, t)}
+
+ );
+ }
+
+ const info = preview.navidromeShareInfo;
+ if (!info) {
+ return (
+
+ {t('sharePaste.navidromeShareMalformed')}
+
+ );
+ }
+
+ return (
+ <>
+ {info.imageUrl && (
+
+
+
+ )}
+
+
+ {info.tracks.map(track => (
+
+
+
+
+
+
{track.title}
+
+ {track.artist}{track.album ? ` · ${track.album}` : ''}
+
+
+ {track.duration > 0 && (
+ {formatTrackTime(track.duration)}
+ )}
+
+ ))}
+
+
+ >
+ );
+}
+
+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(
+ {
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onMouseDown={e => {
+ if (e.target === e.currentTarget) onClose();
+ }}
+ >
+
{
+ e.preventDefault();
+ e.stopPropagation();
+ }}
+ onMouseDown={e => e.stopPropagation()}
+ >
+
+
+
+
+
+
+
+
+
+
+ {t('common.cancel')}
+
+ void openUrl(shareRef.pageUrl)}
+ >
+ {t('sharePaste.openInBrowser')}
+
+ void onPlay()}
+ >
+ {playBusy ? t('sharePaste.navidromeSharePlaying') : t('sharePaste.navidromeSharePlay')}
+
+
+
+
,
+ document.body,
+ );
+}
diff --git a/src/features/search/components/ShareSearchResults.tsx b/src/features/search/components/ShareSearchResults.tsx
index 93660014..0624b945 100644
--- a/src/features/search/components/ShareSearchResults.tsx
+++ b/src/features/search/components/ShareSearchResults.tsx
@@ -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;
+ onPlayNavidromePublic?: () => void | Promise;
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(
+
+
+
+
{t('common.loading')}
+
{sub(t('sharePaste.navidromeShareLoading'))}
+
+
,
+ );
+ }
+
+ 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(
+ ,
+ );
+ }
+
+ if (navidromeShareInfo) {
+ const handlePlay = () => {
+ void Promise.resolve(onPlayNavidromePublic?.()).then(ok => {
+ if (ok) setNavidromePreviewOpen(false);
+ });
+ };
+ return (
+ <>
+ {wrap(
+
+
+
+
+
{title}
+
+ {shareQueueBusy
+ ? sub(t('sharePaste.navidromeSharePlaying'))
+ : sub(t('search.shareQueueTitle', { count }))}
+
+
+
+
{
+ e.stopPropagation();
+ setNavidromePreviewOpen(true);
+ }}
+ aria-label={t('sharePaste.navidromeSharePreview')}
+ >
+
+
+
,
+ )}
+ setNavidromePreviewOpen(false)}
+ publicShareRef={shareMatch.publicShareRef}
+ preview={{ navidromeShareInfo, navidromeShareResolving, navidromeShareError }}
+ hostLabel={shareServerLabel}
+ onPlay={handlePlay}
+ playBusy={shareQueueBusy}
+ />
+ >
+ );
+ }
+ }
+
if (shareMatch.type === 'artist') {
if (shareArtistResolving) {
return wrap(
diff --git a/src/features/search/hooks/useNavidromePublicSharePreview.ts b/src/features/search/hooks/useNavidromePublicSharePreview.ts
new file mode 100644
index 00000000..2828271c
--- /dev/null
+++ b/src/features/search/hooks/useNavidromePublicSharePreview.ts
@@ -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(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;
+}
diff --git a/src/features/search/hooks/useShareSearch.ts b/src/features/search/hooks/useShareSearch.ts
index 56a6006f..76b5318e 100644
--- a/src/features/search/hooks/useShareSearch.ts
+++ b/src/features/search/hooks/useShareSearch.ts
@@ -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,
};
}
diff --git a/src/features/search/index.ts b/src/features/search/index.ts
index b76ad5e7..8c5d2b52 100644
--- a/src/features/search/index.ts
+++ b/src/features/search/index.ts
@@ -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';
diff --git a/src/features/share/components/PasteClipboardHandler.tsx b/src/features/share/components/PasteClipboardHandler.tsx
index 9ca40e7a..912f5921 100644
--- a/src/features/share/components/PasteClipboardHandler.tsx
+++ b/src/features/share/components/PasteClipboardHandler.tsx
@@ -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 = {
* 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;
+type QueuePastePayload = Extract<
+ NonNullable>,
+ { 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(null);
+ const [navidromePaste, setNavidromePaste] = useState(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 && (
+ void confirmNavidromePaste()}
+ playBusy={navidromePasteBusy}
+ />
+ )}
{queuePaste && (
{
+ 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;
+}
diff --git a/src/lib/media/trackTypes.ts b/src/lib/media/trackTypes.ts
index 83820dd0..f4b699b7 100644
--- a/src/lib/media/trackTypes.ts
+++ b/src/lib/media/trackTypes.ts
@@ -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;
}
diff --git a/src/lib/share/fetchNavidromePublicShare.test.ts b/src/lib/share/fetchNavidromePublicShare.test.ts
new file mode 100644
index 00000000..a5d13d11
--- /dev/null
+++ b/src/lib/share/fetchNavidromePublicShare.test.ts
@@ -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 = ``;
+ expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
+ });
+
+ it('extracts Navidrome string-assigned __SHARE_INFO__ (escaped JSON in JS quotes)', () => {
+ const html = ``;
+ expect(parseShareInfoFromHtml(html)).toEqual(shareInfoJson);
+ });
+
+ it('parses real Navidrome v0.63 HTML assignment shape', () => {
+ const html = ``;
+ 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('')).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 = ` `;
+ 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('', { 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');
+ }
+ });
+});
diff --git a/src/lib/share/fetchNavidromePublicShare.ts b/src/lib/share/fetchNavidromePublicShare.ts
new file mode 100644
index 00000000..4a773c42
--- /dev/null
+++ b/src/lib/share/fetchNavidromePublicShare.ts
@@ -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;
+
+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;
+ 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;
+ 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(/ {
+ 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' };
+ }
+}
diff --git a/src/lib/share/navidromePublicSharePlayback.test.ts b/src/lib/share/navidromePublicSharePlayback.test.ts
new file mode 100644
index 00000000..fb6f8dce
--- /dev/null
+++ b/src/lib/share/navidromePublicSharePlayback.test.ts
@@ -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);
+ });
+});
diff --git a/src/lib/share/navidromePublicSharePlayback.ts b/src/lib/share/navidromePublicSharePlayback.ts
new file mode 100644
index 00000000..1ac924de
--- /dev/null
+++ b/src/lib/share/navidromePublicSharePlayback.ts
@@ -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);
+}
diff --git a/src/lib/share/navidromePublicShareTypes.ts b/src/lib/share/navidromePublicShareTypes.ts
new file mode 100644
index 00000000..b0cc4c30
--- /dev/null
+++ b/src/lib/share/navidromePublicShareTypes.ts
@@ -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 };
diff --git a/src/lib/share/navidromePublicShareUrl.test.ts b/src/lib/share/navidromePublicShareUrl.test.ts
new file mode 100644
index 00000000..908898e0
--- /dev/null
+++ b/src/lib/share/navidromePublicShareUrl.test.ts
@@ -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`,
+ );
+ });
+});
diff --git a/src/lib/share/navidromePublicShareUrl.ts b/src/lib/share/navidromePublicShareUrl.ts
new file mode 100644
index 00000000..2e1263cf
--- /dev/null
+++ b/src/lib/share/navidromePublicShareUrl.ts
@@ -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`;
+}
diff --git a/src/lib/share/shareSearch.test.ts b/src/lib/share/shareSearch.test.ts
index 6dd89016..7d462920 100644
--- a/src/lib/share/shareSearch.test.ts
+++ b/src/lib/share/shareSearch.test.ts
@@ -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');
+ });
});
diff --git a/src/lib/share/shareSearch.ts b/src/lib/share/shareSearch.ts
index 37afd57b..1c15ffdb 100644
--- a/src/lib/share/shareSearch.ts
+++ b/src/lib/share/shareSearch.ts
@@ -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);
diff --git a/src/lib/share/shareServerOriginLabel.ts b/src/lib/share/shareServerOriginLabel.ts
index f6919150..c35dcc23 100644
--- a/src/lib/share/shareServerOriginLabel.ts
+++ b/src/lib/share/shareServerOriginLabel.ts
@@ -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;
diff --git a/src/locales/bg/sharePaste.ts b/src/locales/bg/sharePaste.ts
index 9e0cc57d..eb175145 100644
--- a/src/locales/bg/sharePaste.ts
+++ b/src/locales/bg/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/de/sharePaste.ts b/src/locales/de/sharePaste.ts
index f6d5467f..55d4e1c0 100644
--- a/src/locales/de/sharePaste.ts
+++ b/src/locales/de/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/en/sharePaste.ts b/src/locales/en/sharePaste.ts
index f34386a0..c814f0fc 100644
--- a/src/locales/en/sharePaste.ts
+++ b/src/locales/en/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/es/sharePaste.ts b/src/locales/es/sharePaste.ts
index 69c64e95..8272f666 100644
--- a/src/locales/es/sharePaste.ts
+++ b/src/locales/es/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/fr/sharePaste.ts b/src/locales/fr/sharePaste.ts
index 19e47bdd..854c218f 100644
--- a/src/locales/fr/sharePaste.ts
+++ b/src/locales/fr/sharePaste.ts
@@ -17,4 +17,16 @@ export const sharePaste = {
playQueue: 'Play queue',
playQueueing: 'Starting playback…',
genericError: 'Impossible d’ouvrir le lien de partage.',
+ navidromeShareTitle: 'Navidrome share ({{count}} tracks)',
+ navidromeSharePreview: 'Preview share',
+ navidromeSharePlay: 'Play share',
+ navidromeSharePlaying: 'Starting playback…',
+ navidromeShareLoading: 'Loading public share…',
+ navidromeShareNotFound: 'This public share link was not found.',
+ navidromeShareExpired: 'This public share link has expired.',
+ navidromeShareUnreachable: 'Could not reach the server. Check the link or open it in your browser.',
+ navidromeShareMalformed: 'Could not read share metadata from this page.',
+ openInBrowser: 'Open in browser',
+ openedNavidromePublic_one: 'Playing {{count}} track from the public share.',
+ openedNavidromePublic_other: 'Playing {{count}} tracks from the public share.',
};
diff --git a/src/locales/hu/sharePaste.ts b/src/locales/hu/sharePaste.ts
index c3a429d6..ede6a1be 100644
--- a/src/locales/hu/sharePaste.ts
+++ b/src/locales/hu/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/it/sharePaste.ts b/src/locales/it/sharePaste.ts
index fabd53a2..81a0756b 100644
--- a/src/locales/it/sharePaste.ts
+++ b/src/locales/it/sharePaste.ts
@@ -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.',
};
\ No newline at end of file
diff --git a/src/locales/ja/sharePaste.ts b/src/locales/ja/sharePaste.ts
index d21842de..85d12c7a 100644
--- a/src/locales/ja/sharePaste.ts
+++ b/src/locales/ja/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/nb/sharePaste.ts b/src/locales/nb/sharePaste.ts
index 5fcb84d5..8bfec414 100644
--- a/src/locales/nb/sharePaste.ts
+++ b/src/locales/nb/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/nl/sharePaste.ts b/src/locales/nl/sharePaste.ts
index c70cb7c0..0d047c46 100644
--- a/src/locales/nl/sharePaste.ts
+++ b/src/locales/nl/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/pl/sharePaste.ts b/src/locales/pl/sharePaste.ts
index 69c0edc8..19437588 100644
--- a/src/locales/pl/sharePaste.ts
+++ b/src/locales/pl/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/ro/sharePaste.ts b/src/locales/ro/sharePaste.ts
index cb0557ce..fbfe8b00 100644
--- a/src/locales/ro/sharePaste.ts
+++ b/src/locales/ro/sharePaste.ts
@@ -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.',
};
diff --git a/src/locales/ru/sharePaste.ts b/src/locales/ru/sharePaste.ts
index aeaea042..bf072467 100644
--- a/src/locales/ru/sharePaste.ts
+++ b/src/locales/ru/sharePaste.ts
@@ -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}} треков из публичной ссылки.',
};
diff --git a/src/locales/zh/sharePaste.ts b/src/locales/zh/sharePaste.ts
index ad0c4708..7e3cecec 100644
--- a/src/locales/zh/sharePaste.ts
+++ b/src/locales/zh/sharePaste.ts
@@ -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.',
};