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 './player';
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 { contextBridge } from 'electron';
import { autodiscover } from './autodiscover';
import { browser } from './browser';
import { discordRpc } from './discord-rpc';
import { ipc } from './ipc';
@ -13,6 +14,7 @@ import { utils } from './utils';
// Custom APIs for renderer
const api = {
autodiscover,
browser,
discordRpc,
ipc,

View file

@ -3,7 +3,7 @@ import { useFocusTrap } from '@mantine/hooks';
import { closeAllModals } from '@mantine/modals';
import isElectron from 'is-electron';
import { nanoid } from 'nanoid/non-secure';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { api } from '/@/renderer/api';
@ -14,6 +14,7 @@ import { useAuthStoreActions } from '/@/renderer/store';
import { Button } from '/@/shared/components/button/button';
import { Checkbox } from '/@/shared/components/checkbox/checkbox';
import { Group } from '/@/shared/components/group/group';
import { Paper } from '/@/shared/components/paper/paper';
import { PasswordInput } from '/@/shared/components/password-input/password-input';
import { SegmentedControl } from '/@/shared/components/segmented-control/segmented-control';
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 { toast } from '/@/shared/components/toast/toast';
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;
interface AddServerFormProps {
onCancel: (() => void) | null;
}
interface ServerDetails {
icon: string;
name: string;
}
function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
return (
<Stack align="center" justify="center">
@ -38,26 +45,54 @@ function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) {
);
}
const SERVER_TYPES = [
{
label: <ServerIconWithLabel icon={JellyfinIcon} label="Jellyfin" />,
value: ServerType.JELLYFIN,
function useAutodiscovery() {
const [isDone, setDone] = useState(false);
const [servers, setServers] = useState<DiscoveredServerItem[]>([]);
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',
},
{
label: <ServerIconWithLabel icon={NavidromeIcon} label="Navidrome" />,
value: ServerType.NAVIDROME,
[ServerType.NAVIDROME]: {
icon: NavidromeIcon,
name: 'Navidrome',
},
{
label: <ServerIconWithLabel icon={SubsonicIcon} label="OpenSubsonic" />,
value: ServerType.SUBSONIC,
[ServerType.SUBSONIC]: {
icon: SubsonicIcon,
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) => {
const { t } = useTranslation();
const focusTrapRef = useFocusTrap(true);
const [isLoading, setIsLoading] = useState(false);
const { addServer, setCurrentServer } = useAuthStoreActions();
const { servers: discovered } = useAutodiscovery();
const form = useForm({
initialValues: {
@ -85,6 +120,10 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
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 authFunction = api.controller.authenticate;
@ -151,10 +190,29 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
});
return (
<>
<Stack>
{discovered.map((server) => (
<Paper key={server.url} p="10px">
<Group>
<img height="32" src={SERVER_TYPES[server.type].icon} width="32" />
<div
onClick={() => fillServerDetails(server)}
style={{ cursor: 'pointer' }}
>
<Text fw={700}>{server.name}</Text>
<Text>
{SERVER_TYPES[server.type].name} server at {server.url}
</Text>
</div>
</Group>
</Paper>
))}
</Stack>
<form onSubmit={handleSubmit}>
<Stack m={5} ref={focusTrapRef}>
<SegmentedControl
data={SERVER_TYPES}
data={ALL_SERVERS}
disabled={Boolean(serverLock)}
p="md"
withItemsBorders={false}
@ -230,5 +288,6 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
</Group>
</Stack>
</form>
</>
);
};

View file

@ -166,6 +166,12 @@ export enum TableColumn {
YEAR = 'releaseYear',
}
export type DiscoveredServerItem = {
name: string;
type: ServerType;
url: string;
};
export type GridCardData = {
cardControls: any;
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];