support secondary public server URL

This commit is contained in:
jeffvli
2025-12-30 21:05:38 -08:00
parent 7aeadb531f
commit 72d0fca28b
16 changed files with 244 additions and 68 deletions
+3
View File
@@ -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",
+3 -1
View File
@@ -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;
@@ -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;
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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);
@@ -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' +
@@ -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<typeof GeneralSettingsSchema>['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
);
@@ -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';
@@ -41,6 +41,7 @@ export const useDiscordRpc = () => {
imageUrl: currentSong?.imageUrl,
itemType: LibraryItem.SONG,
type: 'table',
useRemoteUrl: true,
});
const imageUrlRef = useRef<null | string | undefined>(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 },
@@ -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')}
/>
</Group>
<TextInput
disabled={isServerLock}
label={t('form.addServer.input', {
context: 'remoteUrl',
postProcess: 'titleCase',
})}
placeholder={t('form.addServer.input', {
context: 'remoteUrlPlaceholder',
postProcess: 'sentenceCase',
})}
{...form.getInputProps('remoteUrl')}
/>
{form.values.remoteUrl && (
<Checkbox
label={t('form.addServer.input', {
context: 'preferRemoteUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('preferRemoteUrl', {
type: 'checkbox',
})}
/>
)}
<TextInput
label={t('form.addServer.input', {
context: 'username',
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import i18n from '/@/i18n/i18n';
import { api } from '/@/renderer/api';
import { queryClient } from '/@/renderer/lib/react-query';
import { useAuthStoreActions } from '/@/renderer/store';
import { getServerById, useAuthStoreActions } from '/@/renderer/store';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Icon } from '/@/shared/components/icon/icon';
@@ -55,6 +55,8 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
name: server?.name,
password: password || '',
preferInstantMix: server.preferInstantMix,
preferRemoteUrl: server?.preferRemoteUrl || false,
remoteUrl: server?.remoteUrl || '',
savePassword: server.savePassword,
type: server?.type,
url: server?.url,
@@ -66,43 +68,81 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
const isNavidrome = form.values.type === ServerType.NAVIDROME;
const handleSubmit = form.onSubmit(async (values) => {
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') && <ModifiedFieldIndicator />}
{...form.getInputProps('url')}
/>
<TextInput
label={t('form.addServer.input', {
context: 'remoteUrl',
postProcess: 'titleCase',
})}
placeholder={t('form.addServer.input', {
context: 'remoteUrlPlaceholder',
postProcess: 'sentenceCase',
})}
rightSection={form.isDirty('remoteUrl') && <ModifiedFieldIndicator />}
{...form.getInputProps('remoteUrl')}
/>
{form.values.remoteUrl && (
<Group gap="xs">
<Checkbox
label={t('form.addServer.input', {
context: 'preferRemoteUrl',
postProcess: 'titleCase',
})}
{...form.getInputProps('preferRemoteUrl', {
type: 'checkbox',
})}
/>
{form.isDirty('preferRemoteUrl') && <ModifiedFieldIndicator />}
</Group>
)}
<TextInput
label={t('form.addServer.input', {
context: 'username',
@@ -182,7 +264,6 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
context: 'password',
postProcess: 'titleCase',
})}
required={isNavidrome || isSubsonic}
{...form.getInputProps('password')}
/>
{localSettings && isNavidrome && (
@@ -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({
+2
View File
@@ -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,
@@ -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;
};
+3
View File
@@ -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;
};
+2
View File
@@ -250,6 +250,8 @@ export type ServerListItem = {
id: string;
name: string;
ndCredential?: string;
preferRemoteUrl?: boolean;
remoteUrl?: string;
savePassword?: boolean;
type: ServerType;
url: string;