From e7431b94b8f46243e09c42ada65b2ee81614d79e Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 15 May 2026 13:38:35 +0300 Subject: [PATCH] feat(search): queue pasted share links from Live Search and mobile search Implement share-link detection in search (track, queue, album, artist, composer): enqueue tracks/queues without interrupting playback; preview album/artist/composer without switching the active server; queue preview modal with scrollable track list. Based on community PR #551. Co-authored-by: Daniel Wagner --- CHANGELOG.md | 9 + src/api/subsonic.contract.test.ts | 27 +- src/api/subsonicEntityWithCredentials.ts | 56 +++ src/api/subsonicStreamUrl.ts | 43 +- src/components/LiveSearch.tsx | 82 +++- src/components/MobileSearchOverlay.tsx | 44 +- .../search/ShareQueuePreviewModal.tsx | 182 +++++++ src/components/search/ShareSearchResults.tsx | 454 ++++++++++++++++++ src/hooks/useShareQueuePreview.ts | 56 +++ src/hooks/useShareSearch.ts | 98 ++++ src/hooks/useShareSearchPreview.ts | 137 ++++++ src/locales/de/search.ts | 16 + src/locales/en/search.ts | 16 + src/locales/es/search.ts | 16 + src/locales/fr/search.ts | 16 + src/locales/nb/search.ts | 16 + src/locales/nl/search.ts | 16 + src/locales/ro/search.ts | 16 + src/locales/ru/search.ts | 20 + src/locales/zh/search.ts | 16 + src/styles/components/index.css | 1 + src/styles/components/live-search.css | 14 + src/styles/components/results-area.css | 9 + .../components/share-queue-preview-modal.css | 215 +++++++++ .../share/enqueueShareSearchPayload.test.ts | 259 ++++++++++ src/utils/share/enqueueShareSearchPayload.ts | 246 ++++++++++ src/utils/share/shareSearch.test.ts | 125 +++++ src/utils/share/shareSearch.ts | 63 +++ .../share/shareServerOriginLabel.test.ts | 46 ++ src/utils/share/shareServerOriginLabel.ts | 25 + 30 files changed, 2323 insertions(+), 16 deletions(-) create mode 100644 src/api/subsonicEntityWithCredentials.ts create mode 100644 src/components/search/ShareQueuePreviewModal.tsx create mode 100644 src/components/search/ShareSearchResults.tsx create mode 100644 src/hooks/useShareQueuePreview.ts create mode 100644 src/hooks/useShareSearch.ts create mode 100644 src/hooks/useShareSearchPreview.ts create mode 100644 src/styles/components/share-queue-preview-modal.css create mode 100644 src/utils/share/enqueueShareSearchPayload.test.ts create mode 100644 src/utils/share/enqueueShareSearchPayload.ts create mode 100644 src/utils/share/shareSearch.test.ts create mode 100644 src/utils/share/shareSearch.ts create mode 100644 src/utils/share/shareServerOriginLabel.test.ts create mode 100644 src/utils/share/shareServerOriginLabel.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 63ed4da2..cae4a1a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,15 @@ Foundational work: faster reviews, narrower diffs, and a safety net under the pa ## Added +### Search — queue pasted share links from Live Search and mobile search + +Inspired by [@DanielWTE](https://github.com/DanielWTE)'s [PR #551](https://github.com/Psychotoxical/psysonic/pull/551). + +* Pasting a `psysonic2-` share link into **Live Search** or the **mobile search overlay** shows a dedicated share row: track and queue links **enqueue** (append) instead of replacing the queue like global paste does; album, artist, and composer links preview metadata **without switching the active server**, then navigate on confirm. +* Queue share links expose a **Preview** action that opens a scrollable track list (resolved lazily against the share server) before **Add to queue**. +* Shared tracks and queues resolve against the matching saved server via explicit credentials; `orbitBulkGuard` applies before bulk enqueue. +* i18n: `search.share*` keys across all 9 locales. + ### HTTP — gzip + brotli decompression for the Rust-side clients **By [@Psychotoxical](https://github.com/Psychotoxical), PR [#704](https://github.com/Psychotoxical/psysonic/pull/704)** diff --git a/src/api/subsonic.contract.test.ts b/src/api/subsonic.contract.test.ts index 4d10df54..2857839c 100644 --- a/src/api/subsonic.contract.test.ts +++ b/src/api/subsonic.contract.test.ts @@ -10,7 +10,14 @@ * Network-bound endpoints (`getAlbum`, `search`, etc.) require axios * mocking and are not in this PR. */ -import { buildCoverArtUrl, buildDownloadUrl, buildStreamUrl, coverArtCacheKey } from './subsonicStreamUrl'; +import { + buildCoverArtUrl, + buildCoverArtUrlForServer, + buildDownloadUrl, + buildStreamUrl, + coverArtCacheKey, + coverArtCacheKeyForServer, +} from './subsonicStreamUrl'; import { beforeEach, describe, expect, it } from 'vitest'; import { parseSubsonicEntityStarRating } from './subsonicRatings'; import { getClient, libraryFilterParams } from './subsonicClient'; @@ -173,6 +180,24 @@ describe('buildCoverArtUrl', () => { }); }); +describe('buildCoverArtUrlForServer', () => { + it('builds getCoverArt URL with explicit server credentials', () => { + const url = new URL(buildCoverArtUrlForServer('https://remote.example', 'bob', 'secret', 'art-9', 40)); + expect(url.origin).toBe('https://remote.example'); + expect(url.pathname).toBe('/rest/getCoverArt.view'); + expect(url.searchParams.get('id')).toBe('art-9'); + expect(url.searchParams.get('size')).toBe('40'); + expect(url.searchParams.get('u')).toBe('bob'); + expect(url.searchParams.get('t')).toBeTruthy(); + }); +}); + +describe('coverArtCacheKeyForServer', () => { + it('scopes cache keys by server id', () => { + expect(coverArtCacheKeyForServer('srv-b', 'cover-1', 80)).toBe('srv-b:cover:cover-1:80'); + }); +}); + describe('buildDownloadUrl', () => { it('returns a /rest/download.view URL with id + auth params', () => { setUpServer({ url: 'https://music.example.com' }); diff --git a/src/api/subsonicEntityWithCredentials.ts b/src/api/subsonicEntityWithCredentials.ts new file mode 100644 index 00000000..b0749038 --- /dev/null +++ b/src/api/subsonicEntityWithCredentials.ts @@ -0,0 +1,56 @@ +import { apiWithCredentials } from './subsonicClient'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from './subsonicTypes'; + +export async function getSongWithCredentials( + serverUrl: string, + username: string, + password: string, + id: string, +): Promise { + try { + const data = await apiWithCredentials<{ song: SubsonicSong }>( + serverUrl, + username, + password, + 'getSong.view', + { id }, + ); + return data.song ?? null; + } catch { + return null; + } +} + +export async function getAlbumWithCredentials( + serverUrl: string, + username: string, + password: string, + id: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + const data = await apiWithCredentials<{ album: SubsonicAlbum & { song: SubsonicSong[] } }>( + serverUrl, + username, + password, + 'getAlbum.view', + { id }, + ); + const { song, ...album } = data.album; + return { album, songs: song ?? [] }; +} + +export async function getArtistWithCredentials( + serverUrl: string, + username: string, + password: string, + id: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { + const data = await apiWithCredentials<{ artist: SubsonicArtist & { album: SubsonicAlbum[] } }>( + serverUrl, + username, + password, + 'getArtist.view', + { id }, + ); + const { album, ...artist } = data.artist; + return { artist, albums: album ?? [] }; +} diff --git a/src/api/subsonicStreamUrl.ts b/src/api/subsonicStreamUrl.ts index 67410a47..c64c1bf7 100644 --- a/src/api/subsonicStreamUrl.ts +++ b/src/api/subsonicStreamUrl.ts @@ -1,6 +1,21 @@ import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; -import { SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; +import { restBaseFromUrl, SUBSONIC_CLIENT, secureRandomSalt } from './subsonicClient'; + +function coverArtQueryParams(username: string, password: string, id: string, size: number): URLSearchParams { + const salt = secureRandomSalt(); + const token = md5(password + salt); + return new URLSearchParams({ + id, + size: String(size), + u: username, + t: token, + s: salt, + v: '1.16.1', + c: SUBSONIC_CLIENT, + f: 'json', + }); +} export function buildStreamUrl(id: string): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); @@ -19,23 +34,33 @@ export function buildStreamUrl(id: string): string { /** Stable cache key for cover art — does not include ephemeral auth params. */ export function coverArtCacheKey(id: string, size = 256): string { const server = useAuthStore.getState().getActiveServer(); - return `${server?.id ?? '_'}:cover:${id}:${size}`; + return coverArtCacheKeyForServer(server?.id ?? '_', id, size); +} + +export function coverArtCacheKeyForServer(serverId: string, id: string, size = 256): string { + return `${serverId}:cover:${id}:${size}`; } export function buildCoverArtUrl(id: string, size = 256): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); const baseUrl = getBaseUrl(); - const salt = secureRandomSalt(); - const token = md5((server?.password ?? '') + salt); - const p = new URLSearchParams({ - id, size: String(size), - u: server?.username ?? '', - t: token, s: salt, v: '1.16.1', c: SUBSONIC_CLIENT, f: 'json', - }); + const p = coverArtQueryParams(server?.username ?? '', server?.password ?? '', id, size); return `${baseUrl}/rest/getCoverArt.view?${p.toString()}`; } +/** Cover art for a specific saved server (e.g. share-search preview on a non-active server). */ +export function buildCoverArtUrlForServer( + serverUrl: string, + username: string, + password: string, + id: string, + size = 256, +): string { + const p = coverArtQueryParams(username, password, id, size); + return `${restBaseFromUrl(serverUrl)}/getCoverArt.view?${p.toString()}`; +} + export function buildDownloadUrl(id: string): string { const { getBaseUrl, getActiveServer } = useAuthStore.getState(); const server = getActiveServer(); diff --git a/src/components/LiveSearch.tsx b/src/components/LiveSearch.tsx index 45aa98a1..b7f8710e 100644 --- a/src/components/LiveSearch.tsx +++ b/src/components/LiveSearch.tsx @@ -10,6 +10,8 @@ import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import { showToast } from '../utils/ui/toast'; +import { useShareSearch } from '../hooks/useShareSearch'; +import ShareSearchResults from './search/ShareSearchResults'; function debounce(fn: (q: string) => void, ms: number): (q: string) => void { let timer: ReturnType; @@ -67,6 +69,13 @@ export default function LiveSearch() { const compactHeaderControlsRef = useRef(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const closeSearch = useCallback(() => { + setOpen(false); + setQuery(''); + }, []); + + const share = useShareSearch(query, closeSearch); + const doSearch = useCallback( debounce(async (q: string) => { if (!q.trim()) { setResults(null); setOpen(false); return; } @@ -82,7 +91,17 @@ export default function LiveSearch() { [musicLibraryFilterVersion] ); - useEffect(() => { doSearch(query); setActiveIndex(-1); }, [query, doSearch]); + useEffect(() => { + if (share.shareMatch) { + setResults(null); + setLoading(false); + setOpen(true); + setActiveIndex(-1); + return; + } + doSearch(query); + setActiveIndex(-1); + }, [query, doSearch, share.shareMatch]); const isSearchActive = isFocused || open || query.trim().length > 0; @@ -202,10 +221,22 @@ export default function LiveSearch() { return () => document.removeEventListener('mousedown', handler); }, [ctxIsOpen]); - const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + const hasResults = + !!share.shareMatch || + (results && (results.artists.length || results.albums.length || results.songs.length)); // Flat list of all navigable items for keyboard nav - const flatItems = results ? [ + const flatItems = share.shareMatch && share.hasShareKeyboardTarget ? [ + { + id: 'share-link', + action: () => { + if (share.canQueueShareMatch) void share.enqueueShareMatch(); + else if (share.canOpenShareAlbum) share.openShareAlbum(); + else if (share.canOpenShareArtist) share.openShareArtist(); + else if (share.canOpenShareComposer) share.openShareComposer(); + }, + }, + ] : results ? [ ...(results.artists.map(a => ({ id: a.id, action: () => { navigate(`/artist/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.albums.map(a => ({ id: a.id, action: () => { navigate(`/album/${a.id}`); setOpen(false); setQuery(''); } }))), ...(results.songs.map(s => ({ id: s.id, action: () => { @@ -217,6 +248,22 @@ export default function LiveSearch() { ] : []; const handleKeyDown = (e: React.KeyboardEvent) => { + if (share.shareMatch) { + if (e.key === 'Enter') { + e.preventDefault(); + if (share.canQueueShareMatch) void share.enqueueShareMatch(); + else if (share.canOpenShareAlbum) share.openShareAlbum(); + else if (share.canOpenShareArtist) share.openShareArtist(); + else if (share.canOpenShareComposer) share.openShareComposer(); + } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(share.hasShareKeyboardTarget ? 0 : -1); + } else if (e.key === 'Escape') { + setOpen(false); + setActiveIndex(-1); + } + return; + } if (!open || !flatItems.length) { if (e.key === 'Enter' && query.trim()) { setOpen(false); navigate(`/search?q=${encodeURIComponent(query.trim())}`); } return; @@ -312,7 +359,36 @@ export default function LiveSearch() {
{t('search.noResults', { query })}
)} + {share.shareMatch && ( + void share.enqueueShareMatch()} + onOpenAlbum={share.openShareAlbum} + onOpenArtist={share.openShareArtist} + onOpenComposer={share.openShareComposer} + onContextMenu={(e, item, type) => openContextMenu(e.clientX, e.clientY, item, type)} + shareTrackSong={share.shareTrackSong} + shareTrackResolving={share.shareTrackResolving} + shareTrackUnavailable={share.shareTrackUnavailable} + shareAlbum={share.shareAlbum} + shareAlbumResolving={share.shareAlbumResolving} + shareAlbumUnavailable={share.shareAlbumUnavailable} + shareArtist={share.shareArtist} + shareArtistResolving={share.shareArtistResolving} + shareArtistUnavailable={share.shareArtistUnavailable} + shareComposer={share.shareComposer} + shareComposerResolving={share.shareComposerResolving} + shareComposerUnavailable={share.shareComposerUnavailable} + /> + )} + {(() => { + if (share.shareMatch) return null; let idx = 0; return <> {results?.artists.length ? ( diff --git a/src/components/MobileSearchOverlay.tsx b/src/components/MobileSearchOverlay.tsx index 1696a9bf..857705e0 100644 --- a/src/components/MobileSearchOverlay.tsx +++ b/src/components/MobileSearchOverlay.tsx @@ -11,6 +11,8 @@ import { useAuthStore } from '../store/authStore'; import { useTranslation } from 'react-i18next'; import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage'; import { showToast } from '../utils/ui/toast'; +import { useShareSearch } from '../hooks/useShareSearch'; +import ShareSearchResults from './search/ShareSearchResults'; const STORAGE_KEY = 'psysonic_recent_searches'; const MAX_RECENT = 6; @@ -67,6 +69,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } const [recentSearches, setRecentSearches] = useState(loadRecent); const inputRef = useRef(null); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const share = useShareSearch(query, onClose); useEffect(() => { inputRef.current?.focus(); }, []); @@ -86,7 +89,14 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } [musicLibraryFilterVersion] ); - useEffect(() => { doSearch(query); }, [query, doSearch]); + useEffect(() => { + if (share.shareMatch) { + setResults(null); + setLoading(false); + return; + } + doSearch(query); + }, [query, doSearch, share.shareMatch]); const commit = (q: string) => { if (q.trim()) setRecentSearches(prev => saveRecent(q, prev)); @@ -114,7 +124,9 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } }); }; - const hasResults = results && (results.artists.length || results.albums.length || results.songs.length); + const hasResults = + !!share.shareMatch || + (results && (results.artists.length || results.albums.length || results.songs.length)); const showEmpty = !query; return createPortal( @@ -209,8 +221,34 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void } )} + {share.shareMatch && ( + void share.enqueueShareMatch()} + onOpenAlbum={share.openShareAlbum} + onOpenArtist={share.openShareArtist} + onOpenComposer={share.openShareComposer} + shareTrackSong={share.shareTrackSong} + shareTrackResolving={share.shareTrackResolving} + shareTrackUnavailable={share.shareTrackUnavailable} + shareAlbum={share.shareAlbum} + shareAlbumResolving={share.shareAlbumResolving} + shareAlbumUnavailable={share.shareAlbumUnavailable} + shareArtist={share.shareArtist} + shareArtistResolving={share.shareArtistResolving} + shareArtistUnavailable={share.shareArtistUnavailable} + shareComposer={share.shareComposer} + shareComposerResolving={share.shareComposerResolving} + shareComposerUnavailable={share.shareComposerUnavailable} + /> + )} + {/* ── Results ── */} - {hasResults && ( + {hasResults && !share.shareMatch && ( <> {results!.artists.length > 0 && (
diff --git a/src/components/search/ShareQueuePreviewModal.tsx b/src/components/search/ShareQueuePreviewModal.tsx new file mode 100644 index 00000000..3494ca75 --- /dev/null +++ b/src/components/search/ShareQueuePreviewModal.tsx @@ -0,0 +1,182 @@ +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Music, X } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { SubsonicSong } from '../../api/subsonicTypes'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { formatTrackTime } from '../../utils/format/formatDuration'; +import type { ShareQueuePreviewState } from '../../hooks/useShareQueuePreview'; +import { sharePayloadTotal, type QueueableShareSearchPayload } from '../../utils/share/shareSearch'; +import CachedImage from '../CachedImage'; +import { + buildCoverArtUrl, + buildCoverArtUrlForServer, + coverArtCacheKey, + coverArtCacheKeyForServer, +} from '../../api/subsonicStreamUrl'; + +type ShareQueuePreviewModalProps = { + open: boolean; + onClose: () => void; + payload: Extract; + preview: ShareQueuePreviewState; + shareServerLabel?: string | null; + coverServer?: ServerProfile | null; + onEnqueue: () => void; + enqueueBusy: boolean; +}; + +function QueuePreviewTrackRow({ + song, + coverServer, +}: { + song: SubsonicSong; + coverServer?: ServerProfile | null; +}) { + const coverId = song.coverArt ?? ''; + const src = coverServer + ? buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId || song.id, 48) + : buildCoverArtUrl(coverId || song.id, 48); + const cacheKey = coverServer + ? coverArtCacheKeyForServer(coverServer.id, coverId || song.id, 48) + : coverArtCacheKey(coverId || song.id, 48); + + return ( +
  • + {coverId ? ( + + ) : ( +
    + +
    + )} +
    +
    {song.title}
    +
    + {song.artist}{song.album ? ` · ${song.album}` : ''} +
    +
    + {formatTrackTime(song.duration)} +
  • + ); +} + +function PreviewBody({ + preview, + coverServer, +}: { + preview: ShareQueuePreviewState; + coverServer?: ServerProfile | null; +}) { + const { t } = useTranslation(); + + if (preview.status === 'loading' || preview.status === 'idle') { + return
    {t('search.shareQueuePreviewLoading')}
    ; + } + + if (preview.status === 'error') { + const msg = + preview.result.type === 'not-logged-in' + ? t('sharePaste.notLoggedIn') + : preview.result.type === 'no-matching-server' + ? t('sharePaste.noMatchingServer', { url: preview.result.url }) + : preview.result.type === 'all-unavailable' + ? t('search.shareQueuePreviewEmpty') + : t('sharePaste.genericError'); + return
    {msg}
    ; + } + + return ( + <> + {preview.skipped > 0 && ( +

    + {t('search.shareQueuePreviewSkipped', { skipped: preview.skipped, total: preview.total })} +

    + )} +
      + {preview.songs.map(song => ( + + ))} +
    + + ); +} + +export default function ShareQueuePreviewModal({ + open, + onClose, + payload, + preview, + shareServerLabel, + coverServer, + onEnqueue, + enqueueBusy, +}: ShareQueuePreviewModalProps) { + const { t } = useTranslation(); + + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + const count = sharePayloadTotal(payload); + const canEnqueue = preview.status === 'ok' && preview.songs.length > 0; + + return createPortal( +
    { + if (e.target === e.currentTarget) onClose(); + }} + > +
    e.stopPropagation()} + > + + +
    +

    + {t('search.shareQueueTitle', { count })} +

    + {shareServerLabel && ( +

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

    + )} +
    + +
    + +
    + +
    + + +
    +
    +
    , + document.body, + ); +} diff --git a/src/components/search/ShareSearchResults.tsx b/src/components/search/ShareSearchResults.tsx new file mode 100644 index 00000000..feaf69fc --- /dev/null +++ b/src/components/search/ShareSearchResults.tsx @@ -0,0 +1,454 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Disc3, Eye, Link2, ListPlus, Music, Users } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { + buildCoverArtUrl, + buildCoverArtUrlForServer, + coverArtCacheKey, + coverArtCacheKeyForServer, +} from '../../api/subsonicStreamUrl'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { songToTrack } from '../../utils/playback/songToTrack'; +import { activateShareSearchServer } from '../../utils/share/enqueueShareSearchPayload'; +import { sharePayloadTotal, type ShareSearchMatch } from '../../utils/share/shareSearch'; +import type { ShareSearchPreviewState } from '../../hooks/useShareSearchPreview'; +import CachedImage, { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from '../CachedImage'; +import { useShareQueuePreview } from '../../hooks/useShareQueuePreview'; +import ShareQueuePreviewModal from './ShareQueuePreviewModal'; + +type ShareSearchResultsProps = { + variant: 'desktop' | 'mobile'; + shareMatch: ShareSearchMatch; + /** Saved server display name when the link is from a non-active server. */ + shareServerLabel?: string | null; + /** Saved server profile for cover art when the link is from a non-active server. */ + shareCoverServer?: ServerProfile | null; + activeIndex?: number; + shareQueueBusy: boolean; + onEnqueue: () => void | Promise; + onOpenAlbum: () => void; + onOpenArtist: () => void; + onOpenComposer: () => void; + onContextMenu?: (e: React.MouseEvent, item: unknown, type: 'song' | 'album' | 'artist') => void; +} & ShareSearchPreviewState; + +function useShareCoverArt(coverId: string, size: number, coverServer: ServerProfile | null | undefined) { + return useMemo(() => { + if (coverServer) { + return { + src: buildCoverArtUrlForServer(coverServer.url, coverServer.username, coverServer.password, coverId, size), + cacheKey: coverArtCacheKeyForServer(coverServer.id, coverId, size), + }; + } + return { + src: buildCoverArtUrl(coverId, size), + cacheKey: coverArtCacheKey(coverId, size), + }; + }, [coverServer, coverId, size]); +} + +function ShareAlbumThumb({ + coverArt, + size, + coverServer, +}: { + coverArt: string; + size: number; + coverServer?: ServerProfile | null; +}) { + const { src, cacheKey } = useShareCoverArt(coverArt, size, coverServer); + const cls = size >= 64 ? 'mobile-search-thumb' : 'search-result-thumb'; + return ; +} + +function ShareArtistThumb({ + artist, + size, + coverServer, +}: { + artist: Pick; + size: number; + coverServer?: ServerProfile | null; +}) { + const [failed, setFailed] = useState(false); + const coverId = artist.coverArt || artist.id; + const { src, cacheKey } = useShareCoverArt(coverId, size, coverServer); + useEffect(() => { setFailed(false); }, [coverId]); + + if (failed) { + if (size >= 64) { + return ( +
    + +
    + ); + } + return ( +
    + +
    + ); + } + + const cls = + size >= 64 + ? 'mobile-search-thumb mobile-search-thumb--artist-round' + : 'search-result-thumb'; + return ( + setFailed(true)} + /> + ); +} + +function StaticIcon({ className, children }: { className: string; children: React.ReactNode }) { + return
    {children}
    ; +} + +function withShareServer( + shareMatch: ShareSearchMatch, + t: TFunction, + fn: () => void, +): void { + if (shareMatch.type === 'unsupported') return; + if (!activateShareSearchServer(shareMatch.payload.srv, t)) return; + fn(); +} + +function shareSubLine(primary: string, serverLabel: string | null | undefined, t: TFunction): string { + if (!serverLabel) return primary; + const hint = t('search.shareFromServer', { server: serverLabel }); + return primary ? `${primary} · ${hint}` : hint; +} + +export default function ShareSearchResults(props: ShareSearchResultsProps) { + const { + variant, + shareMatch, + shareServerLabel = null, + shareCoverServer = null, + activeIndex = 0, + shareQueueBusy, + onEnqueue, + onOpenAlbum, + onOpenArtist, + onOpenComposer, + onContextMenu, + shareTrackSong, + shareTrackResolving, + shareTrackUnavailable, + shareAlbum, + shareAlbumResolving, + shareAlbumUnavailable, + shareArtist, + shareArtistResolving, + shareArtistUnavailable, + shareComposer, + shareComposerResolving, + shareComposerUnavailable, + } = props; + + const { t } = useTranslation(); + const desktop = variant === 'desktop'; + const thumbSize = desktop ? 40 : 80; + const sectionCls = desktop ? 'search-section' : 'mobile-search-section'; + const labelCls = desktop ? 'search-section-label' : 'mobile-search-section-label'; + const mutedCls = desktop ? 'search-result-item search-result-item--muted' : 'mobile-search-item mobile-search-item--muted'; + const iconCls = desktop ? 'search-result-icon' : 'mobile-search-avatar'; + const nameCls = desktop ? 'search-result-name' : 'mobile-search-item-title'; + const subCls = desktop ? 'search-result-sub' : 'mobile-search-item-sub'; + const infoWrap = desktop ? undefined : 'mobile-search-item-info'; + + const wrap = (content: React.ReactNode) => ( +
    +
    + {desktop && } {t('search.shareLink')} +
    + {content} +
    + ); + + const itemCls = (active: boolean) => + desktop ? `search-result-item${active ? ' active' : ''}` : 'mobile-search-item'; + + const sub = (primary: string) => shareSubLine(primary, shareServerLabel, t); + const showEntityKindSub = !desktop || !!shareServerLabel; + const [queuePreviewOpen, setQueuePreviewOpen] = useState(false); + const queuePayload = + shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue' ? shareMatch.payload : null; + const queuePreview = useShareQueuePreview(queuePayload, queuePreviewOpen); + + const unsupportedRow = ( +
    + +
    +
    {t('search.shareUnsupportedTitle')}
    +
    {t('search.shareUnsupportedSub')}
    +
    +
    + ); + + if (shareMatch.type === 'unsupported') { + return wrap(unsupportedRow); + } + + if (shareMatch.type === 'artist') { + if (shareArtistResolving) { + return wrap( +
    + +
    +
    {t('common.loading')}
    +
    {sub(t('search.artists'))}
    +
    +
    , + ); + } + if (shareArtist) { + return wrap( + , + ); + } + return wrap( +
    + +
    +
    + {shareArtistUnavailable ? t('sharePaste.artistUnavailable') : t('sharePaste.genericError')} +
    +
    {t('search.shareUnsupportedSub')}
    +
    +
    , + ); + } + + if (shareMatch.type === 'composer') { + if (shareComposerResolving) { + return wrap( +
    + +
    +
    {t('common.loading')}
    +
    {sub(t('sidebar.composers'))}
    +
    +
    , + ); + } + if (shareComposer) { + return wrap( + , + ); + } + return wrap( +
    + +
    +
    + {shareComposerUnavailable ? t('sharePaste.composerUnavailable') : t('sharePaste.genericError')} +
    +
    {t('search.shareUnsupportedSub')}
    +
    +
    , + ); + } + + if (shareMatch.type === 'album') { + if (shareAlbumResolving) { + return wrap( +
    + +
    +
    {t('common.loading')}
    +
    {sub(t('search.album'))}
    +
    +
    , + ); + } + if (shareAlbum) { + return wrap( + , + ); + } + return wrap( +
    + +
    +
    + {shareAlbumUnavailable ? t('sharePaste.albumUnavailable') : t('sharePaste.genericError')} +
    +
    {t('search.shareUnsupportedSub')}
    +
    +
    , + ); + } + + if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'track') { + if (shareTrackResolving) { + return wrap( +
    + +
    +
    {t('common.loading')}
    +
    {sub(t('search.shareTrackTitle'))}
    +
    +
    , + ); + } + if (shareTrackSong) { + return wrap( + , + ); + } + return wrap( +
    + +
    +
    + {shareTrackUnavailable ? t('sharePaste.trackUnavailable') : t('sharePaste.genericError')} +
    +
    {t('search.shareUnsupportedSub')}
    +
    +
    , + ); + } + + if (shareMatch.type === 'queueable' && shareMatch.payload.k === 'queue') { + const count = sharePayloadTotal(shareMatch.payload); + const rowCls = desktop ? 'search-share-queue-row' : 'mobile-search-share-queue-row'; + const handleModalEnqueue = () => { + void Promise.resolve(onEnqueue()).then(ok => { + if (ok) setQueuePreviewOpen(false); + }); + }; + return ( + <> + {wrap( +
    + + +
    , + )} + setQueuePreviewOpen(false)} + payload={shareMatch.payload} + preview={queuePreview} + shareServerLabel={shareServerLabel} + coverServer={shareCoverServer} + onEnqueue={handleModalEnqueue} + enqueueBusy={shareQueueBusy} + /> + + ); + } + + return null; +} diff --git a/src/hooks/useShareQueuePreview.ts b/src/hooks/useShareQueuePreview.ts new file mode 100644 index 00000000..e8610731 --- /dev/null +++ b/src/hooks/useShareQueuePreview.ts @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react'; +import type { SubsonicSong } from '../api/subsonicTypes'; +import { + resolveShareSearchPayload, + type ShareSearchResolveResult, +} from '../utils/share/enqueueShareSearchPayload'; +import type { QueueableShareSearchPayload } from '../utils/share/shareSearch'; + +export type ShareQueuePreviewState = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'ok'; songs: SubsonicSong[]; total: number; skipped: number } + | { status: 'error'; result: Exclude }; + +const IDLE: ShareQueuePreviewState = { status: 'idle' }; + +export function useShareQueuePreview( + payload: Extract | null, + open: boolean, +): ShareQueuePreviewState { + const [state, setState] = useState(IDLE); + + useEffect(() => { + if (!open || !payload) { + setState(IDLE); + return; + } + + let cancelled = false; + setState({ status: 'loading' }); + + void resolveShareSearchPayload(payload) + .then(result => { + if (cancelled) return; + if (result.type === 'ok') { + setState({ + status: 'ok', + songs: result.songs, + total: result.total, + skipped: result.skipped, + }); + return; + } + setState({ status: 'error', result }); + }) + .catch(() => { + if (!cancelled) setState({ status: 'error', result: { type: 'error' } }); + }); + + return () => { + cancelled = true; + }; + }, [open, payload]); + + return state; +} diff --git a/src/hooks/useShareSearch.ts b/src/hooks/useShareSearch.ts new file mode 100644 index 00000000..2f139e11 --- /dev/null +++ b/src/hooks/useShareSearch.ts @@ -0,0 +1,98 @@ +import { useCallback, useMemo, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { useAuthStore } from '../store/authStore'; +import { + activateShareSearchServer, + enqueueShareSearchPayload, +} from '../utils/share/enqueueShareSearchPayload'; +import type { ServerProfile } from '../store/authStoreTypes'; +import { findServerIdForShareUrl } from '../utils/share/shareLink'; +import { shareServerOriginLabel } from '../utils/share/shareServerOriginLabel'; +import { parseShareSearchText } from '../utils/share/shareSearch'; +import { useShareSearchPreview } from './useShareSearchPreview'; + +export function useShareSearch(query: string, onSuccess?: () => void) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const servers = useAuthStore(s => s.servers); + const activeServerId = useAuthStore(s => s.activeServerId); + const shareMatch = useMemo(() => parseShareSearchText(query), [query]); + const shareServerLabel = useMemo( + () => shareServerOriginLabel(shareMatch, servers, activeServerId), + [shareMatch, servers, activeServerId], + ); + const shareCoverServer = useMemo((): ServerProfile | null => { + if (!shareMatch || shareMatch.type === 'unsupported') return null; + const serverId = findServerIdForShareUrl(servers, shareMatch.payload.srv); + if (!serverId || serverId === activeServerId) return null; + return servers.find(s => s.id === serverId) ?? null; + }, [shareMatch, servers, activeServerId]); + const preview = useShareSearchPreview(shareMatch); + const [shareQueueBusy, setShareQueueBusy] = useState(false); + + const canQueueShareMatch = + shareMatch?.type === 'queueable' && + (shareMatch.payload.k === 'queue' || + (!preview.shareTrackResolving && !!preview.shareTrackSong)); + + const canOpenShareAlbum = + shareMatch?.type === 'album' && !!preview.shareAlbum && !preview.shareAlbumResolving; + const canOpenShareArtist = + shareMatch?.type === 'artist' && !!preview.shareArtist && !preview.shareArtistResolving; + const canOpenShareComposer = + shareMatch?.type === 'composer' && !!preview.shareComposer && !preview.shareComposerResolving; + + const hasShareKeyboardTarget = + canQueueShareMatch || canOpenShareAlbum || canOpenShareArtist || canOpenShareComposer; + + const openShareAlbum = useCallback(() => { + if (shareMatch?.type !== 'album' || !preview.shareAlbum) return; + if (!activateShareSearchServer(shareMatch.payload.srv, t)) return; + navigate(`/album/${preview.shareAlbum.id}`); + onSuccess?.(); + }, [shareMatch, preview.shareAlbum, navigate, t, onSuccess]); + + const openShareArtist = useCallback(() => { + if (shareMatch?.type !== 'artist' || !preview.shareArtist) return; + if (!activateShareSearchServer(shareMatch.payload.srv, t)) return; + navigate(`/artist/${preview.shareArtist.id}`); + onSuccess?.(); + }, [shareMatch, preview.shareArtist, navigate, t, onSuccess]); + + const openShareComposer = useCallback(() => { + if (shareMatch?.type !== 'composer' || !preview.shareComposer) return; + if (!activateShareSearchServer(shareMatch.payload.srv, t)) return; + navigate(`/composer/${preview.shareComposer.id}`); + onSuccess?.(); + }, [shareMatch, preview.shareComposer, navigate, t, onSuccess]); + + const enqueueShareMatch = useCallback(async () => { + if (shareMatch?.type !== 'queueable' || shareQueueBusy) return false; + if (shareMatch.payload.k === 'track' && (!preview.shareTrackSong || preview.shareTrackResolving)) { + return false; + } + setShareQueueBusy(true); + const ok = await enqueueShareSearchPayload(shareMatch.payload, t); + setShareQueueBusy(false); + if (ok) onSuccess?.(); + return ok; + }, [shareMatch, shareQueueBusy, preview.shareTrackSong, preview.shareTrackResolving, t, onSuccess]); + + return { + shareMatch, + shareServerLabel, + shareCoverServer, + shareQueueBusy, + canQueueShareMatch, + canOpenShareAlbum, + canOpenShareArtist, + canOpenShareComposer, + hasShareKeyboardTarget, + openShareAlbum, + openShareArtist, + openShareComposer, + enqueueShareMatch, + ...preview, + }; +} diff --git a/src/hooks/useShareSearchPreview.ts b/src/hooks/useShareSearchPreview.ts new file mode 100644 index 00000000..ac683f06 --- /dev/null +++ b/src/hooks/useShareSearchPreview.ts @@ -0,0 +1,137 @@ +import { useEffect, useState } from 'react'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../api/subsonicTypes'; +import { + resolveShareSearchAlbum, + resolveShareSearchArtist, + resolveShareSearchPayload, +} from '../utils/share/enqueueShareSearchPayload'; +import type { ShareSearchMatch } from '../utils/share/shareSearch'; + +export interface ShareSearchPreviewState { + shareTrackSong: SubsonicSong | null; + shareTrackResolving: boolean; + shareTrackUnavailable: boolean; + shareAlbum: SubsonicAlbum | null; + shareAlbumResolving: boolean; + shareAlbumUnavailable: boolean; + shareArtist: SubsonicArtist | null; + shareArtistResolving: boolean; + shareArtistUnavailable: boolean; + shareComposer: SubsonicArtist | null; + shareComposerResolving: boolean; + shareComposerUnavailable: boolean; +} + +const EMPTY_PREVIEW: ShareSearchPreviewState = { + shareTrackSong: null, + shareTrackResolving: false, + shareTrackUnavailable: false, + shareAlbum: null, + shareAlbumResolving: false, + shareAlbumUnavailable: false, + shareArtist: null, + shareArtistResolving: false, + shareArtistUnavailable: false, + shareComposer: null, + shareComposerResolving: false, + shareComposerUnavailable: false, +}; + +export function useShareSearchPreview(shareMatch: ShareSearchMatch | null): ShareSearchPreviewState { + const [preview, setPreview] = useState(EMPTY_PREVIEW); + + useEffect(() => { + let cancelled = false; + setPreview(EMPTY_PREVIEW); + + if (shareMatch?.type === 'queueable' && shareMatch.payload.k === 'track') { + setPreview({ ...EMPTY_PREVIEW, shareTrackResolving: true }); + void resolveShareSearchPayload(shareMatch.payload) + .then(result => { + if (cancelled) return; + setPreview({ + ...EMPTY_PREVIEW, + shareTrackSong: result.type === 'ok' ? (result.songs[0] ?? null) : null, + shareTrackUnavailable: result.type !== 'ok' || result.songs.length === 0, + }); + }) + .finally(() => { + if (!cancelled) { + setPreview(current => ({ ...current, shareTrackResolving: false })); + } + }); + return () => { + cancelled = true; + }; + } + + if (shareMatch?.type === 'artist') { + setPreview({ ...EMPTY_PREVIEW, shareArtistResolving: true }); + void resolveShareSearchArtist(shareMatch.payload) + .then(result => { + if (cancelled) return; + setPreview({ + ...EMPTY_PREVIEW, + shareArtist: result.type === 'ok' ? result.artist : null, + shareArtistUnavailable: result.type !== 'ok', + }); + }) + .finally(() => { + if (!cancelled) { + setPreview(current => ({ ...current, shareArtistResolving: false })); + } + }); + return () => { + cancelled = true; + }; + } + + if (shareMatch?.type === 'composer') { + setPreview({ ...EMPTY_PREVIEW, shareComposerResolving: true }); + void resolveShareSearchArtist(shareMatch.payload) + .then(result => { + if (cancelled) return; + setPreview({ + ...EMPTY_PREVIEW, + shareComposer: result.type === 'ok' ? result.artist : null, + shareComposerUnavailable: result.type !== 'ok', + }); + }) + .finally(() => { + if (!cancelled) { + setPreview(current => ({ ...current, shareComposerResolving: false })); + } + }); + return () => { + cancelled = true; + }; + } + + if (shareMatch?.type === 'album') { + setPreview({ ...EMPTY_PREVIEW, shareAlbumResolving: true }); + void resolveShareSearchAlbum(shareMatch.payload) + .then(result => { + if (cancelled) return; + setPreview({ + ...EMPTY_PREVIEW, + shareAlbum: result.type === 'ok' ? result.album : null, + shareAlbumUnavailable: result.type !== 'ok', + }); + }) + .finally(() => { + if (!cancelled) { + setPreview(current => ({ ...current, shareAlbumResolving: false })); + } + }); + return () => { + cancelled = true; + }; + } + + return () => { + cancelled = true; + }; + }, [shareMatch]); + + return preview; +} diff --git a/src/locales/de/search.ts b/src/locales/de/search.ts index 7a358bab..a9651cbc 100644 --- a/src/locales/de/search.ts +++ b/src/locales/de/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Stöbern', emptyHint: 'Was möchtest du hören?', genres: 'Genres', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/en/search.ts b/src/locales/en/search.ts index 0825776c..0f44d607 100644 --- a/src/locales/en/search.ts +++ b/src/locales/en/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Browse', emptyHint: 'What do you want to hear?', genres: 'Genres', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/es/search.ts b/src/locales/es/search.ts index ebb23ff1..783453bf 100644 --- a/src/locales/es/search.ts +++ b/src/locales/es/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Explorar', emptyHint: '¿Qué quieres escuchar?', genres: 'Géneros', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/fr/search.ts b/src/locales/fr/search.ts index fa87d444..c68ec626 100644 --- a/src/locales/fr/search.ts +++ b/src/locales/fr/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Parcourir', emptyHint: 'Que veux-tu écouter ?', genres: 'Genres', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/nb/search.ts b/src/locales/nb/search.ts index 85cfaa6d..76f7ef89 100644 --- a/src/locales/nb/search.ts +++ b/src/locales/nb/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Utforsk', emptyHint: 'Hva vil du høre?', genres: 'Sjangre', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/nl/search.ts b/src/locales/nl/search.ts index 35bc877d..c8a7e353 100644 --- a/src/locales/nl/search.ts +++ b/src/locales/nl/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Bladeren', emptyHint: 'Wat wil je horen?', genres: 'Genres', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/ro/search.ts b/src/locales/ro/search.ts index 25c6e6d1..42d6e647 100644 --- a/src/locales/ro/search.ts +++ b/src/locales/ro/search.ts @@ -26,4 +26,20 @@ export const search = { browse: 'Răsfoiește', emptyHint: 'Ce vrei să auzi?', genres: 'Genuri', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/locales/ru/search.ts b/src/locales/ru/search.ts index bd8bc2d2..4bbb7370 100644 --- a/src/locales/ru/search.ts +++ b/src/locales/ru/search.ts @@ -26,4 +26,24 @@ export const search = { browse: 'Обзор', emptyHint: 'Что хочешь послушать?', genres: 'Жанры', + shareLink: 'Ссылка для обмена', + shareTrackTitle: 'Общий трек', + shareQueueTitle_one: 'Общая очередь ({{count}} трек)', + shareQueueTitle_other: 'Общая очередь ({{count}} трека)', + shareQueueTitle_few: 'Общая очередь ({{count}} трека)', + shareQueueTitle_many: 'Общая очередь ({{count}} треков)', + shareQueueAction: 'Добавить в очередь', + shareQueueing: 'Добавление в очередь…', + shareQueued_one: 'В очередь добавлен {{count}} трек', + shareQueued_few: 'В очередь добавлено {{count}} трека', + shareQueued_many: 'В очередь добавлено {{count}} треков', + shareQueued_other: 'В очередь добавлено {{count}} треков', + shareQueuedPartial: 'Добавлено {{queued}} из {{total}} треков ({{skipped}} не найдено).', + shareUnsupportedTitle: 'Эту ссылку нельзя использовать здесь', + shareUnsupportedSub: 'Нужна ссылка на трек, альбом, исполнителя, композитора или очередь.', + shareFromServer: 'С сервера «{{server}}»', + shareQueuePreview: 'Просмотр треков', + shareQueuePreviewLoading: 'Загрузка треков…', + shareQueuePreviewEmpty: 'Треки из этой ссылки не найдены на сервере.', + shareQueuePreviewSkipped: '{{skipped}} из {{total}} треков не найдено на этом сервере.', }; diff --git a/src/locales/zh/search.ts b/src/locales/zh/search.ts index d813be9c..015f4d67 100644 --- a/src/locales/zh/search.ts +++ b/src/locales/zh/search.ts @@ -26,4 +26,20 @@ export const search = { browse: '浏览', emptyHint: '你想听什么?', genres: '流派', + shareLink: 'Share link', + shareTrackTitle: 'Shared track', + shareQueueTitle_one: 'Shared queue ({{count}} track)', + shareQueueTitle_other: 'Shared queue ({{count}} tracks)', + shareQueueAction: 'Add to queue', + shareQueueing: 'Adding to queue…', + shareQueued_one: 'Added {{count}} shared track to queue', + shareQueued_other: 'Added {{count}} shared tracks to queue', + shareQueuedPartial: 'Added {{queued}} of {{total}} shared tracks to queue ({{skipped}} not found).', + shareUnsupportedTitle: 'This share link cannot be used here', + shareUnsupportedSub: 'Use a track, album, artist, composer, or queue share link.', + shareFromServer: 'From {{server}}', + shareQueuePreview: 'Preview tracks', + shareQueuePreviewLoading: 'Loading tracks…', + shareQueuePreviewEmpty: 'No tracks from this link could be found on the server.', + shareQueuePreviewSkipped: '{{skipped}} of {{total}} tracks were not found on this server.', }; diff --git a/src/styles/components/index.css b/src/styles/components/index.css index afcfe462..4a0565db 100644 --- a/src/styles/components/index.css +++ b/src/styles/components/index.css @@ -14,6 +14,7 @@ @import './offline-cache-button.css'; @import './download-folder-modal.css'; @import './song-info-modal.css'; +@import './share-queue-preview-modal.css'; @import './tracklist.css'; @import './modal.css'; @import './playback-delay-sleep-delayed-start-modal.css'; diff --git a/src/styles/components/live-search.css b/src/styles/components/live-search.css index 752e1b50..92dee157 100644 --- a/src/styles/components/live-search.css +++ b/src/styles/components/live-search.css @@ -178,6 +178,20 @@ background: var(--bg-hover); } +.search-result-item:disabled { + opacity: 0.65; + cursor: wait; +} + +.search-result-item--muted { + cursor: default; + color: var(--text-muted); +} + +.search-result-item--muted:hover { + background: transparent; +} + .search-result-icon { width: 32px; height: 32px; diff --git a/src/styles/components/results-area.css b/src/styles/components/results-area.css index 1dc3fa19..da8f41a1 100644 --- a/src/styles/components/results-area.css +++ b/src/styles/components/results-area.css @@ -12,3 +12,12 @@ font-size: 15px; } +.mobile-search-item:disabled { + opacity: 0.65; +} + +.mobile-search-item--muted { + cursor: default; + color: var(--text-muted); +} + diff --git a/src/styles/components/share-queue-preview-modal.css b/src/styles/components/share-queue-preview-modal.css new file mode 100644 index 00000000..4060683a --- /dev/null +++ b/src/styles/components/share-queue-preview-modal.css @@ -0,0 +1,215 @@ +/* ─ Share queue preview modal (search paste) ─ */ +.share-queue-preview-modal-overlay { + align-items: center; + padding-top: 0; +} + +.share-queue-preview-modal { + display: flex; + flex-direction: column; + width: min(520px, 92vw); + max-width: 520px; + max-height: min(85vh, 720px); + padding: var(--space-6); + overflow: hidden; +} + +.share-queue-preview-modal__header { + flex-shrink: 0; + padding-right: var(--space-8); + margin-bottom: var(--space-4); +} + +.share-queue-preview-modal__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + line-height: 1.3; +} + +.share-queue-preview-modal__server { + margin: var(--space-1) 0 0; + font-size: 0.8125rem; + color: var(--text-muted); +} + +.share-queue-preview-modal__body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.share-queue-preview-modal__status { + padding: var(--space-6) var(--space-2); + text-align: center; + color: var(--text-muted); + font-size: 0.9375rem; +} + +.share-queue-preview-modal__status--error { + color: var(--text-secondary); +} + +.share-queue-preview-modal__skipped { + flex-shrink: 0; + margin: 0 0 var(--space-3); + font-size: 0.8125rem; + color: var(--text-muted); +} + +.share-queue-preview-modal__list { + flex: 1; + min-height: 120px; + max-height: min(52vh, 480px); + overflow-y: auto; + overscroll-behavior: contain; + margin: 0; + padding: 0; + list-style: none; +} + +.share-queue-preview-track { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-2) var(--space-1); + border-radius: var(--radius-sm); +} + +.share-queue-preview-track:hover { + background: var(--bg-hover); +} + +.share-queue-preview-track__thumb { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + flex-shrink: 0; + object-fit: cover; +} + +.share-queue-preview-track__icon { + width: 40px; + height: 40px; + border-radius: var(--radius-sm); + background: var(--bg-hover); + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + flex-shrink: 0; +} + +.share-queue-preview-track__meta { + flex: 1; + min-width: 0; +} + +.share-queue-preview-track__title { + font-size: 0.9375rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.share-queue-preview-track__sub { + font-size: 0.8125rem; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.share-queue-preview-track__dur { + flex-shrink: 0; + font-size: 0.8125rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.share-queue-preview-modal__footer { + flex-shrink: 0; + display: flex; + justify-content: flex-end; + gap: var(--space-3); + margin-top: var(--space-4); + padding-top: var(--space-4); + border-top: 1px solid var(--border); +} + +/* Live / mobile search — queue row with preview + enqueue */ +.search-share-queue-row { + display: flex; + align-items: stretch; + width: 100%; +} + +.search-share-queue-row .search-share-queue-main { + display: flex; + align-items: center; + gap: var(--space-3); + flex: 1; + min-width: 0; + padding: var(--space-2) var(--space-3) var(--space-2) var(--space-4); + text-align: left; + border: none; + background: transparent; + color: inherit; + cursor: pointer; + transition: background var(--transition-fast); +} + +.search-share-queue-row:hover .search-share-queue-main, +.search-share-queue-row.active .search-share-queue-main { + background: var(--bg-hover); +} + +.search-share-queue-row.active { + background: var(--bg-hover); +} + +.search-share-queue-row .search-share-queue-main:disabled { + opacity: 0.65; + cursor: wait; +} + +.search-share-queue-preview-btn { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 44px; + padding: 0 var(--space-2); + border: none; + border-left: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + cursor: pointer; + transition: background var(--transition-fast), color var(--transition-fast); +} + +.search-share-queue-preview-btn:hover { + background: var(--bg-hover); + color: var(--accent); +} + +.mobile-search-share-queue-row { + display: flex; + align-items: stretch; + width: 100%; +} + +.mobile-search-share-queue-row .mobile-search-item { + flex: 1; + min-width: 0; + border: none; + border-radius: 0; +} + +.mobile-search-share-queue-row .search-share-queue-preview-btn { + width: 48px; + border-left: 1px solid var(--border); +} diff --git a/src/utils/share/enqueueShareSearchPayload.test.ts b/src/utils/share/enqueueShareSearchPayload.test.ts new file mode 100644 index 00000000..57ce313a --- /dev/null +++ b/src/utils/share/enqueueShareSearchPayload.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TFunction } from 'i18next'; + +const mocks = vi.hoisted(() => ({ + authState: { + current: { + servers: [] as Array<{ id: string; name: string; url: string; username: string; password: string }>, + isLoggedIn: true, + activeServerId: 'active', + setActiveServer: vi.fn(), + }, + }, + enqueue: vi.fn(), + getAlbum: vi.fn(), + getAlbumWithCredentials: vi.fn(), + getArtist: vi.fn(), + getArtistWithCredentials: vi.fn(), + getSong: vi.fn(), + getSongWithCredentials: vi.fn(), + orbitBulkGuard: vi.fn(), + showToast: vi.fn(), + songToTrack: vi.fn(), +})); + +vi.mock('../../api/subsonicLibrary', () => ({ + getAlbum: mocks.getAlbum, + getSong: mocks.getSong, +})); + +vi.mock('../../api/subsonicArtists', () => ({ + getArtist: mocks.getArtist, +})); + +vi.mock('../../api/subsonicEntityWithCredentials', () => ({ + getAlbumWithCredentials: mocks.getAlbumWithCredentials, + getArtistWithCredentials: mocks.getArtistWithCredentials, + getSongWithCredentials: mocks.getSongWithCredentials, +})); + +vi.mock('../../store/authStore', () => ({ + useAuthStore: { + getState: () => mocks.authState.current, + }, +})); + +vi.mock('../../store/playerStore', () => ({ + usePlayerStore: { + getState: () => ({ enqueue: mocks.enqueue }), + }, +})); + +vi.mock('../playback/songToTrack', () => ({ + songToTrack: mocks.songToTrack, +})); + +vi.mock('../orbitBulkGuard', () => ({ + orbitBulkGuard: mocks.orbitBulkGuard, +})); + +vi.mock('../ui/toast', () => ({ + showToast: mocks.showToast, +})); + +import { + activateShareSearchServer, + enqueueShareSearchPayload, + resolveShareSearchAlbum, + resolveShareSearchArtist, + resolveShareSearchPayload, +} from './enqueueShareSearchPayload'; + +const sharedServer = { + id: 'shared', + name: 'Shared', + url: 'https://shared.example.com', + username: 'shared-user', + password: 'shared-pass', +}; + +const activeServer = { + id: 'active', + name: 'Active', + url: 'https://active.example.com', + username: 'active-user', + password: 'active-pass', +}; + +const sharedSong = { + id: 'song-1', + title: 'Shared Song', + artist: 'Shared Artist', + album: 'Shared Album', + albumId: 'album-1', + duration: 180, + minutesAgo: 0, + playerId: 0, + playerName: '', +}; + +describe('share search payload resolution', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.authState.current = { + servers: [activeServer, sharedServer], + isLoggedIn: true, + activeServerId: 'active', + setActiveServer: vi.fn(), + }; + mocks.getSongWithCredentials.mockResolvedValue(sharedSong); + mocks.getAlbumWithCredentials.mockResolvedValue({ + album: { id: 'album-1', name: 'Shared Album', artist: 'Shared Artist' }, + songs: [], + }); + mocks.getArtistWithCredentials.mockResolvedValue({ + artist: { id: 'artist-1', name: 'Shared Artist' }, + albums: [], + }); + mocks.getSong.mockResolvedValue(sharedSong); + mocks.songToTrack.mockImplementation(song => ({ id: song.id, title: song.title })); + mocks.orbitBulkGuard.mockResolvedValue(true); + }); + + it('resolves a shared track preview with explicit credentials without switching active server', async () => { + const result = await resolveShareSearchPayload({ + srv: 'https://shared.example.com', + k: 'track', + id: 'song-1', + }); + + expect(result).toEqual({ type: 'ok', songs: [sharedSong], total: 1, skipped: 0 }); + expect(mocks.getSongWithCredentials).toHaveBeenCalledWith( + sharedServer.url, + sharedServer.username, + sharedServer.password, + 'song-1', + ); + expect(mocks.getSong).not.toHaveBeenCalled(); + expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); + }); + + it('resolves album and artist previews without switching active server', async () => { + await resolveShareSearchAlbum({ srv: 'https://shared.example.com', k: 'album', id: 'album-1' }); + await resolveShareSearchArtist({ srv: 'https://shared.example.com', k: 'artist', id: 'artist-1' }); + + expect(mocks.getAlbumWithCredentials).toHaveBeenCalledWith( + sharedServer.url, + sharedServer.username, + sharedServer.password, + 'album-1', + ); + expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith( + sharedServer.url, + sharedServer.username, + sharedServer.password, + 'artist-1', + ); + expect(mocks.getAlbum).not.toHaveBeenCalled(); + expect(mocks.getArtist).not.toHaveBeenCalled(); + expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); + }); + + it('resolves composer previews via artist credentials without switching active server', async () => { + const result = await resolveShareSearchArtist({ + srv: 'https://shared.example.com', + k: 'composer', + id: 'composer-1', + }); + + expect(result.type).toBe('ok'); + expect(mocks.getArtistWithCredentials).toHaveBeenCalledWith( + sharedServer.url, + sharedServer.username, + sharedServer.password, + 'composer-1', + ); + expect(mocks.authState.current.setActiveServer).not.toHaveBeenCalled(); + }); + + it('returns not-logged-in without calling the API', async () => { + mocks.authState.current.isLoggedIn = false; + + const result = await resolveShareSearchPayload({ + srv: 'https://shared.example.com', + k: 'track', + id: 'song-1', + }); + + expect(result).toEqual({ type: 'not-logged-in' }); + expect(mocks.getSongWithCredentials).not.toHaveBeenCalled(); + }); + + it('activates the share server for confirmed enqueue actions', async () => { + const t = ((key: string) => key) as TFunction; + const ok = await enqueueShareSearchPayload({ + srv: 'https://shared.example.com', + k: 'track', + id: 'song-1', + }, t); + + expect(ok).toBe(true); + expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared'); + expect(mocks.getSong).toHaveBeenCalledWith('song-1'); + expect(mocks.getSongWithCredentials).not.toHaveBeenCalled(); + expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true); + }); + + it('aborts enqueue when orbitBulkGuard rejects the bulk add', async () => { + mocks.orbitBulkGuard.mockResolvedValue(false); + const t = ((key: string) => key) as TFunction; + + const ok = await enqueueShareSearchPayload({ + srv: 'https://shared.example.com', + k: 'track', + id: 'song-1', + }, t); + + expect(ok).toBe(false); + expect(mocks.enqueue).not.toHaveBeenCalled(); + }); + + it('reports partial queue enqueue with a partial toast', async () => { + mocks.getSong.mockImplementation((id: string) => + id === 'song-1' ? Promise.resolve(sharedSong) : Promise.resolve(null), + ); + const t = ((key: string, opts?: Record) => + opts ? `${key}:${JSON.stringify(opts)}` : key) as TFunction; + + const ok = await enqueueShareSearchPayload({ + srv: 'https://shared.example.com', + k: 'queue', + ids: ['song-1', 'missing'], + }, t); + + expect(ok).toBe(true); + expect(mocks.enqueue).toHaveBeenCalledWith([{ id: 'song-1', title: 'Shared Song' }], true); + expect(mocks.showToast).toHaveBeenCalledWith( + expect.stringContaining('search.shareQueuedPartial'), + 5000, + 'info', + ); + }); + + it('activateShareSearchServer switches server when lookup succeeds', () => { + const t = ((key: string) => key) as TFunction; + expect(activateShareSearchServer('https://shared.example.com', t)).toBe(true); + expect(mocks.authState.current.setActiveServer).toHaveBeenCalledWith('shared'); + expect(mocks.showToast).not.toHaveBeenCalled(); + }); + + it('activateShareSearchServer toasts when no matching server exists', () => { + const t = ((key: string) => key) as TFunction; + expect(activateShareSearchServer('https://unknown.example.com', t)).toBe(false); + expect(mocks.showToast).toHaveBeenCalledWith( + 'sharePaste.noMatchingServer', + 6000, + 'error', + ); + }); +}); diff --git a/src/utils/share/enqueueShareSearchPayload.ts b/src/utils/share/enqueueShareSearchPayload.ts new file mode 100644 index 00000000..9b1c8eca --- /dev/null +++ b/src/utils/share/enqueueShareSearchPayload.ts @@ -0,0 +1,246 @@ +import type { TFunction } from 'i18next'; +import { + getAlbumWithCredentials, + getArtistWithCredentials, + getSongWithCredentials, +} from '../../api/subsonicEntityWithCredentials'; +import { getAlbum, getSong } from '../../api/subsonicLibrary'; +import { getArtist } from '../../api/subsonicArtists'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; +import { useAuthStore } from '../../store/authStore'; +import type { ServerProfile } from '../../store/authStoreTypes'; +import { usePlayerStore } from '../../store/playerStore'; +import { songToTrack } from '../playback/songToTrack'; +import type { Track } from '../../store/playerStoreTypes'; +import { orbitBulkGuard } from '../orbitBulkGuard'; +import { findServerIdForShareUrl } from './shareLink'; +import type { + AlbumShareSearchPayload, + ArtistShareSearchPayload, + ComposerShareSearchPayload, + QueueableShareSearchPayload, +} from './shareSearch'; +import { showToast } from '../ui/toast'; + +const RESOLVE_QUEUE_CHUNK = 12; + +type ShareServerLookupResult = + | { type: 'ok'; serverId: string; server: ServerProfile } + | { type: 'not-logged-in' } + | { type: 'no-matching-server'; url: string }; + +type ShareResolveOptions = { + activateServer?: boolean; +}; + +export type ShareSearchResolveResult = + | { type: 'ok'; songs: SubsonicSong[]; total: number; skipped: number } + | { type: 'not-logged-in' } + | { type: 'no-matching-server'; url: string } + | { type: 'all-unavailable' } + | { type: 'error' }; + +export type ShareSearchAlbumResolveResult = + | { type: 'ok'; album: SubsonicAlbum } + | { type: 'not-logged-in' } + | { type: 'no-matching-server'; url: string } + | { type: 'unavailable' } + | { type: 'error' }; + +export type ShareSearchArtistResolveResult = + | { type: 'ok'; artist: SubsonicArtist } + | { type: 'not-logged-in' } + | { type: 'no-matching-server'; url: string } + | { type: 'unavailable' } + | { type: 'error' }; + +function lookupShareServer(shareSrv: string): ShareServerLookupResult { + const { servers, isLoggedIn } = useAuthStore.getState(); + if (!isLoggedIn) { + return { type: 'not-logged-in' }; + } + + const serverId = findServerIdForShareUrl(servers, shareSrv); + const server = serverId ? servers.find(s => s.id === serverId) : undefined; + if (!serverId || !server) { + return { type: 'no-matching-server', url: shareSrv }; + } + + return { type: 'ok', serverId, server }; +} + +function activateShareServer(serverId: string): void { + const { activeServerId, setActiveServer } = useAuthStore.getState(); + if (activeServerId !== serverId) { + setActiveServer(serverId); + } +} + +export function activateShareSearchServer(shareSrv: string, t: TFunction): boolean { + const lookup = lookupShareServer(shareSrv); + if (lookup.type === 'not-logged-in') { + showToast(t('sharePaste.notLoggedIn'), 4000, 'info'); + return false; + } + if (lookup.type === 'no-matching-server') { + showToast(t('sharePaste.noMatchingServer', { url: lookup.url }), 6000, 'error'); + return false; + } + + activateShareServer(lookup.serverId); + return true; +} + +async function resolveSharedSong( + id: string, + lookup: Extract, + options: ShareResolveOptions, +): Promise { + if (options.activateServer) { + activateShareServer(lookup.serverId); + return getSong(id); + } + return getSongWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, id); +} + +async function getAlbumAfterActivation( + id: string, + serverId: string, +): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[] }> { + activateShareServer(serverId); + return getAlbum(id); +} + +async function getArtistAfterActivation( + id: string, + serverId: string, +): Promise<{ artist: SubsonicArtist; albums: SubsonicAlbum[] }> { + activateShareServer(serverId); + return getArtist(id); +} + +export async function resolveShareSearchPayload( + payload: QueueableShareSearchPayload, + options: ShareResolveOptions = {}, +): Promise { + const lookup = lookupShareServer(payload.srv); + if (lookup.type === 'not-logged-in') { + return { type: 'not-logged-in' }; + } + if (lookup.type === 'no-matching-server') { + return { type: 'no-matching-server', url: lookup.url }; + } + + try { + const ids = payload.k === 'track' ? [payload.id] : payload.ids; + const resolved: SubsonicSong[] = []; + for (let i = 0; i < ids.length; i += RESOLVE_QUEUE_CHUNK) { + const chunk = ids.slice(i, i + RESOLVE_QUEUE_CHUNK); + const songs = await Promise.all(chunk.map(id => resolveSharedSong(id, lookup, options))); + for (const song of songs) { + if (song) resolved.push(song); + } + } + + const skipped = ids.length - resolved.length; + if (resolved.length === 0) { + return { type: 'all-unavailable' }; + } + + return { type: 'ok', songs: resolved, total: ids.length, skipped }; + } catch { + return { type: 'error' }; + } +} + +export async function resolveShareSearchAlbum( + payload: AlbumShareSearchPayload, + options: ShareResolveOptions = {}, +): Promise { + const lookup = lookupShareServer(payload.srv); + if (lookup.type === 'not-logged-in') { + return { type: 'not-logged-in' }; + } + if (lookup.type === 'no-matching-server') { + return { type: 'no-matching-server', url: lookup.url }; + } + + try { + const { album } = options.activateServer + ? await getAlbumAfterActivation(payload.id, lookup.serverId) + : await getAlbumWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + return { type: 'ok', album }; + } catch { + return { type: 'unavailable' }; + } +} + +export async function resolveShareSearchArtist( + payload: ArtistShareSearchPayload | ComposerShareSearchPayload, + options: ShareResolveOptions = {}, +): Promise { + const lookup = lookupShareServer(payload.srv); + if (lookup.type === 'not-logged-in') { + return { type: 'not-logged-in' }; + } + if (lookup.type === 'no-matching-server') { + return { type: 'no-matching-server', url: lookup.url }; + } + + try { + const { artist } = options.activateServer + ? await getArtistAfterActivation(payload.id, lookup.serverId) + : await getArtistWithCredentials(lookup.server.url, lookup.server.username, lookup.server.password, payload.id); + return { type: 'ok', artist }; + } catch { + return { type: 'unavailable' }; + } +} + +export async function enqueueShareSearchPayload( + payload: QueueableShareSearchPayload, + t: TFunction, +): Promise { + const resolved = await resolveShareSearchPayload(payload, { activateServer: true }); + if (resolved.type === 'not-logged-in') { + showToast(t('sharePaste.notLoggedIn'), 4000, 'info'); + return false; + } + if (resolved.type === 'no-matching-server') { + showToast(t('sharePaste.noMatchingServer', { url: resolved.url }), 6000, 'error'); + return false; + } + if (resolved.type === 'all-unavailable') { + showToast( + payload.k === 'track' ? t('sharePaste.trackUnavailable') : t('sharePaste.queueAllUnavailable'), + payload.k === 'track' ? 5000 : 6000, + 'error', + ); + return false; + } + if (resolved.type === 'error') { + showToast(t('sharePaste.genericError'), 5000, 'error'); + return false; + } + + try { + const tracks: Track[] = resolved.songs.map(songToTrack); + const okToEnqueue = await orbitBulkGuard(tracks.length); + if (!okToEnqueue) return false; + usePlayerStore.getState().enqueue(tracks, true); + if (resolved.skipped > 0) { + showToast( + t('search.shareQueuedPartial', { queued: tracks.length, total: resolved.total, skipped: resolved.skipped }), + 5000, + 'info', + ); + } else { + showToast(t('search.shareQueued', { count: tracks.length }), 3000, 'info'); + } + return true; + } catch (e) { + console.error('[psysonic] share search enqueue failed', e); + showToast(t('sharePaste.genericError'), 5000, 'error'); + return false; + } +} diff --git a/src/utils/share/shareSearch.test.ts b/src/utils/share/shareSearch.test.ts new file mode 100644 index 00000000..18c9401e --- /dev/null +++ b/src/utils/share/shareSearch.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { encodeServerMagicString } from '../server/serverMagicString'; +import { encodeSharePayload } from './shareLink'; +import { parseShareSearchText, sharePayloadTotal } from './shareSearch'; + +describe('share search parsing', () => { + it('detects track share links as queueable', () => { + const link = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'track', + id: 'song-1', + }); + + expect(parseShareSearchText(link)).toEqual({ + type: 'queueable', + payload: { + srv: 'https://music.example.com', + k: 'track', + id: 'song-1', + }, + }); + }); + + it('detects queue share links as queueable and counts their tracks', () => { + const link = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'queue', + ids: ['a', 'b', 'c'], + }); + const match = parseShareSearchText(`try this ${link}`); + + expect(match).toEqual({ + type: 'queueable', + payload: { + srv: 'https://music.example.com', + k: 'queue', + ids: ['a', 'b', 'c'], + }, + }); + if (match?.type === 'queueable') { + expect(sharePayloadTotal(match.payload)).toBe(3); + } + }); + + it('does not treat server magic strings as share-search links', () => { + const invite = encodeServerMagicString({ + url: 'https://music.example.com', + username: 'user', + password: 'pass', + }); + + expect(parseShareSearchText(invite)).toBeNull(); + }); + + it('detects album share links as album search results', () => { + const album = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'album', + id: 'album-1', + }); + + expect(parseShareSearchText(album)).toEqual({ + type: 'album', + payload: { + srv: 'https://music.example.com', + k: 'album', + id: 'album-1', + }, + }); + }); + + it('detects artist share links as artist search results', () => { + const artist = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'artist', + id: 'artist-1', + }); + + expect(parseShareSearchText(artist)).toEqual({ + type: 'artist', + payload: { + srv: 'https://music.example.com', + k: 'artist', + id: 'artist-1', + }, + }); + }); + + it('detects composer share links as composer search results', () => { + const composer = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'composer', + id: 'composer-1', + }); + + expect(parseShareSearchText(composer)).toEqual({ + type: 'composer', + payload: { + srv: 'https://music.example.com', + k: 'composer', + id: 'composer-1', + }, + }); + }); + + it('returns unsupported for invalid psysonic2 payloads', () => { + expect(parseShareSearchText('psysonic2-not-valid-base64!!!')).toEqual({ type: 'unsupported' }); + }); + + it('counts a single track in sharePayloadTotal', () => { + expect( + sharePayloadTotal({ srv: 'https://music.example.com', k: 'track', id: 'song-1' }), + ).toBe(1); + }); + + it('marks orbit invites as unsupported in search', () => { + const orbit = encodeSharePayload({ + srv: 'https://music.example.com', + k: 'orbit', + sid: '1234abcd', + }); + + expect(parseShareSearchText(orbit)).toEqual({ type: 'unsupported' }); + }); +}); diff --git a/src/utils/share/shareSearch.ts b/src/utils/share/shareSearch.ts new file mode 100644 index 00000000..33624a25 --- /dev/null +++ b/src/utils/share/shareSearch.ts @@ -0,0 +1,63 @@ +import { + decodeSharePayloadFromText, + PSYSONIC_SHARE_PREFIX, +} from './shareLink'; + +export type QueueableShareSearchPayload = + | { srv: string; k: 'track'; id: string } + | { srv: string; k: 'queue'; ids: string[] }; + +export type AlbumShareSearchPayload = { srv: string; k: 'album'; id: string }; +export type ArtistShareSearchPayload = { srv: string; k: 'artist'; id: string }; +export type ComposerShareSearchPayload = { srv: string; k: 'composer'; id: string }; + +export type ShareSearchMatch = + | { type: 'queueable'; payload: QueueableShareSearchPayload } + | { type: 'album'; payload: AlbumShareSearchPayload } + | { type: 'artist'; payload: ArtistShareSearchPayload } + | { type: 'composer'; payload: ComposerShareSearchPayload } + | { type: 'unsupported' }; + +export function parseShareSearchText(text: string): ShareSearchMatch | null { + const trimmed = text.trim(); + if (!trimmed.includes(PSYSONIC_SHARE_PREFIX)) return null; + + const payload = decodeSharePayloadFromText(trimmed); + if (!payload) return { type: 'unsupported' }; + if (payload.k === 'track') { + return { + type: 'queueable', + payload: { srv: payload.srv, k: 'track', id: payload.id }, + }; + } + if (payload.k === 'queue') { + return { + type: 'queueable', + payload: { srv: payload.srv, k: 'queue', ids: payload.ids }, + }; + } + if (payload.k === 'album') { + return { + type: 'album', + payload: { srv: payload.srv, k: 'album', id: payload.id }, + }; + } + if (payload.k === 'artist') { + return { + type: 'artist', + payload: { srv: payload.srv, k: 'artist', id: payload.id }, + }; + } + if (payload.k === 'composer') { + return { + type: 'composer', + payload: { srv: payload.srv, k: 'composer', id: payload.id }, + }; + } + + return { type: 'unsupported' }; +} + +export function sharePayloadTotal(payload: QueueableShareSearchPayload): number { + return payload.k === 'track' ? 1 : payload.ids.length; +} diff --git a/src/utils/share/shareServerOriginLabel.test.ts b/src/utils/share/shareServerOriginLabel.test.ts new file mode 100644 index 00000000..b5315b2e --- /dev/null +++ b/src/utils/share/shareServerOriginLabel.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { encodeSharePayload } from './shareLink'; +import { parseShareSearchText } from './shareSearch'; +import { shareServerOriginLabel } from './shareServerOriginLabel'; + +const home = { + id: 'home', + name: 'Home NAS', + url: 'https://music.home.example', + username: 'u1', + password: 'p1', +}; +const office = { + id: 'office', + name: 'Office', + url: 'https://music.office.example', + username: 'u2', + password: 'p2', +}; + +describe('shareServerOriginLabel', () => { + it('returns null when share targets the active server', () => { + const match = parseShareSearchText( + encodeSharePayload({ srv: home.url, k: 'track', id: 't-1' }), + ); + expect(shareServerOriginLabel(match, [home, office], 'home')).toBeNull(); + }); + + it('returns the saved server display name when share is from another saved server', () => { + const match = parseShareSearchText( + encodeSharePayload({ srv: office.url, k: 'track', id: 't-1' }), + ); + expect(shareServerOriginLabel(match, [home, office], 'home')).toBe('Office'); + }); + + it('returns null when the share server is not in saved profiles', () => { + const match = parseShareSearchText( + encodeSharePayload({ srv: 'https://unknown.example', k: 'track', id: 't-1' }), + ); + expect(shareServerOriginLabel(match, [home], 'home')).toBeNull(); + }); + + it('returns null for unsupported share payloads', () => { + expect(shareServerOriginLabel({ type: 'unsupported' }, [home], 'home')).toBeNull(); + }); +}); diff --git a/src/utils/share/shareServerOriginLabel.ts b/src/utils/share/shareServerOriginLabel.ts new file mode 100644 index 00000000..3df7dd59 --- /dev/null +++ b/src/utils/share/shareServerOriginLabel.ts @@ -0,0 +1,25 @@ +import type { ServerProfile } from '../../store/authStoreTypes'; +import { serverListDisplayLabel } from '../server/serverDisplayName'; +import { findServerIdForShareUrl } from './shareLink'; +import type { ShareSearchMatch } from './shareSearch'; + +/** + * 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 + * unsupported, or does not match any saved server profile. + */ +export function shareServerOriginLabel( + shareMatch: ShareSearchMatch | null, + servers: ServerProfile[], + activeServerId: string | null, +): string | null { + if (!shareMatch || shareMatch.type === 'unsupported') return null; + + const shareServerId = findServerIdForShareUrl(servers, shareMatch.payload.srv); + if (!shareServerId || shareServerId === activeServerId) return null; + + const server = servers.find(s => s.id === shareServerId); + if (!server) return null; + + return serverListDisplayLabel(server, servers); +}