diff --git a/CHANGELOG.md b/CHANGELOG.md index 95d1c96e..986a03ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.46.0] - 2026-05-05 +## Added + +### Discord — album cover art from your own server + +**By [@Sayykii](https://github.com/Sayykii), PR [#462](https://github.com/Psychotoxical/psysonic/pull/462)** + +* Discord Rich Presence can now show **album artwork from your own server** via the Subsonic `getAlbumInfo2` endpoint (requires the server to be publicly reachable). +* New cover-source picker under Discord Rich Presence settings: **None** (app icon only), **Server**, or **Apple Music**. Mutually exclusive. +* Fresh installs default to **Server** for opt-in-friendly cover art with no third-party data leak. Existing users keep their previous Apple-covers preference via migration. + ## Changed ### Dependencies — npm / Cargo refresh and rodio 0.22 diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index c843102c..ae5d1b58 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -1053,6 +1053,23 @@ export function buildDownloadUrl(id: string): string { return `${baseUrl}/rest/download.view?${p.toString()}`; } +// ─── Album Info (public image URLs from Last.fm/MusicBrainz) ── +export interface AlbumInfo { + largeImageUrl?: string; + mediumImageUrl?: string; + smallImageUrl?: string; + notes?: string; +} + +export async function getAlbumInfo2(albumId: string): Promise { + try { + const data = await api<{ albumInfo: AlbumInfo }>('getAlbumInfo2.view', { id: albumId }); + return data.albumInfo ?? null; + } catch { + return null; + } +} + // ─── Playlists ──────────────────────────────────────────────── export async function getPlaylists(includeOrbit = false): Promise { const data = await api<{ playlists: { playlist: SubsonicPlaylist[] } }>('getPlaylists.view', { _t: Date.now() }); diff --git a/src/locales/de.ts b/src/locales/de.ts index 60a05aa3..918100db 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -709,8 +709,11 @@ export const deTranslation = { useCustomTitlebarDesc: 'Ersetzt die System-Titelleiste durch eine eingebaute, die zum App-Theme passt. Deaktivieren, um die native GNOME/GTK-Titelleiste zu verwenden.', linuxWebkitSmoothScroll: 'Sanftes Mausrad (Linux)', linuxWebkitSmoothScrollDesc: 'An: mit Nachlauf. Aus: zeilenweise wie in GTK-Apps.', - discordAppleCovers: 'Cover über Apple Music für Discord laden', - discordAppleCoversDesc: 'Sendet Künstler- und Albumname an die Apple-Such-API, um Cover für dein Discord-Profil zu finden. Standardmäßig aus Datenschutzgründen deaktiviert.', + discordCoverSource: 'Cover-Quelle', + discordCoverSourceDesc: 'Woher das Album-Cover für dein Discord-Profil geladen wird.', + discordCoverNone: 'Keine (nur App-Symbol)', + discordCoverServer: 'Server (über Album-Info)', + discordCoverApple: 'Apple Music', discordOptions: 'Erweiterte Discord-Optionen', discordTemplates: 'Benutzerdefinierte Textvorlagen', discordTemplatesDesc: 'Passen Sie an, welche Informationen in Ihrem Discord-Profil angezeigt werden. Variablen: {title}, {artist}, {album}', diff --git a/src/locales/en.ts b/src/locales/en.ts index 73e3af06..5815d342 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -715,8 +715,11 @@ export const enTranslation = { useCustomTitlebarDesc: 'Replace the system title bar with a built-in one that matches the app theme. Disable to use the native GNOME/GTK title bar.', linuxWebkitSmoothScroll: 'Smooth wheel (Linux)', linuxWebkitSmoothScrollDesc: 'On: inertial scroll. Off: line-by-line, GTK-style.', - discordAppleCovers: 'Fetch covers from Apple Music for Discord', - discordAppleCoversDesc: 'Sends the artist and album name to Apple\'s search API to find cover art for your Discord profile. Disabled by default for privacy.', + discordCoverSource: 'Cover art source', + discordCoverSourceDesc: 'Where to fetch album artwork shown on your Discord profile.', + discordCoverNone: 'None (app icon only)', + discordCoverServer: 'Server (via album info)', + discordCoverApple: 'Apple Music', discordOptions: 'Advanced Discord options', discordTemplates: 'Custom text templates', discordTemplatesDesc: 'Customize what information is shown on your Discord profile. Variables: {title}, {artist}, {album}', diff --git a/src/locales/es.ts b/src/locales/es.ts index 578c77d1..d72c11a8 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -702,8 +702,11 @@ export const esTranslation = { useCustomTitlebarDesc: 'Reemplaza la barra de título del sistema con una integrada que coincide con el tema de la app. Desactiva para usar la barra nativa de GNOME/GTK.', linuxWebkitSmoothScroll: 'Rueda suave (Linux)', linuxWebkitSmoothScrollDesc: 'Activado: inercia. Desactivado: pasos por línea (estilo GTK).', - discordAppleCovers: 'Obtener portadas de Apple Music para Discord', - discordAppleCoversDesc: 'Envía el artista y nombre del álbum a la API de búsqueda de Apple para encontrar portadas para tu perfil de Discord. Desactivado por defecto por privacidad.', + discordCoverSource: 'Fuente de portada', + discordCoverSourceDesc: 'De dónde obtener la portada del álbum para tu perfil de Discord.', + discordCoverNone: 'Ninguna (solo icono de la app)', + discordCoverServer: 'Servidor (vía info del álbum)', + discordCoverApple: 'Apple Music', discordOptions: 'Opciones avanzadas de Discord', discordTemplates: 'Plantillas de texto personalizadas', discordTemplatesDesc: 'Personaliza qué información se muestra en tu perfil de Discord. Variables: {title}, {artist}, {album}', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 48bf8076..9b8b1a31 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -697,8 +697,11 @@ export const frTranslation = { linuxWebkitSmoothScrollDesc: 'Activé : inertie. Désactivé : pas à la ligne, style GTK.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Affiche le titre en cours de lecture sur votre profil Discord. Discord doit être ouvert.', - discordAppleCovers: 'Récupérer les pochettes via Apple Music pour Discord', - discordAppleCoversDesc: 'Envoie le nom de l\'artiste et de l\'album à l\'API Apple pour trouver une pochette pour votre profil Discord. Désactivé par défaut pour des raisons de confidentialité.', + discordCoverSource: 'Source de pochette', + discordCoverSourceDesc: 'D\'où récupérer la pochette pour votre profil Discord.', + discordCoverNone: 'Aucune (icône de l\'app uniquement)', + discordCoverServer: 'Serveur (via infos album)', + discordCoverApple: 'Apple Music', discordOptions: 'Options Discord avancées', discordTemplates: 'Modèles de texte personnalisés', discordTemplatesDesc: 'Personnalisez les informations affichées sur votre profil Discord. Variables : {title}, {artist}, {album}', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 1358e861..f140cbcc 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -696,8 +696,11 @@ export const nbTranslation = { linuxWebkitSmoothScrollDesc: 'På: treg rull med etterslep. Av: trinnvis som i GTK.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Vis sporet som spilles i din Discord-profil. Krever at Discord kjører.', - discordAppleCovers: 'Hent covere fra Apple Music til Discord', - discordAppleCoversDesc: 'Sender artist- og albumnavn til Apples søke-API for å finne cover til Discord-profilen din. Deaktivert som standard av personvernhensyn.', + discordCoverSource: 'Coverkilde', + discordCoverSourceDesc: 'Hvor albumcoveret for Discord-profilen din hentes fra.', + discordCoverNone: 'Ingen (kun app-ikon)', + discordCoverServer: 'Server (via albuminfo)', + discordCoverApple: 'Apple Music', discordOptions: 'Avanserte Discord-alternativer', discordTemplates: 'Egendefinerte tekstmaler', discordTemplatesDesc: 'Tilpass hvilken informasjon som vises på Discord-profilen din. Variabler: {title}, {artist}, {album}', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index b2bf39c0..873d5875 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -696,8 +696,11 @@ export const nlTranslation = { linuxWebkitSmoothScrollDesc: 'Aan: traag naloop. Uit: regel voor regel, GTK-stijl.', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: 'Toont het huidige nummer op je Discord-profiel. Discord moet daarvoor geopend zijn.', - discordAppleCovers: 'Hoezen ophalen via Apple Music voor Discord', - discordAppleCoversDesc: 'Stuurt artiest- en albumnaam naar de Apple-zoek-API om een hoes te vinden voor je Discord-profiel. Standaard uitgeschakeld vanwege privacy.', + discordCoverSource: 'Hoesbron', + discordCoverSourceDesc: 'Waar de albumhoes voor je Discord-profiel vandaan komt.', + discordCoverNone: 'Geen (alleen app-icoon)', + discordCoverServer: 'Server (via albuminfo)', + discordCoverApple: 'Apple Music', discordOptions: 'Geavanceerde Discord-opties', discordTemplates: 'Aangepaste tekstsjablonen', discordTemplatesDesc: 'Pas aan welke informatie wordt weergegeven op je Discord-profiel. Variabelen: {title}, {artist}, {album}', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 712d0ee7..2fda4c18 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -739,8 +739,11 @@ export const ruTranslation = { discordRichPresence: 'Статус в Discord', discordRichPresenceDesc: 'Показывать текущий трек в профиле и статусе Discord. Нужен запущенный клиент Discord.', - discordAppleCovers: 'Загружать обложки через Apple Music для Discord', - discordAppleCoversDesc: 'Отправляет имя исполнителя и альбома в Apple Music API для поиска обложки для профиля Discord. По умолчанию отключено из соображений конфиденциальности.', + discordCoverSource: 'Источник обложки', + discordCoverSourceDesc: 'Откуда загружать обложку альбома для профиля Discord.', + discordCoverNone: 'Нет (только иконка приложения)', + discordCoverServer: 'Сервер (через информацию об альбоме)', + discordCoverApple: 'Apple Music', discordOptions: 'Расширенные параметры Discord', discordTemplates: 'Пользовательские шаблоны текста', discordTemplatesDesc: 'Настройте, какая информация отображается в профиле Discord. Переменные: {title}, {artist}, {album}', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 0a451091..10f0083d 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -691,8 +691,11 @@ export const zhTranslation = { linuxWebkitSmoothScrollDesc: '开:惯性滚动。关:逐行,类似 GTK。', discordRichPresence: 'Discord Rich Presence', discordRichPresenceDesc: '在 Discord 个人资料上显示当前播放的曲目。需要 Discord 处于运行状态。', - discordAppleCovers: '通过 Apple Music 为 Discord 获取封面', - discordAppleCoversDesc: '将艺术家和专辑名称发送至 Apple 搜索 API,以为 Discord 个人资料查找封面图片。出于隐私考虑,默认禁用。', + discordCoverSource: '封面来源', + discordCoverSourceDesc: '从何处获取 Discord 个人资料上显示的专辑封面。', + discordCoverNone: '无(仅显示应用图标)', + discordCoverServer: '服务器(通过专辑信息)', + discordCoverApple: 'Apple Music', discordOptions: 'Discord 高级选项', discordTemplates: '自定义文本模板', discordTemplatesDesc: '自定义在 Discord 个人资料上显示的信息。变量:{title}, {artist}, {album}', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index e7dee5aa..5ac28c26 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -269,6 +269,13 @@ const CONTRIBUTORS = [ 'WebView2 idle hooks when Tauri windows are hidden — Windows GPU and compositor mitigation (PR #273)', ], }, + { + github: 'Sayykii', + since: '1.46.0', + contributions: [ + 'Discord Rich Presence: cover art from your own server (Subsonic getAlbumInfo2) with three-way picker — none / server / Apple Music (PR #462)', + ], + }, { github: 'Psychotoxical', since: '1.0.0', @@ -2803,14 +2810,36 @@ export default function Settings() { {auth.discordRichPresence && ( <> -
-
-
-
{t('settings.discordAppleCovers')}
-
{t('settings.discordAppleCoversDesc')}
-
-
@@ -2835,7 +2864,7 @@ export default function Settings() { type="text" value={auth.discordTemplateState} onChange={e => auth.setDiscordTemplateState(e.target.value)} - placeholder="{album}" + placeholder="{title}" />
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 51571c66..1df00177 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -26,6 +26,7 @@ export type SeekbarStyle = 'truewave' | 'pseudowave' | 'linedot' | 'bar' | 'thic export type LoggingMode = 'off' | 'normal' | 'debug'; export type NormalizationEngine = 'off' | 'replaygain' | 'loudness'; export type AnimationMode = 'full' | 'reduced' | 'static'; +export type DiscordCoverSource = 'none' | 'apple' | 'server'; /** Integrated-loudness target presets (Settings + analysis). */ export type LoudnessLufsPreset = -16 | -14 | -12 | -10; @@ -136,7 +137,7 @@ interface AuthState { * touch Orbit can hide it so the header stays uncluttered. */ showOrbitTrigger: boolean; discordRichPresence: boolean; - enableAppleMusicCoversDiscord: boolean; + discordCoverSource: DiscordCoverSource; /** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */ enableBandsintown: boolean; discordTemplateDetails: string; @@ -311,7 +312,7 @@ interface AuthState { setMinimizeToTray: (v: boolean) => void; setShowOrbitTrigger: (v: boolean) => void; setDiscordRichPresence: (v: boolean) => void; - setEnableAppleMusicCoversDiscord: (v: boolean) => void; + setDiscordCoverSource: (v: DiscordCoverSource) => void; setEnableBandsintown: (v: boolean) => void; setDiscordTemplateDetails: (v: string) => void; setDiscordTemplateState: (v: string) => void; @@ -448,10 +449,10 @@ export const useAuthStore = create()( minimizeToTray: false, showOrbitTrigger: true, discordRichPresence: false, - enableAppleMusicCoversDiscord: false, + discordCoverSource: 'server', enableBandsintown: false, - discordTemplateDetails: '{artist} - {title}', - discordTemplateState: '{album}', + discordTemplateDetails: '{artist}', + discordTemplateState: '{title}', discordTemplateLargeText: '{album}', useCustomTitlebar: false, preloadMiniPlayer: false, @@ -611,7 +612,7 @@ export const useAuthStore = create()( setMinimizeToTray: (v) => set({ minimizeToTray: v }), setShowOrbitTrigger: (v) => set({ showOrbitTrigger: v }), setDiscordRichPresence: (v) => set({ discordRichPresence: v }), - setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }), + setDiscordCoverSource: (v) => set({ discordCoverSource: v }), setEnableBandsintown: (v) => set({ enableBandsintown: v }), setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }), setDiscordTemplateState: (v) => set({ discordTemplateState: v }), @@ -864,6 +865,13 @@ export const useAuthStore = create()( ) : DEFAULT_LOUDNESS_PRE_ANALYSIS_ATTENUATION_DB); + // Migrate enableAppleMusicCoversDiscord boolean → discordCoverSource enum. + let discordCoverSourceMigrated: { discordCoverSource?: DiscordCoverSource } = {}; + const legacyAppleCovers = (state as { enableAppleMusicCoversDiscord?: unknown }).enableAppleMusicCoversDiscord; + if (legacyAppleCovers === true && (!state.discordCoverSource || state.discordCoverSource === 'none')) { + discordCoverSourceMigrated = { discordCoverSource: 'apple' }; + } + useAuthStore.setState({ mixMinRatingSong: clampMixFilterMinStars(state.mixMinRatingSong as number), mixMinRatingAlbum: clampMixFilterMinStars(state.mixMinRatingAlbum as number), @@ -880,6 +888,7 @@ export const useAuthStore = create()( ...wheelSmoothOneTime, ...seekbarStyleMigrated, ...animationModeMigrated, + ...discordCoverSourceMigrated, }); }, } diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 5c2edbaa..ac554be9 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -3,7 +3,7 @@ import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { showToast } from '../utils/toast'; -import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating } from '../api/subsonic'; +import { buildCoverArtUrl, buildStreamUrl, getPlayQueue, savePlayQueue, reportNowPlaying, scrobbleSong, SubsonicSong, getSong, getRandomSongs, getSimilarSongs2, getTopSongs, InternetRadioStation, setRating, getAlbumInfo2 } from '../api/subsonic'; import { resolvePlaybackUrl, streamUrlTrackId, getPlaybackSourceKind, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; import { redactSubsonicUrlForLog } from '../utils/redactSubsonicUrl'; import { setDeferHotCachePrefetch } from '../utils/hotCacheGate'; @@ -1968,13 +1968,15 @@ export function initAudioListeners(): () => void { let discordPrevTemplateDetails: string | null = null; let discordPrevTemplateState: string | null = null; let discordPrevTemplateLargeText: string | null = null; + let discordPrevCoverSource: string | null = null; + const discordServerCoverCache = new Map(); function syncDiscord() { const { currentTrack, isPlaying } = usePlayerStore.getState(); const currentTime = getPlaybackProgressSnapshot().currentTime; const { discordRichPresence, - enableAppleMusicCoversDiscord, + discordCoverSource, discordTemplateDetails, discordTemplateState, discordTemplateLargeText, @@ -1985,6 +1987,7 @@ export function initAudioListeners(): () => void { discordPrevTrackId = null; discordPrevIsPlaying = null; discordPrevFetchCovers = null; + discordPrevCoverSource = null; discordPrevTemplateDetails = null; discordPrevTemplateState = null; discordPrevTemplateLargeText = null; @@ -1995,33 +1998,49 @@ export function initAudioListeners(): () => void { const trackChanged = currentTrack.id !== discordPrevTrackId; const playingChanged = isPlaying !== discordPrevIsPlaying; - const coversSettingChanged = enableAppleMusicCoversDiscord !== discordPrevFetchCovers; + const coverSourceChanged = discordCoverSource !== discordPrevCoverSource; const detailsTemplateChanged = discordTemplateDetails !== discordPrevTemplateDetails; const stateTemplateChanged = discordTemplateState !== discordPrevTemplateState; const largeTextTemplateChanged = discordTemplateLargeText !== discordPrevTemplateLargeText; - if (!trackChanged && !playingChanged && !coversSettingChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return; + if (!trackChanged && !playingChanged && !coverSourceChanged && !detailsTemplateChanged && !stateTemplateChanged && !largeTextTemplateChanged) return; discordPrevTrackId = currentTrack.id; discordPrevIsPlaying = isPlaying; - discordPrevFetchCovers = enableAppleMusicCoversDiscord; + discordPrevFetchCovers = discordCoverSource === 'apple'; + discordPrevCoverSource = discordCoverSource; discordPrevTemplateDetails = discordTemplateDetails; discordPrevTemplateState = discordTemplateState; discordPrevTemplateLargeText = discordTemplateLargeText; - invoke('discord_update_presence', { - title: currentTrack.title, - artist: currentTrack.artist ?? 'Unknown Artist', - album: currentTrack.album ?? null, - isPlaying, - elapsedSecs: isPlaying ? currentTime : null, - // coverArtUrl is intentionally not passed — Subsonic URLs require auth. - // iTunes cover fetching is only done when explicitly opted in. - coverArtUrl: null, - fetchItunesCovers: enableAppleMusicCoversDiscord, - detailsTemplate: discordTemplateDetails, - stateTemplate: discordTemplateState, - largeTextTemplate: discordTemplateLargeText, - }).catch(() => {}); + const sendPresence = (coverArtUrl: string | null) => { + invoke('discord_update_presence', { + title: currentTrack.title, + artist: currentTrack.artist ?? 'Unknown Artist', + album: currentTrack.album ?? null, + isPlaying, + elapsedSecs: isPlaying ? currentTime : null, + coverArtUrl, + fetchItunesCovers: discordCoverSource === 'apple', + detailsTemplate: discordTemplateDetails, + stateTemplate: discordTemplateState, + largeTextTemplate: discordTemplateLargeText, + }).catch(() => {}); + }; + + if (discordCoverSource === 'server' && currentTrack.albumId) { + const cached = discordServerCoverCache.get(currentTrack.albumId); + if (cached !== undefined) { + sendPresence(cached); + } else { + getAlbumInfo2(currentTrack.albumId).then(info => { + const url = info?.largeImageUrl || info?.mediumImageUrl || info?.smallImageUrl || null; + discordServerCoverCache.set(currentTrack.albumId, url); + sendPresence(url); + }); + } + } else { + sendPresence(null); + } } const unsubDiscordPlayer = usePlayerStore.subscribe(syncDiscord);