mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-02 02:43:33 +00:00
Add autodiscovery for Jellyfin servers (#1146)
* Add autodiscovery for Jellyfin servers * Remove debugging aids you didn't see anything * Fix linter errors * Send a discovery packet to localhost too
This commit is contained in:
parent
bca4a14f2e
commit
e344adfeed
6 changed files with 237 additions and 91 deletions
55
src/main/features/core/autodiscover/index.ts
Normal file
55
src/main/features/core/autodiscover/index.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { createSocket } from 'dgram';
|
||||||
|
import { ipcMain } from 'electron';
|
||||||
|
|
||||||
|
import { DiscoveredServerItem, ServerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
type JellyfinResponse = {
|
||||||
|
Address: string;
|
||||||
|
Id: string;
|
||||||
|
Name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function discoverAll(reply: (server: DiscoveredServerItem) => void) {
|
||||||
|
return Promise.all([discoverJellyfin(reply)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function discoverJellyfin(reply: (server: DiscoveredServerItem) => void) {
|
||||||
|
const sock = createSocket('udp4');
|
||||||
|
sock.on('message', (msg) => {
|
||||||
|
try {
|
||||||
|
const response: JellyfinResponse = JSON.parse(msg.toString('utf-8'));
|
||||||
|
|
||||||
|
reply({
|
||||||
|
name: response.Name,
|
||||||
|
type: ServerType.JELLYFIN,
|
||||||
|
url: response.Address,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Got a spurious response, ignore?
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
sock.bind(() => {
|
||||||
|
sock.setBroadcast(true);
|
||||||
|
// Send a broadcast packet to both loopback and default route, allowing discovery of same-machine instances
|
||||||
|
sock.send('who is JellyfinServer?', 7359, '127.255.255.255');
|
||||||
|
sock.send('who is JellyfinServer?', 7359, '255.255.255.255');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
sock.close();
|
||||||
|
resolve();
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ipcMain.on('autodiscover-ping', (ev) => {
|
||||||
|
if (ev.ports.length === 0) throw new Error('Expected a port to stream autodiscovery results');
|
||||||
|
const port = ev.ports[0];
|
||||||
|
|
||||||
|
discoverAll((result) => port.postMessage(result))
|
||||||
|
.then(() => port.close())
|
||||||
|
.catch((err) => console.error(err));
|
||||||
|
});
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import './autodiscover';
|
||||||
import './lyrics';
|
import './lyrics';
|
||||||
import './player';
|
import './player';
|
||||||
import './remote';
|
import './remote';
|
||||||
|
|
|
||||||
23
src/preload/autodiscover.ts
Normal file
23
src/preload/autodiscover.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import { ipcRenderer } from 'electron';
|
||||||
|
|
||||||
|
import { DiscoveredServerItem } from '../shared/types/types';
|
||||||
|
|
||||||
|
const discover = (onReply: (server: DiscoveredServerItem) => void): Promise<void> => {
|
||||||
|
const { port1: local, port2: remote } = new MessageChannel();
|
||||||
|
|
||||||
|
ipcRenderer.postMessage('autodiscover-ping', {}, [remote]);
|
||||||
|
|
||||||
|
local.onmessage = (ev) => {
|
||||||
|
onReply(ev.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
local.addEventListener('close', () => resolve());
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autodiscover = {
|
||||||
|
discover,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AutoDiscover = typeof autodiscover;
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { electronAPI } from '@electron-toolkit/preload';
|
import { electronAPI } from '@electron-toolkit/preload';
|
||||||
import { contextBridge } from 'electron';
|
import { contextBridge } from 'electron';
|
||||||
|
|
||||||
|
import { autodiscover } from './autodiscover';
|
||||||
import { browser } from './browser';
|
import { browser } from './browser';
|
||||||
import { discordRpc } from './discord-rpc';
|
import { discordRpc } from './discord-rpc';
|
||||||
import { ipc } from './ipc';
|
import { ipc } from './ipc';
|
||||||
|
|
@ -13,6 +14,7 @@ import { utils } from './utils';
|
||||||
|
|
||||||
// Custom APIs for renderer
|
// Custom APIs for renderer
|
||||||
const api = {
|
const api = {
|
||||||
|
autodiscover,
|
||||||
browser,
|
browser,
|
||||||
discordRpc,
|
discordRpc,
|
||||||
ipc,
|
ipc,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { useFocusTrap } from '@mantine/hooks';
|
||||||
import { closeAllModals } from '@mantine/modals';
|
import { closeAllModals } from '@mantine/modals';
|
||||||
import isElectron from 'is-electron';
|
import isElectron from 'is-electron';
|
||||||
import { nanoid } from 'nanoid/non-secure';
|
import { nanoid } from 'nanoid/non-secure';
|
||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { api } from '/@/renderer/api';
|
import { api } from '/@/renderer/api';
|
||||||
|
|
@ -14,6 +14,7 @@ import { useAuthStoreActions } from '/@/renderer/store';
|
||||||
import { Button } from '/@/shared/components/button/button';
|
import { Button } from '/@/shared/components/button/button';
|
||||||
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 { Paper } from '/@/shared/components/paper/paper';
|
||||||
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
import { PasswordInput } from '/@/shared/components/password-input/password-input';
|
||||||
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
|
||||||
import { Stack } from '/@/shared/components/stack/stack';
|
import { Stack } from '/@/shared/components/stack/stack';
|
||||||
|
|
@ -21,14 +22,20 @@ import { TextInput } from '/@/shared/components/text-input/text-input';
|
||||||
import { Text } from '/@/shared/components/text/text';
|
import { Text } from '/@/shared/components/text/text';
|
||||||
import { toast } from '/@/shared/components/toast/toast';
|
import { toast } from '/@/shared/components/toast/toast';
|
||||||
import { AuthenticationResponse } from '/@/shared/types/domain-types';
|
import { AuthenticationResponse } from '/@/shared/types/domain-types';
|
||||||
import { ServerType, toServerType } from '/@/shared/types/types';
|
import { DiscoveredServerItem, ServerType, toServerType } from '/@/shared/types/types';
|
||||||
|
|
||||||
|
const autodiscover = isElectron() ? window.api.autodiscover : null;
|
||||||
const localSettings = isElectron() ? window.api.localSettings : null;
|
const localSettings = isElectron() ? window.api.localSettings : null;
|
||||||
|
|
||||||
interface AddServerFormProps {
|
interface AddServerFormProps {
|
||||||
onCancel: (() => void) | null;
|
onCancel: (() => void) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ServerDetails {
|
||||||
|
icon: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
|
function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
<Stack align="center" justify="center">
|
<Stack align="center" justify="center">
|
||||||
|
|
@ -38,26 +45,54 @@ function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SERVER_TYPES = [
|
function useAutodiscovery() {
|
||||||
{
|
const [isDone, setDone] = useState(false);
|
||||||
label: <ServerIconWithLabel icon={JellyfinIcon} label="Jellyfin" />,
|
const [servers, setServers] = useState<DiscoveredServerItem[]>([]);
|
||||||
value: ServerType.JELLYFIN,
|
|
||||||
|
useEffect(() => {
|
||||||
|
setServers([]);
|
||||||
|
|
||||||
|
autodiscover
|
||||||
|
?.discover((newServer) => {
|
||||||
|
setServers((tail) => [...tail, newServer]);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
setDone(true);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isDone, servers };
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_TYPES: Record<ServerType, ServerDetails> = {
|
||||||
|
[ServerType.JELLYFIN]: {
|
||||||
|
icon: JellyfinIcon,
|
||||||
|
name: 'Jellyfin',
|
||||||
},
|
},
|
||||||
{
|
[ServerType.NAVIDROME]: {
|
||||||
label: <ServerIconWithLabel icon={NavidromeIcon} label="Navidrome" />,
|
icon: NavidromeIcon,
|
||||||
value: ServerType.NAVIDROME,
|
name: 'Navidrome',
|
||||||
},
|
},
|
||||||
{
|
[ServerType.SUBSONIC]: {
|
||||||
label: <ServerIconWithLabel icon={SubsonicIcon} label="OpenSubsonic" />,
|
icon: SubsonicIcon,
|
||||||
value: ServerType.SUBSONIC,
|
name: 'OpenSubsonic',
|
||||||
},
|
},
|
||||||
];
|
};
|
||||||
|
|
||||||
|
const ALL_SERVERS = Object.keys(SERVER_TYPES).map((serverType) => {
|
||||||
|
const info = SERVER_TYPES[serverType];
|
||||||
|
return {
|
||||||
|
label: <ServerIconWithLabel icon={info.icon} label={info.name} />,
|
||||||
|
value: serverType,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const focusTrapRef = useFocusTrap(true);
|
const focusTrapRef = useFocusTrap(true);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { addServer, setCurrentServer } = useAuthStoreActions();
|
const { addServer, setCurrentServer } = useAuthStoreActions();
|
||||||
|
const { servers: discovered } = useAutodiscovery();
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: {
|
initialValues: {
|
||||||
|
|
@ -85,6 +120,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||||
|
|
||||||
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
|
const isSubmitDisabled = !form.values.name || !form.values.url || !form.values.username;
|
||||||
|
|
||||||
|
const fillServerDetails = (server: DiscoveredServerItem) => {
|
||||||
|
form.setValues({ ...server });
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit(async (values) => {
|
const handleSubmit = form.onSubmit(async (values) => {
|
||||||
const authFunction = api.controller.authenticate;
|
const authFunction = api.controller.authenticate;
|
||||||
|
|
||||||
|
|
@ -151,84 +190,104 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit}>
|
<>
|
||||||
<Stack m={5} ref={focusTrapRef}>
|
<Stack>
|
||||||
<SegmentedControl
|
{discovered.map((server) => (
|
||||||
data={SERVER_TYPES}
|
<Paper key={server.url} p="10px">
|
||||||
disabled={Boolean(serverLock)}
|
<Group>
|
||||||
p="md"
|
<img height="32" src={SERVER_TYPES[server.type].icon} width="32" />
|
||||||
withItemsBorders={false}
|
<div
|
||||||
{...form.getInputProps('type')}
|
onClick={() => fillServerDetails(server)}
|
||||||
/>
|
style={{ cursor: 'pointer' }}
|
||||||
<Group grow>
|
>
|
||||||
<TextInput
|
<Text fw={700}>{server.name}</Text>
|
||||||
data-autofocus
|
<Text>
|
||||||
disabled={Boolean(serverLock)}
|
{SERVER_TYPES[server.type].name} server at {server.url}
|
||||||
label={t('form.addServer.input', {
|
</Text>
|
||||||
context: 'name',
|
</div>
|
||||||
postProcess: 'titleCase',
|
</Group>
|
||||||
})}
|
</Paper>
|
||||||
{...form.getInputProps('name')}
|
))}
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
disabled={Boolean(serverLock)}
|
|
||||||
label={t('form.addServer.input', {
|
|
||||||
context: 'url',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
|
||||||
{...form.getInputProps('url')}
|
|
||||||
/>
|
|
||||||
</Group>
|
|
||||||
<TextInput
|
|
||||||
label={t('form.addServer.input', {
|
|
||||||
context: 'username',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
|
||||||
{...form.getInputProps('username')}
|
|
||||||
/>
|
|
||||||
<PasswordInput
|
|
||||||
label={t('form.addServer.input', {
|
|
||||||
context: 'password',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
|
||||||
{...form.getInputProps('password')}
|
|
||||||
/>
|
|
||||||
{localSettings && form.values.type === ServerType.NAVIDROME && (
|
|
||||||
<Checkbox
|
|
||||||
label={t('form.addServer.input', {
|
|
||||||
context: 'savePassword',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
|
||||||
{...form.getInputProps('savePassword', {
|
|
||||||
type: 'checkbox',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{form.values.type === ServerType.SUBSONIC && (
|
|
||||||
<Checkbox
|
|
||||||
label={t('form.addServer.input', {
|
|
||||||
context: 'legacyAuthentication',
|
|
||||||
postProcess: 'titleCase',
|
|
||||||
})}
|
|
||||||
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Group grow justify="flex-end">
|
|
||||||
{onCancel && (
|
|
||||||
<Button onClick={onCancel} variant="subtle">
|
|
||||||
{t('common.cancel', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
disabled={isSubmitDisabled}
|
|
||||||
loading={isLoading}
|
|
||||||
type="submit"
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
{t('common.add', { postProcess: 'titleCase' })}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
<form onSubmit={handleSubmit}>
|
||||||
|
<Stack m={5} ref={focusTrapRef}>
|
||||||
|
<SegmentedControl
|
||||||
|
data={ALL_SERVERS}
|
||||||
|
disabled={Boolean(serverLock)}
|
||||||
|
p="md"
|
||||||
|
withItemsBorders={false}
|
||||||
|
{...form.getInputProps('type')}
|
||||||
|
/>
|
||||||
|
<Group grow>
|
||||||
|
<TextInput
|
||||||
|
data-autofocus
|
||||||
|
disabled={Boolean(serverLock)}
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'name',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('name')}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
disabled={Boolean(serverLock)}
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'url',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('url')}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'username',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('username')}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'password',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
{localSettings && form.values.type === ServerType.NAVIDROME && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'savePassword',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('savePassword', {
|
||||||
|
type: 'checkbox',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{form.values.type === ServerType.SUBSONIC && (
|
||||||
|
<Checkbox
|
||||||
|
label={t('form.addServer.input', {
|
||||||
|
context: 'legacyAuthentication',
|
||||||
|
postProcess: 'titleCase',
|
||||||
|
})}
|
||||||
|
{...form.getInputProps('legacyAuth', { type: 'checkbox' })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Group grow justify="flex-end">
|
||||||
|
{onCancel && (
|
||||||
|
<Button onClick={onCancel} variant="subtle">
|
||||||
|
{t('common.cancel', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
disabled={isSubmitDisabled}
|
||||||
|
loading={isLoading}
|
||||||
|
type="submit"
|
||||||
|
variant="filled"
|
||||||
|
>
|
||||||
|
{t('common.add', { postProcess: 'titleCase' })}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,12 @@ export enum TableColumn {
|
||||||
YEAR = 'releaseYear',
|
YEAR = 'releaseYear',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DiscoveredServerItem = {
|
||||||
|
name: string;
|
||||||
|
type: ServerType;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GridCardData = {
|
export type GridCardData = {
|
||||||
cardControls: any;
|
cardControls: any;
|
||||||
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];
|
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue