From 72d0fca28b32a7c56fd98879032c2e8e10f48218 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 30 Dec 2025 21:05:38 -0800 Subject: [PATCH] support secondary public server URL --- src/i18n/locales/en.json | 3 + src/renderer/api/jellyfin/jellyfin-api.ts | 4 +- .../api/jellyfin/jellyfin-controller.ts | 10 +- src/renderer/api/navidrome/navidrome-api.ts | 4 +- src/renderer/api/subsonic/subsonic-api.ts | 4 +- .../api/subsonic/subsonic-controller.ts | 8 +- .../components/item-image/item-image.tsx | 27 ++- .../albums/components/album-detail-header.tsx | 1 - .../features/discord-rpc/use-discord-rpc.ts | 12 +- .../servers/components/add-server-form.tsx | 33 ++++ .../servers/components/edit-server-form.tsx | 173 +++++++++++++----- .../components/share-item-context-modal.tsx | 5 +- src/renderer/store/auth.store.ts | 2 + src/renderer/utils/normalize-server-url.ts | 21 +++ src/shared/types/domain-types.ts | 3 + src/shared/types/types.ts | 2 + 16 files changed, 244 insertions(+), 68 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f998e8588..57b455bc0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -312,6 +312,9 @@ "input_password": "password", "input_preferInstantMix": "prefer instant mix", "input_preferInstantMixDescription": "only use instant mix to get similar songs. useful if you have plugins that modify this behavior", + "input_preferRemoteUrl": "prefer public url", + "input_remoteUrl": "public url", + "input_remoteUrlPlaceholder": "optional: public url for external features", "input_savePassword": "save password", "input_url": "url", "input_username": "username", diff --git a/src/renderer/api/jellyfin/jellyfin-api.ts b/src/renderer/api/jellyfin/jellyfin-api.ts index b1eca8054..6de03ccd6 100644 --- a/src/renderer/api/jellyfin/jellyfin-api.ts +++ b/src/renderer/api/jellyfin/jellyfin-api.ts @@ -9,6 +9,7 @@ import packageJson from '../../../../package.json'; import i18n from '/@/i18n/i18n'; import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { getClientType } from '/@/shared/api/utils'; import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; @@ -408,7 +409,8 @@ export const jfApiClient = (args: { const { params, path: api } = parsePath(path); if (server) { - baseUrl = `${server?.url}`; + const serverUrl = getServerUrl(server); + baseUrl = serverUrl; token = server?.credential; } else { baseUrl = url; diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 35f959e14..ac136a63b 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -6,6 +6,7 @@ import { z } from 'zod'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; import { useRadioStore } from '/@/renderer/features/radio/store/radio-store'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize'; import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils'; @@ -691,21 +692,22 @@ export const JellyfinController: InternalControllerEndpoint = { totalRecordCount: res.body?.TotalRecordCount || 0, }; }, - getImageUrl: ({ apiClientProps: { server }, query }) => { + getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => { const { id, size } = query; const imageSize = size; + const url = baseUrl || getServerUrl(server); - if (!server?.url) { + if (!url) { return null; } // For Jellyfin, we construct the URL pattern // The server will return a 404 or placeholder if no image exists - const baseUrl = `${server.url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`; + const imageUrl = `${url}/Items/${id}/Images/Primary?quality=96${imageSize ? `&width=${imageSize}` : ''}`; // For songs, we might want to fall back to album art, but we don't have albumId here // The caller can handle this if needed - return baseUrl; + return imageUrl; }, getInternetRadioStations: async (args) => { const { apiClientProps } = args; diff --git a/src/renderer/api/navidrome/navidrome-api.ts b/src/renderer/api/navidrome/navidrome-api.ts index 1a9a4d442..9391eb8fa 100644 --- a/src/renderer/api/navidrome/navidrome-api.ts +++ b/src/renderer/api/navidrome/navidrome-api.ts @@ -8,6 +8,7 @@ import qs from 'qs'; import i18n from '/@/i18n/i18n'; import { authenticationFailure } from '/@/renderer/api/utils'; import { useAuthStore } from '/@/renderer/store'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ndType } from '/@/shared/api/navidrome/navidrome-types'; import { resultWithHeaders } from '/@/shared/api/utils'; import { toast } from '/@/shared/components/toast/toast'; @@ -411,7 +412,8 @@ export const ndApiClient = (args: { const { params, path: api } = parsePath(path); if (server) { - baseUrl = `${server?.url}/api`; + const serverUrl = getServerUrl(server); + baseUrl = serverUrl ? `${serverUrl}/api` : undefined; token = server?.ndCredential; } else { baseUrl = url; diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 499de5547..efa6d9c75 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -5,6 +5,7 @@ import qs from 'qs'; import { z } from 'zod'; import i18n from '/@/i18n/i18n'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssType } from '/@/shared/api/subsonic/subsonic-types'; import { hasFeature } from '/@/shared/api/utils'; import { toast } from '/@/shared/components/toast/toast'; @@ -398,7 +399,8 @@ export const ssApiClient = (args: { const { params, path: api } = parsePath(path); if (server) { - baseUrl = `${server.url}/rest`; + const serverUrl = getServerUrl(server); + baseUrl = serverUrl ? `${serverUrl}/rest` : undefined; const token = server.credential; const params = token.split(/&?\w=/gm); diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 2f3cd14fb..edb6bb92f 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -9,6 +9,7 @@ import { z } from 'zod'; import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api'; import { randomString } from '/@/renderer/utils'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize'; import { AlbumListSortType, @@ -851,11 +852,12 @@ export const SubsonicController: InternalControllerEndpoint = { startIndex: query.startIndex, }); }, - getImageUrl: ({ apiClientProps: { server }, query }) => { + getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => { const { id, size } = query; const imageSize = size; + const url = baseUrl || getServerUrl(server); - if (!server?.url || !server?.credential) { + if (!url || !server?.credential) { return null; } @@ -865,7 +867,7 @@ export const SubsonicController: InternalControllerEndpoint = { } return ( - `${server.url}/rest/getCoverArt.view` + + `${url}/rest/getCoverArt.view` + `?id=${id}` + `&${server.credential}` + '&v=1.13.0' + diff --git a/src/renderer/components/item-image/item-image.tsx b/src/renderer/components/item-image/item-image.tsx index a320f7d9f..a85b96d9f 100644 --- a/src/renderer/components/item-image/item-image.tsx +++ b/src/renderer/components/item-image/item-image.tsx @@ -4,6 +4,7 @@ import z from 'zod'; import { api } from '/@/renderer/api'; import { GeneralSettingsSchema, + getServerById, useAuthStore, useCurrentServerId, useSettingsStore, @@ -65,10 +66,11 @@ interface UseItemImageUrlProps { serverId?: string; size?: number; type?: keyof z.infer['imageRes']; + useRemoteUrl?: boolean; } export const useItemImageUrl = (args: UseItemImageUrlProps) => { - const { id, imageUrl, itemType, size, type } = args; + const { id, imageUrl, itemType, size, type, useRemoteUrl } = args; const serverId = useCurrentServerId(); const imageRes = useSettingsStore((store) => store.general.imageRes); @@ -83,17 +85,26 @@ export const useItemImageUrl = (args: UseItemImageUrlProps) => { return undefined; } + const targetServerId = args.serverId || serverId; + let baseUrl: string | undefined; + + if (useRemoteUrl) { + const server = getServerById(targetServerId); + baseUrl = server?.remoteUrl || server?.url; + } + return ( api.controller.getImageUrl({ - apiClientProps: { serverId: args.serverId || serverId }, + apiClientProps: { serverId: targetServerId }, + baseUrl, query: { id, itemType, size: size ?? sizeByType }, }) || undefined ); - }, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType]); + }, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]); }; export function getItemImageUrl(args: UseItemImageUrlProps) { - const { id, imageUrl, itemType, size, type } = args; + const { id, imageUrl, itemType, size, type, useRemoteUrl } = args; const authStore = useAuthStore.getState(); const currentServerId = authStore.currentServer?.id; const serverId = (args.serverId || currentServerId) as string; @@ -109,9 +120,17 @@ export function getItemImageUrl(args: UseItemImageUrlProps) { return undefined; } + let baseUrl: string | undefined; + + if (useRemoteUrl) { + const server = getServerById(serverId); + baseUrl = server?.remoteUrl || server?.url; + } + return ( api.controller.getImageUrl({ apiClientProps: { serverId }, + baseUrl, query: { id, itemType, size: size ?? sizeByType }, }) || undefined ); diff --git a/src/renderer/features/albums/components/album-detail-header.tsx b/src/renderer/features/albums/components/album-detail-header.tsx index 1e64cff1f..52530239f 100644 --- a/src/renderer/features/albums/components/album-detail-header.tsx +++ b/src/renderer/features/albums/components/album-detail-header.tsx @@ -5,7 +5,6 @@ import { Link, useParams } from 'react-router'; import styles from './album-detail-header.module.css'; -import { useItemImageUrl } from '/@/renderer/components/item-image/item-image'; import { albumQueries } from '/@/renderer/features/albums/api/album-api'; import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; diff --git a/src/renderer/features/discord-rpc/use-discord-rpc.ts b/src/renderer/features/discord-rpc/use-discord-rpc.ts index 559bdf894..b1da380da 100644 --- a/src/renderer/features/discord-rpc/use-discord-rpc.ts +++ b/src/renderer/features/discord-rpc/use-discord-rpc.ts @@ -41,6 +41,7 @@ export const useDiscordRpc = () => { imageUrl: currentSong?.imageUrl, itemType: LibraryItem.SONG, type: 'table', + useRemoteUrl: true, }); const imageUrlRef = useRef(imageUrl); @@ -194,14 +195,13 @@ export const useDiscordRpc = () => { } if (discordSettings.showServerImage && song) { - // Use imageUrl from useItemImageUrl hook if available and song matches current song if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) { - activity.largeImageKey = imageUrlRef.current; - } else { - // Fallback to old logic if song doesn't match (shouldn't happen in normal flow) if (song._serverType === ServerType.JELLYFIN && song.imageUrl) { - activity.largeImageKey = song.imageUrl; - } else if (song._serverType === ServerType.NAVIDROME) { + activity.largeImageKey = imageUrlRef.current; + } else if ( + song._serverType === ServerType.NAVIDROME || + song._serverType === ServerType.SUBSONIC + ) { try { const info = await api.controller.getAlbumInfo({ apiClientProps: { serverId: song._serverId }, diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index 24e9e8bf5..5df89d1ff 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -101,6 +101,8 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { (localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) || 'My Server', password: '', preferInstantMix: undefined, + preferRemoteUrl: false, + remoteUrl: '', savePassword: undefined, type: (localSettings @@ -166,6 +168,14 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { serverItem.savePassword = values.savePassword; } + if (values.remoteUrl?.trim()) { + serverItem.remoteUrl = values.remoteUrl.trim().replace(/\/$/, ''); + } + + if (values.preferRemoteUrl !== undefined) { + serverItem.preferRemoteUrl = values.preferRemoteUrl; + } + if (data.ndCredential !== undefined) { serverItem.ndCredential = data.ndCredential; } @@ -247,6 +257,29 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => { {...form.getInputProps('url')} /> + + {form.values.remoteUrl && ( + + )} { - const authFunction = api.controller.authenticate; - - if (!authFunction) { - return toast.error({ - message: t('error.invalidServer', { postProcess: 'sentenceCase' }), - }); - } - try { setIsLoading(true); - const data: AuthenticationResponse | undefined = await authFunction( - values.url, - { - legacy: values.legacyAuth, - password: values.password, - username: values.username, - }, - values.type, - ); - if (!data) { - return toast.error({ - message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), - }); + // Check if we can skip authentication + const usernameChanged = values.username !== server.username; + const passwordProvided = values.password && values.password.trim() !== ''; + const urlChanged = values.url !== server.url; + const typeChanged = values.type !== server.type; + + // Skip authentication if username hasn't changed, password is empty, and URL/type haven't changed + const canSkipAuth = + !usernameChanged && !passwordProvided && !urlChanged && !typeChanged; + + let data: AuthenticationResponse | undefined; + let serverItem: ServerListItemWithCredential; + + if (canSkipAuth) { + // Use existing server credentials + const existingServer = getServerById(server.id); + if (!existingServer) { + return toast.error({ + message: t('error.invalidServer', { postProcess: 'sentenceCase' }), + }); + } + + serverItem = { + ...existingServer, + id: server.id, + name: values.name, + type: values.type, + url: values.url, + }; + } else { + // Need to authenticate + const authFunction = api.controller.authenticate; + + if (!authFunction) { + return toast.error({ + message: t('error.invalidServer', { postProcess: 'sentenceCase' }), + }); + } + + data = await authFunction( + values.url, + { + legacy: values.legacyAuth, + password: values.password, + username: values.username, + }, + values.type, + ); + + if (!data) { + return toast.error({ + message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }), + }); + } + + serverItem = { + credential: data.credential, + id: server.id, + isAdmin: data.isAdmin, + name: values.name, + type: values.type, + url: values.url, + userId: data.userId, + username: data.username, + }; + + if (data.ndCredential !== undefined) { + serverItem.ndCredential = data.ndCredential; + } } - const serverItem: ServerListItemWithCredential = { - credential: data.credential, - id: server.id, - isAdmin: data.isAdmin, - name: values.name, - type: values.type, - url: values.url, - userId: data.userId, - username: data.username, - }; - + // Update optional fields if (values.preferInstantMix !== undefined) { serverItem.preferInstantMix = values.preferInstantMix; } @@ -111,8 +151,14 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer serverItem.savePassword = values.savePassword; } - if (data.ndCredential !== undefined) { - serverItem.ndCredential = data.ndCredential; + if (values.remoteUrl?.trim()) { + serverItem.remoteUrl = values.remoteUrl.trim().replace(/\/$/, ''); + } else { + serverItem.remoteUrl = undefined; + } + + if (values.preferRemoteUrl !== undefined) { + serverItem.preferRemoteUrl = values.preferRemoteUrl; } updateServer(server.id, serverItem); @@ -120,19 +166,29 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer message: t('form.updateServer.title', { postProcess: 'sentenceCase' }), }); + // Handle password saving in local settings if (localSettings) { - if (values.savePassword) { - const saved = await localSettings.passwordSet(values.password, server.id); - if (!saved) { - toast.error({ - message: t('form.addServer.error', { - context: 'savePassword', - postProcess: 'sentenceCase', - }), - }); + if (canSkipAuth) { + // If we skipped auth, only update savePassword preference + // Don't change the actual saved password + if (!values.savePassword) { + localSettings.passwordRemove(server.id); } } else { - localSettings.passwordRemove(server.id); + // If we authenticated, update password if savePassword is enabled + if (values.savePassword && passwordProvided) { + const saved = await localSettings.passwordSet(values.password, server.id); + if (!saved) { + toast.error({ + message: t('form.addServer.error', { + context: 'savePassword', + postProcess: 'sentenceCase', + }), + }); + } + } else if (!values.savePassword) { + localSettings.passwordRemove(server.id); + } } } @@ -167,6 +223,32 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer rightSection={form.isDirty('url') && } {...form.getInputProps('url')} /> + } + {...form.getInputProps('remoteUrl')} + /> + {form.values.remoteUrl && ( + + + {form.isDirty('preferRemoteUrl') && } + + )} {localSettings && isNavidrome && ( diff --git a/src/renderer/features/sharing/components/share-item-context-modal.tsx b/src/renderer/features/sharing/components/share-item-context-modal.tsx index f60e3303a..59697576c 100644 --- a/src/renderer/features/sharing/components/share-item-context-modal.tsx +++ b/src/renderer/features/sharing/components/share-item-context-modal.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation'; import { useCurrentServer } from '/@/renderer/store'; +import { getServerUrl } from '/@/renderer/utils/normalize-server-url'; import { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker'; import { Group } from '/@/shared/components/group/group'; import { ModalButton } from '/@/shared/components/modal/model-shared'; @@ -69,7 +70,9 @@ export const ShareItemContextModal = ({ if (!server) throw new Error('Server not found'); if (!_data?.id) throw new Error('Failed to share item'); - const shareUrl = `${server.url}/share/${_data.id}`; + const serverUrl = getServerUrl(server, true); + if (!serverUrl) throw new Error('Server URL not found'); + const shareUrl = `${serverUrl}/share/${_data.id}`; navigator.clipboard.writeText(shareUrl); toast.success({ diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 088e34a60..09dc25e6a 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -124,6 +124,8 @@ export const useCurrentServer = () => musicFolderId: state.currentServer?.musicFolderId, name: state.currentServer?.name, preferInstantMix: state.currentServer?.preferInstantMix, + preferRemoteUrl: state.currentServer?.preferRemoteUrl, + remoteUrl: state.currentServer?.remoteUrl, savePassword: state.currentServer?.savePassword, type: state.currentServer?.type, url: state.currentServer?.url, diff --git a/src/renderer/utils/normalize-server-url.ts b/src/renderer/utils/normalize-server-url.ts index 9afaefb46..292fb28a3 100644 --- a/src/renderer/utils/normalize-server-url.ts +++ b/src/renderer/utils/normalize-server-url.ts @@ -1,4 +1,25 @@ +import { ServerListItem } from '/@/shared/types/domain-types'; + export const normalizeServerUrl = (url: string) => { // Remove trailing slash return url.endsWith('/') ? url.slice(0, -1) : url; }; + +export const getServerUrl = ( + server: null | ServerListItem | undefined, + forceRemoteUrl?: boolean, +): string | undefined => { + if (!server) { + return undefined; + } + + if (!forceRemoteUrl && !server.preferRemoteUrl) { + return server.url; + } + + if (!server.remoteUrl) { + return server.url; + } + + return server.remoteUrl; +}; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 1563d9215..0e4c8b4e2 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -91,6 +91,8 @@ export type ServerListItem = { musicFolderId?: string[]; name: string; preferInstantMix?: boolean; + preferRemoteUrl?: boolean; + remoteUrl?: string; savePassword?: boolean; type: ServerType; url: string; @@ -1418,6 +1420,7 @@ export type GetQueueResponse = { }; export type ImageArgs = BaseEndpointArgs & { + baseUrl?: string; query: ImageQuery; }; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index ab664d7f2..3c6e81e6f 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -250,6 +250,8 @@ export type ServerListItem = { id: string; name: string; ndCredential?: string; + preferRemoteUrl?: boolean; + remoteUrl?: string; savePassword?: boolean; type: ServerType; url: string;