diff --git a/src/main/features/core/autodiscover/index.ts b/src/main/features/core/autodiscover/index.ts new file mode 100644 index 00000000..9155b101 --- /dev/null +++ b/src/main/features/core/autodiscover/index.ts @@ -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((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)); +}); diff --git a/src/main/features/core/index.ts b/src/main/features/core/index.ts index 1846db0d..fad60828 100644 --- a/src/main/features/core/index.ts +++ b/src/main/features/core/index.ts @@ -1,3 +1,4 @@ +import './autodiscover'; import './lyrics'; import './player'; import './remote'; diff --git a/src/preload/autodiscover.ts b/src/preload/autodiscover.ts new file mode 100644 index 00000000..f3a7909f --- /dev/null +++ b/src/preload/autodiscover.ts @@ -0,0 +1,23 @@ +import { ipcRenderer } from 'electron'; + +import { DiscoveredServerItem } from '../shared/types/types'; + +const discover = (onReply: (server: DiscoveredServerItem) => void): Promise => { + const { port1: local, port2: remote } = new MessageChannel(); + + ipcRenderer.postMessage('autodiscover-ping', {}, [remote]); + + local.onmessage = (ev) => { + onReply(ev.data); + }; + + return new Promise((resolve) => { + local.addEventListener('close', () => resolve()); + }); +}; + +export const autodiscover = { + discover, +}; + +export type AutoDiscover = typeof autodiscover; diff --git a/src/preload/index.ts b/src/preload/index.ts index 7115f7b3..1f87f408 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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, diff --git a/src/renderer/features/servers/components/add-server-form.tsx b/src/renderer/features/servers/components/add-server-form.tsx index c9768e0d..4116aa32 100644 --- a/src/renderer/features/servers/components/add-server-form.tsx +++ b/src/renderer/features/servers/components/add-server-form.tsx @@ -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 ( @@ -38,26 +45,54 @@ function ServerIconWithLabel({ icon, label }: { icon: string; label: string }) { ); } -const SERVER_TYPES = [ - { - label: , - value: ServerType.JELLYFIN, +function useAutodiscovery() { + const [isDone, setDone] = useState(false); + const [servers, setServers] = useState([]); + + useEffect(() => { + setServers([]); + + autodiscover + ?.discover((newServer) => { + setServers((tail) => [...tail, newServer]); + }) + .then(() => { + setDone(true); + }); + }, []); + + return { isDone, servers }; +} + +const SERVER_TYPES: Record = { + [ServerType.JELLYFIN]: { + icon: JellyfinIcon, + name: 'Jellyfin', }, - { - label: , - value: ServerType.NAVIDROME, + [ServerType.NAVIDROME]: { + icon: NavidromeIcon, + name: 'Navidrome', }, - { - label: , - 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: , + 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 ( -
- - - - - - - - - {localSettings && form.values.type === ServerType.NAVIDROME && ( - - )} - {form.values.type === ServerType.SUBSONIC && ( - - )} - - {onCancel && ( - - )} - - + <> + + {discovered.map((server) => ( + + + +
fillServerDetails(server)} + style={{ cursor: 'pointer' }} + > + {server.name} + + {SERVER_TYPES[server.type].name} server at {server.url} + +
+
+
+ ))}
- +
+ + + + + + + + + {localSettings && form.values.type === ServerType.NAVIDROME && ( + + )} + {form.values.type === ServerType.SUBSONIC && ( + + )} + + {onCancel && ( + + )} + + + +
+ ); }; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index 5beb700a..b3704528 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -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[];