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:
Henry 2025-09-26 23:53:19 +01:00 committed by GitHub
parent bca4a14f2e
commit e344adfeed
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 237 additions and 91 deletions

View 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));
});

View file

@ -1,3 +1,4 @@
import './autodiscover';
import './lyrics'; import './lyrics';
import './player'; import './player';
import './remote'; import './remote';

View 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;

View file

@ -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,

View file

@ -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>
</>
); );
}; };

View file

@ -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>[];