mirror of
https://github.com/antebudimir/feishin.git
synced 2025-12-31 18:13:31 +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 './player';
|
||||
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 { 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,
|
||||
|
|
|
|||
|
|
@ -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,84 +190,104 @@ export const AddServerForm = ({ onCancel }: AddServerFormProps) => {
|
|||
});
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack m={5} ref={focusTrapRef}>
|
||||
<SegmentedControl
|
||||
data={SERVER_TYPES}
|
||||
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>
|
||||
{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>
|
||||
<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',
|
||||
}
|
||||
|
||||
export type DiscoveredServerItem = {
|
||||
name: string;
|
||||
type: ServerType;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type GridCardData = {
|
||||
cardControls: any;
|
||||
cardRows: CardRow<Album | AlbumArtist | Artist | Playlist | Song>[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue