Compare commits

...

2 Commits

Author SHA1 Message Date
Psychotoxical c74e8f1921 fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session (#1322)
* fix(api): align native client UA with the WebView to collapse the duplicate Navidrome session

* docs(changelog): duplicate Navidrome session fix (#1322)
2026-07-17 13:56:18 +02:00
Psychotoxical 7dfe58dba6 feat(artist): add "add to queue" for the whole discography (#1321)
* feat(artist): enqueue whole discography from artist page

* i18n(artist): add enqueue-all button strings

* docs(changelog): artist discography enqueue (#1321)
2026-07-17 12:37:25 +02:00
22 changed files with 125 additions and 7 deletions
+12
View File
@@ -139,6 +139,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
### Artist page — add the whole discography to the queue
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
## Changed
@@ -351,6 +357,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The Square Corners toggle left the player bar cover and the small cover thumbnails in list rows rounded (queue, playlists, favorites, search, Random Mix). They now go square with everything else; the floating player bar's circular cover stays round by design.
### Duplicate server session on Navidrome
**By [@Psychotoxical](https://github.com/Psychotoxical), reported by TheHomeGuy on the Psysonic Discord, PR [#1322](https://github.com/Psychotoxical/psysonic/pull/1322)**
* Native requests carried a separate User-Agent from the in-app view, so the server listed the app as two logged-in players at once. They now share one identity and show as a single session.
## [1.49.0] - 2026-06-29
@@ -123,7 +123,10 @@ pub fn nd_http_client() -> reqwest::Client {
// the WebKit-side Subsonic calls end up negotiating most of the time
// on these setups.
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (the main WebView's User-Agent once the frontend reports
// it at startup) so Navidrome logs these native calls under the same
// client as the WebView instead of a second `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.http1_only()
.pool_max_idle_per_host(0)
.max_tls_version(reqwest::tls::Version::TLS_1_2)
@@ -413,7 +413,10 @@ struct MusicFoldersWrapper {
fn default_http_client() -> reqwest::Client {
reqwest::Client::builder()
.user_agent(format!("Psysonic/{} (Tauri)", env!("CARGO_PKG_VERSION")))
// Shared wire UA (aligned with the main WebView at startup) so native
// Subsonic calls share the WebView's client identity on the server
// instead of registering a separate `[Psysonic]` session.
.user_agent(psysonic_core::user_agent::subsonic_wire_user_agent())
.build()
.unwrap_or_else(|_| reqwest::Client::new())
}
+1
View File
@@ -430,6 +430,7 @@ const CONTRIBUTOR_ENTRIES = [
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
'Artist page — add the whole discography to the queue (PR #1321)',
],
},
{
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useAlbumDetailBack } from '@/features/album';
import {
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
ListPlus, Loader2, Play, Radio, Share2, Shuffle, Users,
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { useOfflineStore } from '@/features/offline';
@@ -35,6 +35,7 @@ interface Props {
toggleStar: () => Promise<void>;
handlePlayAll: () => void;
handleShuffle: () => void;
handleEnqueueAll: () => void;
handleStartRadio: () => void;
handleShareArtist: () => void;
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
@@ -100,7 +101,7 @@ function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
export default function ArtistDetailHero({
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
toggleStar, handlePlayAll, handleShuffle, handleEnqueueAll, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
@@ -302,6 +303,15 @@ export default function ArtistDetailHero({
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={playAllLoading}
aria-label={t('artistDetail.enqueue')}
{...tooltipAttrs(t('artistDetail.enqueueTooltip'))}
>
<ListPlus size={16} />
</button>
</>
)}
<button
+6 -1
View File
@@ -18,7 +18,8 @@ import {
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '@/features/artist/hooks/useArtistSimilarArtists';
import {
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle,
runArtistDetailStartRadio, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
@@ -113,6 +114,9 @@ export default function ArtistDetail() {
const handleShuffle = () => runArtistDetailShuffle({
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
});
const handleEnqueueAll = () => runArtistDetailEnqueueAll({
albums, serverId: activeServerId, setPlayAllLoading, enqueue,
});
const handleStartRadio = () => {
if (!artist) return;
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
@@ -270,6 +274,7 @@ export default function ArtistDetail() {
toggleStar={toggleStar}
handlePlayAll={handlePlayAll}
handleShuffle={handleShuffle}
handleEnqueueAll={handleEnqueueAll}
handleStartRadio={handleStartRadio}
handleShareArtist={handleShareArtist}
handleImageUpload={handleImageUpload}
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import * as offlineMediaResolve from '@/features/offline';
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from '@/features/artist/utils/runArtistDetailPlay';
import {
fetchArtistDetailTracks, runArtistDetailPlayTopSong, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
vi.mock('@/features/offline', () => ({
resolveAlbum: vi.fn(),
@@ -99,3 +101,39 @@ describe('runArtistDetailPlayTopSong', () => {
);
});
});
describe('runArtistDetailEnqueueAll', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('enqueues the whole ordered discography', async () => {
resolveAlbumMock
.mockResolvedValueOnce({
album: albums[1],
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
})
.mockResolvedValueOnce({
album: albums[0],
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
});
const enqueue = vi.fn();
const setPlayAllLoading = vi.fn();
await runArtistDetailEnqueueAll({ albums, serverId: 'srv-1', setPlayAllLoading, enqueue });
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
});
it('is a no-op with no albums', async () => {
const enqueue = vi.fn();
await runArtistDetailEnqueueAll({ albums: [], serverId: 'srv-1', setPlayAllLoading: vi.fn(), enqueue });
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
});
});
@@ -3,7 +3,7 @@ import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '@/features/playback/utils/playback/runBulkPlay';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
@@ -82,6 +82,24 @@ export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Pro
});
}
export interface RunArtistDetailEnqueueDeps {
albums: SubsonicAlbum[];
serverId?: string | null;
setPlayAllLoading: (v: boolean) => void;
enqueue: (tracks: Track[]) => void;
}
/** Append the whole artist discography to the play queue. */
export async function runArtistDetailEnqueueAll(deps: RunArtistDetailEnqueueDeps): Promise<void> {
const { albums, serverId, setPlayAllLoading, enqueue } = deps;
if (albums.length === 0) return;
await runBulkEnqueue({
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
setLoading: setPlayAllLoading,
enqueue,
});
}
export interface RunArtistDetailStartRadioDeps {
artist: SubsonicArtist;
t: TFunction;
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Пусни цялата дискография',
shuffleTooltip: 'Разбъркай цялата дискография',
radioTooltip: 'Стартирай радио на базата на този изпълнител',
enqueue: 'Добави в опашката',
enqueueTooltip: 'Добави цялата дискография в опашката',
loading: 'Зареждане…',
noRadio: 'Няма намерени подобни песни за този изпълнител.',
notFound: 'Изпълнителят не е намерен.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Gesamte Diskografie abspielen',
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
radioTooltip: 'Radio basierend auf diesem Künstler starten',
enqueue: 'Einreihen',
enqueueTooltip: 'Gesamte Diskografie zur Warteschlange hinzufügen',
loading: 'Lädt…',
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
notFound: 'Künstler nicht gefunden.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Play the whole discography',
shuffleTooltip: 'Shuffle the whole discography',
radioTooltip: 'Start a radio based on this artist',
enqueue: 'Enqueue',
enqueueTooltip: 'Add the whole discography to queue',
loading: 'Loading…',
noRadio: 'No similar tracks found for this artist.',
notFound: 'Artist not found.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Reproducir toda la discografía',
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
radioTooltip: 'Iniciar una radio basada en este artista',
enqueue: 'Agregar a la Cola',
enqueueTooltip: 'Agregar toda la discografía a la cola',
loading: 'Cargando…',
noRadio: 'No se encontraron pistas similares para este artista.',
notFound: 'Artista no encontrado.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Lire toute la discographie',
shuffleTooltip: 'Lire toute la discographie en aléatoire',
radioTooltip: 'Démarrer une radio basée sur cet artiste',
enqueue: 'Mettre en file',
enqueueTooltip: 'Ajouter toute la discographie à la file d\'attente',
loading: 'Chargement…',
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
notFound: 'Artiste introuvable.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'A teljes diszkográfia lejátszása',
shuffleTooltip: 'A teljes diszkográfia keverése',
radioTooltip: 'Rádió indítása az előadó alapján',
enqueue: 'Sorba',
enqueueTooltip: 'A teljes diszkográfia hozzáadása a sorhoz',
loading: 'Betöltés…',
noRadio: 'Nem található hasonló szám ehhez az előadóhoz.',
notFound: 'Az előadó nem található.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Ascolta tutta la discografia',
shuffleTooltip: 'Mescola tutta la discografia',
radioTooltip: 'Crea una radio basata su questo artista',
enqueue: 'Accoda',
enqueueTooltip: 'Aggiungi tutta la discografia alla coda',
loading: 'Caricamento…',
noRadio: 'Non sono stati trovati brani simili per questo artista.',
notFound: 'Artista non trovato.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'ディスコグラフィ全体を再生',
shuffleTooltip: 'ディスコグラフィ全体をシャッフル',
radioTooltip: 'このアーティストをもとにラジオを開始',
enqueue: 'キューに追加',
enqueueTooltip: 'ディスコグラフィ全体をキューに追加',
loading: '読み込み中…',
noRadio: 'このアーティストの類似トラックは見つかりませんでした。',
notFound: 'アーティストが見つかりません。',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Spill hele diskografien',
shuffleTooltip: 'Spill hele diskografien i tilfeldig rekkefølge',
radioTooltip: 'Start en radio basert på denne artisten',
enqueue: 'Legg i kø',
enqueueTooltip: 'Legg hele diskografien i kø',
loading: 'Laster…',
noRadio: 'Ingen lignende spor funnet for denne artisten.',
notFound: 'Artisten ble ikke funnet.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Volledige discografie afspelen',
shuffleTooltip: 'Volledige discografie willekeurig afspelen',
radioTooltip: 'Radio op basis van deze artiest starten',
enqueue: 'In wachtrij',
enqueueTooltip: 'Volledige discografie aan wachtrij toevoegen',
loading: 'Laden…',
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
notFound: 'Artiest niet gevonden.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Odtwórz całą dyskografię',
shuffleTooltip: 'Przetasuj całą dyskografię',
radioTooltip: 'Rozpocznij radio w oparciu o tego wykonawcę',
enqueue: 'Dodaj do kolejki',
enqueueTooltip: 'Dodaj całą dyskografię do kolejki',
loading: 'Ładowanie…',
noRadio: 'Nie znaleziono podobnych utworów dla tego wykonawcy.',
notFound: 'Wykonawca nie znaleziony.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Redă întreaga discografie',
shuffleTooltip: 'Redă întreaga discografie amestecat',
radioTooltip: 'Pornește un radio bazat pe acest artist',
enqueue: 'Adaugă în coadă',
enqueueTooltip: 'Adaugă întreaga discografie în coadă',
loading: 'Se încarcă…',
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
notFound: 'Artistul nu a fost găsit.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Воспроизвести всю дискографию',
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
radioTooltip: 'Запустить радио на основе этого исполнителя',
enqueue: 'В очередь',
enqueueTooltip: 'Добавить всю дискографию в очередь',
loading: 'Загрузка…',
noRadio: 'Похожие треки для этого исполнителя не найдены.',
notFound: 'Исполнитель не найден.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: '播放全部作品',
shuffleTooltip: '随机播放全部作品',
radioTooltip: '基于该艺人开始电台',
enqueue: '加入队列',
enqueueTooltip: '将全部作品添加到播放队列',
loading: '加载中…',
noRadio: '未找到该艺术家的相似曲目。',
notFound: '未找到艺术家。',