mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-09 20:29:36 +02:00
support secondary public server URL
This commit is contained in:
@@ -312,6 +312,9 @@
|
|||||||
"input_password": "password",
|
"input_password": "password",
|
||||||
"input_preferInstantMix": "prefer instant mix",
|
"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_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_savePassword": "save password",
|
||||||
"input_url": "url",
|
"input_url": "url",
|
||||||
"input_username": "username",
|
"input_username": "username",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import packageJson from '../../../../package.json';
|
|||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
||||||
import { getClientType } from '/@/shared/api/utils';
|
import { getClientType } from '/@/shared/api/utils';
|
||||||
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
import { ServerListItemWithCredential } from '/@/shared/types/domain-types';
|
||||||
@@ -408,7 +409,8 @@ export const jfApiClient = (args: {
|
|||||||
const { params, path: api } = parsePath(path);
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
baseUrl = `${server?.url}`;
|
const serverUrl = getServerUrl(server);
|
||||||
|
baseUrl = serverUrl;
|
||||||
token = server?.credential;
|
token = server?.credential;
|
||||||
} else {
|
} else {
|
||||||
baseUrl = url;
|
baseUrl = url;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
||||||
import { useRadioStore } from '/@/renderer/features/radio/store/radio-store';
|
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 { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
||||||
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
||||||
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
|
import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils';
|
||||||
@@ -691,21 +692,22 @@ export const JellyfinController: InternalControllerEndpoint = {
|
|||||||
totalRecordCount: res.body?.TotalRecordCount || 0,
|
totalRecordCount: res.body?.TotalRecordCount || 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, query }) => {
|
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
||||||
const { id, size } = query;
|
const { id, size } = query;
|
||||||
const imageSize = size;
|
const imageSize = size;
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
if (!server?.url) {
|
if (!url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Jellyfin, we construct the URL pattern
|
// For Jellyfin, we construct the URL pattern
|
||||||
// The server will return a 404 or placeholder if no image exists
|
// 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
|
// 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
|
// The caller can handle this if needed
|
||||||
return baseUrl;
|
return imageUrl;
|
||||||
},
|
},
|
||||||
getInternetRadioStations: async (args) => {
|
getInternetRadioStations: async (args) => {
|
||||||
const { apiClientProps } = args;
|
const { apiClientProps } = args;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import qs from 'qs';
|
|||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { authenticationFailure } from '/@/renderer/api/utils';
|
import { authenticationFailure } from '/@/renderer/api/utils';
|
||||||
import { useAuthStore } from '/@/renderer/store';
|
import { useAuthStore } from '/@/renderer/store';
|
||||||
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
|
import { ndType } from '/@/shared/api/navidrome/navidrome-types';
|
||||||
import { resultWithHeaders } from '/@/shared/api/utils';
|
import { resultWithHeaders } from '/@/shared/api/utils';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
@@ -411,7 +412,8 @@ export const ndApiClient = (args: {
|
|||||||
const { params, path: api } = parsePath(path);
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
baseUrl = `${server?.url}/api`;
|
const serverUrl = getServerUrl(server);
|
||||||
|
baseUrl = serverUrl ? `${serverUrl}/api` : undefined;
|
||||||
token = server?.ndCredential;
|
token = server?.ndCredential;
|
||||||
} else {
|
} else {
|
||||||
baseUrl = url;
|
baseUrl = url;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import qs from 'qs';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
import { ssType } from '/@/shared/api/subsonic/subsonic-types';
|
||||||
import { hasFeature } from '/@/shared/api/utils';
|
import { hasFeature } from '/@/shared/api/utils';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
@@ -398,7 +399,8 @@ export const ssApiClient = (args: {
|
|||||||
const { params, path: api } = parsePath(path);
|
const { params, path: api } = parsePath(path);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
baseUrl = `${server.url}/rest`;
|
const serverUrl = getServerUrl(server);
|
||||||
|
baseUrl = serverUrl ? `${serverUrl}/rest` : undefined;
|
||||||
const token = server.credential;
|
const token = server.credential;
|
||||||
const params = token.split(/&?\w=/gm);
|
const params = token.split(/&?\w=/gm);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
import { contract, ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
|
||||||
import { randomString } from '/@/renderer/utils';
|
import { randomString } from '/@/renderer/utils';
|
||||||
|
import { getServerUrl } from '/@/renderer/utils/normalize-server-url';
|
||||||
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
|
||||||
import {
|
import {
|
||||||
AlbumListSortType,
|
AlbumListSortType,
|
||||||
@@ -851,11 +852,12 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
startIndex: query.startIndex,
|
startIndex: query.startIndex,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getImageUrl: ({ apiClientProps: { server }, query }) => {
|
getImageUrl: ({ apiClientProps: { server }, baseUrl, query }) => {
|
||||||
const { id, size } = query;
|
const { id, size } = query;
|
||||||
const imageSize = size;
|
const imageSize = size;
|
||||||
|
const url = baseUrl || getServerUrl(server);
|
||||||
|
|
||||||
if (!server?.url || !server?.credential) {
|
if (!url || !server?.credential) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -865,7 +867,7 @@ export const SubsonicController: InternalControllerEndpoint = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
`${server.url}/rest/getCoverArt.view` +
|
`${url}/rest/getCoverArt.view` +
|
||||||
`?id=${id}` +
|
`?id=${id}` +
|
||||||
`&${server.credential}` +
|
`&${server.credential}` +
|
||||||
'&v=1.13.0' +
|
'&v=1.13.0' +
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import z from 'zod';
|
|||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import {
|
import {
|
||||||
GeneralSettingsSchema,
|
GeneralSettingsSchema,
|
||||||
|
getServerById,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
useCurrentServerId,
|
useCurrentServerId,
|
||||||
useSettingsStore,
|
useSettingsStore,
|
||||||
@@ -65,10 +66,11 @@ interface UseItemImageUrlProps {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
size?: number;
|
size?: number;
|
||||||
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
|
type?: keyof z.infer<typeof GeneralSettingsSchema>['imageRes'];
|
||||||
|
useRemoteUrl?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
||||||
const { id, imageUrl, itemType, size, type } = args;
|
const { id, imageUrl, itemType, size, type, useRemoteUrl } = args;
|
||||||
const serverId = useCurrentServerId();
|
const serverId = useCurrentServerId();
|
||||||
|
|
||||||
const imageRes = useSettingsStore((store) => store.general.imageRes);
|
const imageRes = useSettingsStore((store) => store.general.imageRes);
|
||||||
@@ -83,17 +85,26 @@ export const useItemImageUrl = (args: UseItemImageUrlProps) => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const targetServerId = args.serverId || serverId;
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(targetServerId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
api.controller.getImageUrl({
|
api.controller.getImageUrl({
|
||||||
apiClientProps: { serverId: args.serverId || serverId },
|
apiClientProps: { serverId: targetServerId },
|
||||||
|
baseUrl,
|
||||||
query: { id, itemType, size: size ?? sizeByType },
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
}) || undefined
|
}) || undefined
|
||||||
);
|
);
|
||||||
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType]);
|
}, [args.serverId, id, imageUrl, itemType, serverId, size, sizeByType, useRemoteUrl]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getItemImageUrl(args: UseItemImageUrlProps) {
|
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 authStore = useAuthStore.getState();
|
||||||
const currentServerId = authStore.currentServer?.id;
|
const currentServerId = authStore.currentServer?.id;
|
||||||
const serverId = (args.serverId || currentServerId) as string;
|
const serverId = (args.serverId || currentServerId) as string;
|
||||||
@@ -109,9 +120,17 @@ export function getItemImageUrl(args: UseItemImageUrlProps) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let baseUrl: string | undefined;
|
||||||
|
|
||||||
|
if (useRemoteUrl) {
|
||||||
|
const server = getServerById(serverId);
|
||||||
|
baseUrl = server?.remoteUrl || server?.url;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
api.controller.getImageUrl({
|
api.controller.getImageUrl({
|
||||||
apiClientProps: { serverId },
|
apiClientProps: { serverId },
|
||||||
|
baseUrl,
|
||||||
query: { id, itemType, size: size ?? sizeByType },
|
query: { id, itemType, size: size ?? sizeByType },
|
||||||
}) || undefined
|
}) || undefined
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Link, useParams } from 'react-router';
|
|||||||
|
|
||||||
import styles from './album-detail-header.module.css';
|
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 { albumQueries } from '/@/renderer/features/albums/api/album-api';
|
||||||
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
import { JoinedArtists } from '/@/renderer/features/albums/components/joined-artists';
|
||||||
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller';
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export const useDiscordRpc = () => {
|
|||||||
imageUrl: currentSong?.imageUrl,
|
imageUrl: currentSong?.imageUrl,
|
||||||
itemType: LibraryItem.SONG,
|
itemType: LibraryItem.SONG,
|
||||||
type: 'table',
|
type: 'table',
|
||||||
|
useRemoteUrl: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
const imageUrlRef = useRef<null | string | undefined>(imageUrl);
|
||||||
@@ -194,14 +195,13 @@ export const useDiscordRpc = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (discordSettings.showServerImage && song) {
|
if (discordSettings.showServerImage && song) {
|
||||||
// Use imageUrl from useItemImageUrl hook if available and song matches current song
|
|
||||||
if (song._uniqueId === currentSong?._uniqueId && imageUrlRef.current) {
|
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) {
|
if (song._serverType === ServerType.JELLYFIN && song.imageUrl) {
|
||||||
activity.largeImageKey = song.imageUrl;
|
activity.largeImageKey = imageUrlRef.current;
|
||||||
} else if (song._serverType === ServerType.NAVIDROME) {
|
} else if (
|
||||||
|
song._serverType === ServerType.NAVIDROME ||
|
||||||
|
song._serverType === ServerType.SUBSONIC
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const info = await api.controller.getAlbumInfo({
|
const info = await api.controller.getAlbumInfo({
|
||||||
apiClientProps: { serverId: song._serverId },
|
apiClientProps: { serverId: song._serverId },
|
||||||
|
|||||||
@@ -101,6 +101,8 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
|||||||
(localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) || 'My Server',
|
(localSettings ? localSettings.env.SERVER_NAME : window.SERVER_NAME) || 'My Server',
|
||||||
password: '',
|
password: '',
|
||||||
preferInstantMix: undefined,
|
preferInstantMix: undefined,
|
||||||
|
preferRemoteUrl: false,
|
||||||
|
remoteUrl: '',
|
||||||
savePassword: undefined,
|
savePassword: undefined,
|
||||||
type:
|
type:
|
||||||
(localSettings
|
(localSettings
|
||||||
@@ -166,6 +168,14 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
|||||||
serverItem.savePassword = values.savePassword;
|
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) {
|
if (data.ndCredential !== undefined) {
|
||||||
serverItem.ndCredential = data.ndCredential;
|
serverItem.ndCredential = data.ndCredential;
|
||||||
}
|
}
|
||||||
@@ -247,6 +257,29 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
|||||||
{...form.getInputProps('url')}
|
{...form.getInputProps('url')}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</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
|
<TextInput
|
||||||
label={t('form.addServer.input', {
|
label={t('form.addServer.input', {
|
||||||
context: 'username',
|
context: 'username',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import i18n from '/@/i18n/i18n';
|
import i18n from '/@/i18n/i18n';
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
import { queryClient } from '/@/renderer/lib/react-query';
|
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 { Checkbox } from '/@/shared/components/checkbox/checkbox';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { Icon } from '/@/shared/components/icon/icon';
|
import { Icon } from '/@/shared/components/icon/icon';
|
||||||
@@ -55,6 +55,8 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
|||||||
name: server?.name,
|
name: server?.name,
|
||||||
password: password || '',
|
password: password || '',
|
||||||
preferInstantMix: server.preferInstantMix,
|
preferInstantMix: server.preferInstantMix,
|
||||||
|
preferRemoteUrl: server?.preferRemoteUrl || false,
|
||||||
|
remoteUrl: server?.remoteUrl || '',
|
||||||
savePassword: server.savePassword,
|
savePassword: server.savePassword,
|
||||||
type: server?.type,
|
type: server?.type,
|
||||||
url: server?.url,
|
url: server?.url,
|
||||||
@@ -66,43 +68,81 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
|||||||
const isNavidrome = form.values.type === ServerType.NAVIDROME;
|
const isNavidrome = form.values.type === ServerType.NAVIDROME;
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
const authFunction = api.controller.authenticate;
|
|
||||||
|
|
||||||
if (!authFunction) {
|
|
||||||
return toast.error({
|
|
||||||
message: t('error.invalidServer', { postProcess: 'sentenceCase' }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const data: AuthenticationResponse | undefined = await authFunction(
|
|
||||||
values.url,
|
|
||||||
{
|
|
||||||
legacy: values.legacyAuth,
|
|
||||||
password: values.password,
|
|
||||||
username: values.username,
|
|
||||||
},
|
|
||||||
values.type,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!data) {
|
// Check if we can skip authentication
|
||||||
return toast.error({
|
const usernameChanged = values.username !== server.username;
|
||||||
message: t('error.authenticationFailed', { postProcess: 'sentenceCase' }),
|
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 = {
|
// Update optional fields
|
||||||
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 (values.preferInstantMix !== undefined) {
|
if (values.preferInstantMix !== undefined) {
|
||||||
serverItem.preferInstantMix = values.preferInstantMix;
|
serverItem.preferInstantMix = values.preferInstantMix;
|
||||||
}
|
}
|
||||||
@@ -111,8 +151,14 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
|||||||
serverItem.savePassword = values.savePassword;
|
serverItem.savePassword = values.savePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.ndCredential !== undefined) {
|
if (values.remoteUrl?.trim()) {
|
||||||
serverItem.ndCredential = data.ndCredential;
|
serverItem.remoteUrl = values.remoteUrl.trim().replace(/\/$/, '');
|
||||||
|
} else {
|
||||||
|
serverItem.remoteUrl = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (values.preferRemoteUrl !== undefined) {
|
||||||
|
serverItem.preferRemoteUrl = values.preferRemoteUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateServer(server.id, serverItem);
|
updateServer(server.id, serverItem);
|
||||||
@@ -120,19 +166,29 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
|||||||
message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),
|
message: t('form.updateServer.title', { postProcess: 'sentenceCase' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle password saving in local settings
|
||||||
if (localSettings) {
|
if (localSettings) {
|
||||||
if (values.savePassword) {
|
if (canSkipAuth) {
|
||||||
const saved = await localSettings.passwordSet(values.password, server.id);
|
// If we skipped auth, only update savePassword preference
|
||||||
if (!saved) {
|
// Don't change the actual saved password
|
||||||
toast.error({
|
if (!values.savePassword) {
|
||||||
message: t('form.addServer.error', {
|
localSettings.passwordRemove(server.id);
|
||||||
context: 'savePassword',
|
|
||||||
postProcess: 'sentenceCase',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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 />}
|
rightSection={form.isDirty('url') && <ModifiedFieldIndicator />}
|
||||||
{...form.getInputProps('url')}
|
{...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
|
<TextInput
|
||||||
label={t('form.addServer.input', {
|
label={t('form.addServer.input', {
|
||||||
context: 'username',
|
context: 'username',
|
||||||
@@ -182,7 +264,6 @@ export const EditServerForm = ({ isUpdate, onCancel, password, server }: EditSer
|
|||||||
context: 'password',
|
context: 'password',
|
||||||
postProcess: 'titleCase',
|
postProcess: 'titleCase',
|
||||||
})}
|
})}
|
||||||
required={isNavidrome || isSubsonic}
|
|
||||||
{...form.getInputProps('password')}
|
{...form.getInputProps('password')}
|
||||||
/>
|
/>
|
||||||
{localSettings && isNavidrome && (
|
{localSettings && isNavidrome && (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';
|
import { useShareItem } from '/@/renderer/features/sharing/mutations/share-item-mutation';
|
||||||
import { useCurrentServer } from '/@/renderer/store';
|
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 { DateTimePicker } from '/@/shared/components/date-time-picker/date-time-picker';
|
||||||
import { Group } from '/@/shared/components/group/group';
|
import { Group } from '/@/shared/components/group/group';
|
||||||
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
import { ModalButton } from '/@/shared/components/modal/model-shared';
|
||||||
@@ -69,7 +70,9 @@ export const ShareItemContextModal = ({
|
|||||||
if (!server) throw new Error('Server not found');
|
if (!server) throw new Error('Server not found');
|
||||||
if (!_data?.id) throw new Error('Failed to share item');
|
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);
|
navigator.clipboard.writeText(shareUrl);
|
||||||
toast.success({
|
toast.success({
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ export const useCurrentServer = () =>
|
|||||||
musicFolderId: state.currentServer?.musicFolderId,
|
musicFolderId: state.currentServer?.musicFolderId,
|
||||||
name: state.currentServer?.name,
|
name: state.currentServer?.name,
|
||||||
preferInstantMix: state.currentServer?.preferInstantMix,
|
preferInstantMix: state.currentServer?.preferInstantMix,
|
||||||
|
preferRemoteUrl: state.currentServer?.preferRemoteUrl,
|
||||||
|
remoteUrl: state.currentServer?.remoteUrl,
|
||||||
savePassword: state.currentServer?.savePassword,
|
savePassword: state.currentServer?.savePassword,
|
||||||
type: state.currentServer?.type,
|
type: state.currentServer?.type,
|
||||||
url: state.currentServer?.url,
|
url: state.currentServer?.url,
|
||||||
|
|||||||
@@ -1,4 +1,25 @@
|
|||||||
|
import { ServerListItem } from '/@/shared/types/domain-types';
|
||||||
|
|
||||||
export const normalizeServerUrl = (url: string) => {
|
export const normalizeServerUrl = (url: string) => {
|
||||||
// Remove trailing slash
|
// Remove trailing slash
|
||||||
return url.endsWith('/') ? url.slice(0, -1) : url;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -91,6 +91,8 @@ export type ServerListItem = {
|
|||||||
musicFolderId?: string[];
|
musicFolderId?: string[];
|
||||||
name: string;
|
name: string;
|
||||||
preferInstantMix?: boolean;
|
preferInstantMix?: boolean;
|
||||||
|
preferRemoteUrl?: boolean;
|
||||||
|
remoteUrl?: string;
|
||||||
savePassword?: boolean;
|
savePassword?: boolean;
|
||||||
type: ServerType;
|
type: ServerType;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -1418,6 +1420,7 @@ export type GetQueueResponse = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ImageArgs = BaseEndpointArgs & {
|
export type ImageArgs = BaseEndpointArgs & {
|
||||||
|
baseUrl?: string;
|
||||||
query: ImageQuery;
|
query: ImageQuery;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ export type ServerListItem = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ndCredential?: string;
|
ndCredential?: string;
|
||||||
|
preferRemoteUrl?: boolean;
|
||||||
|
remoteUrl?: string;
|
||||||
savePassword?: boolean;
|
savePassword?: boolean;
|
||||||
type: ServerType;
|
type: ServerType;
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user