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 ? ( + {currentTrack?.album ) : !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()} + > + + +
+ + {hostLabel && ( +

+ {t('search.shareFromServer', { server: hostLabel })} +

+ )} +
+ +
+ +
+ +
+ + + +
+
+
, + 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( +
+ +
+
{errMsg}
+
{sub('')}
+
+
, + ); + } + + if (navidromeShareInfo) { + const handlePlay = () => { + void Promise.resolve(onPlayNavidromePublic?.()).then(ok => { + if (ok) setNavidromePreviewOpen(false); + }); + }; + return ( + <> + {wrap( +
+ + +
, + )} + 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.', };