mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
Compare commits
2 Commits
6759ee40e2
...
c74e8f1921
| Author | SHA1 | Date | |
|---|---|---|---|
| c74e8f1921 | |||
| 7dfe58dba6 |
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Пусни цялата дискография',
|
||||
shuffleTooltip: 'Разбъркай цялата дискография',
|
||||
radioTooltip: 'Стартирай радио на базата на този изпълнител',
|
||||
enqueue: 'Добави в опашката',
|
||||
enqueueTooltip: 'Добави цялата дискография в опашката',
|
||||
loading: 'Зареждане…',
|
||||
noRadio: 'Няма намерени подобни песни за този изпълнител.',
|
||||
notFound: 'Изпълнителят не е намерен.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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ó.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'ディスコグラフィ全体を再生',
|
||||
shuffleTooltip: 'ディスコグラフィ全体をシャッフル',
|
||||
radioTooltip: 'このアーティストをもとにラジオを開始',
|
||||
enqueue: 'キューに追加',
|
||||
enqueueTooltip: 'ディスコグラフィ全体をキューに追加',
|
||||
loading: '読み込み中…',
|
||||
noRadio: 'このアーティストの類似トラックは見つかりませんでした。',
|
||||
notFound: 'アーティストが見つかりません。',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: 'Воспроизвести всю дискографию',
|
||||
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
|
||||
radioTooltip: 'Запустить радио на основе этого исполнителя',
|
||||
enqueue: 'В очередь',
|
||||
enqueueTooltip: 'Добавить всю дискографию в очередь',
|
||||
loading: 'Загрузка…',
|
||||
noRadio: 'Похожие треки для этого исполнителя не найдены.',
|
||||
notFound: 'Исполнитель не найден.',
|
||||
|
||||
@@ -11,6 +11,8 @@ export const artistDetail = {
|
||||
playAllTooltip: '播放全部作品',
|
||||
shuffleTooltip: '随机播放全部作品',
|
||||
radioTooltip: '基于该艺人开始电台',
|
||||
enqueue: '加入队列',
|
||||
enqueueTooltip: '将全部作品添加到播放队列',
|
||||
loading: '加载中…',
|
||||
noRadio: '未找到该艺术家的相似曲目。',
|
||||
notFound: '未找到艺术家。',
|
||||
|
||||
Reference in New Issue
Block a user