From 95f395bd87ed5db061f7bae98cbdbde87260817d Mon Sep 17 00:00:00 2001 From: jeffvli Date: Tue, 16 Dec 2025 18:07:09 -0800 Subject: [PATCH] add jukebox endpoint / controller --- .../api/jellyfin/jellyfin-controller.ts | 3 + .../api/navidrome/navidrome-controller.ts | 2 + src/renderer/api/query-keys.ts | 7 +++ src/renderer/api/subsonic/subsonic-api.ts | 8 +++ .../api/subsonic/subsonic-controller.ts | 29 +++++++++- .../features/jukebox/api/jukebox-api.ts | 19 ++++++ src/shared/api/subsonic/subsonic-types.ts | 58 +++++++++++++++++++ src/shared/types/domain-types.ts | 35 +++++++++++ src/shared/types/features-types.ts | 1 + 9 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/renderer/features/jukebox/api/jukebox-api.ts diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 4bdf93c22..f749ab25d 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -1202,6 +1202,9 @@ export const JellyfinController: InternalControllerEndpoint = { name: res.body.Name, }; }, + jukeboxControl: async () => { + throw new Error('Not implemented'); + }, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 207280692..6c62f3a18 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -582,6 +582,7 @@ export const NavidromeController: InternalControllerEndpoint = { const features = { ...subsonicArgs.features, ...navidromeFeatures, + jukebox: [1], publicPlaylist: [1], [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], }; @@ -761,6 +762,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, + jukeboxControl: SubsonicController.jukeboxControl, movePlaylistItem: async (args) => { const { apiClientProps, query } = args; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index 9f53bd979..d8b5e4366 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -6,6 +6,7 @@ import type { ArtistListQuery, FolderQuery, GenreListQuery, + JukeboxControlQuery, LyricSearchQuery, LyricsQuery, PlaylistDetailQuery, @@ -262,6 +263,12 @@ export const queryKeys: Record< }, root: (serverId: string) => [serverId, 'genres'] as const, }, + jukebox: { + control: (serverId: string, query?: JukeboxControlQuery) => { + if (query) return [serverId, 'jukebox', 'control', query] as const; + return [serverId, 'jukebox', 'control'] as const; + }, + }, musicFolders: { list: (serverId: string) => [serverId, 'musicFolders', 'list'] as const, }, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 7b672af77..6bd27d310 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -249,6 +249,14 @@ export const contract = c.router({ 200: ssType._response.user, }, }, + jukeboxControl: { + method: 'GET', + path: 'jukeboxControl.view', + query: ssType._parameters.jukeboxControl, + responses: { + 200: ssType._response.jukeboxPlaylist, + }, + }, ping: { method: 'GET', path: 'ping.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index b47af74f7..7e1218cc2 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -867,7 +867,6 @@ export const SubsonicController: InternalControllerEndpoint = { return ssNormalize.playlist(res.body.playlist, apiClientProps.server); }, - getPlaylistList: async ({ apiClientProps, query }) => { const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; @@ -1060,7 +1059,9 @@ export const SubsonicController: InternalControllerEndpoint = { throw new Error('Failed to ping server'); } - const features: ServerFeatures = {}; + const features: ServerFeatures = { + jukebox: [1], + }; if (!ping.body.openSubsonic || !ping.body.serverVersion) { return { features, version: ping.body.version }; @@ -1579,6 +1580,30 @@ export const SubsonicController: InternalControllerEndpoint = { name: res.body.user.username, }; }, + jukeboxControl: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).jukeboxControl({ + query: query, + }); + + if (res.status !== 200) { + throw new Error('Failed to control jukebox'); + } + + const jukeboxPlaylist = res.body.jukeboxPlaylist; + + return { + currentIndex: jukeboxPlaylist.currentIndex, + gain: jukeboxPlaylist.gain, + playing: jukeboxPlaylist.playing, + position: jukeboxPlaylist.position ?? 0, + songs: + jukeboxPlaylist.entry?.map((song) => + ssNormalize.song(song, apiClientProps.server), + ) || [], + }; + }, removeFromPlaylist: async ({ apiClientProps, query }) => { const res = await ssApiClient(apiClientProps).updatePlaylist({ query: { diff --git a/src/renderer/features/jukebox/api/jukebox-api.ts b/src/renderer/features/jukebox/api/jukebox-api.ts new file mode 100644 index 000000000..9c138cf59 --- /dev/null +++ b/src/renderer/features/jukebox/api/jukebox-api.ts @@ -0,0 +1,19 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; +import { JukeboxControlQuery } from '/@/shared/types/domain-types'; + +export const jukeboxQueries = { + jukeboxControl: (args: QueryHookArgs) => { + return queryOptions({ + queryFn: ({ signal }) => + api.controller.jukeboxControl({ + apiClientProps: { serverId: args.serverId, signal }, + query: args.query, + }), + queryKey: queryKeys.jukebox.control(args.serverId, args.query), + }); + }, +}; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 092e8bd9d..628d9f701 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -692,6 +692,61 @@ const getInternetRadioStations = z.object({ .optional(), }); +const jukeboxStatus = z.object({ + jukeboxStatus: z.object({ + currentIndex: z.number().describe('The index of the current song in the queue'), + gain: z.number().describe('Volume, in a range of [0.0, 1.0]'), + playing: z.boolean().describe('Whether the jukebox is playing'), + position: z.number().optional().describe('The position of the current song in seconds'), + }), +}); + +const jukeboxPlaylist = z.object({ + jukeboxPlaylist: z.object({ + currentIndex: z.number().describe('The index of the current song in the queue'), + entry: z.array(song).optional(), + gain: z.number().describe('Volume, in a range of [0.0, 1.0]'), + playing: z.boolean().describe('Whether the jukebox is playing'), + position: z.number().optional().describe('The position of the current song in seconds'), + }), +}); + +const jukeboxControlParameters = z.object({ + action: z.enum([ + 'get', + 'status', + 'set', + 'start', + 'stop', + 'skip', + 'add', + 'clear', + 'remove', + 'shuffle', + 'setGain', + ]), + gain: z + .number() + .optional() + .describe( + 'Used by setGain to control the playback volume. A float value between 0.0 and 1.0.', + ), + id: z + .string() + .optional() + .describe( + 'Used by add and set. ID of song to add to the jukebox playlist. Use multiple id parameters to add many songs in the same request. (set is similar to a clear followed by a add, but will not change the currently playing track.)', + ), + index: z + .number() + .optional() + .describe('Used by skip and remove. Zero-based index of the song to skip to or remove.'), + offset: z + .number() + .optional() + .describe('Used by skip. Start playing this many seconds into the track.'), +}); + export const ssType = { _parameters: { albumInfo: albumInfoParameters, @@ -716,6 +771,7 @@ export const ssType = { getSong: getSongParameters, getSongsByGenre: getSongsByGenreParameters, getStarred: getStarredParameters, + jukeboxControl: jukeboxControlParameters, randomSongList: randomSongListParameters, removeFavorite: removeFavoriteParameters, savePlayQueueByIndex: savePlayQueueByIndexParameters, @@ -761,6 +817,8 @@ export const ssType = { getSongsByGenre, getStarred, internetRadioStation, + jukeboxPlaylist, + jukeboxStatus, musicFolderList, ping, playlist, diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 7c3fd0045..ee065ce06 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -1364,6 +1364,7 @@ export type ControllerEndpoint = { // getArtistInfo?: (args: any) => void; getUserInfo: (args: UserInfoArgs) => Promise; getUserList?: (args: UserListArgs) => Promise; + jukeboxControl: (args: JukeboxControlArgs) => Promise; movePlaylistItem?: (args: MoveItemArgs) => Promise; removeFromPlaylist: (args: RemoveFromPlaylistArgs) => Promise; replacePlaylist: (args: ReplacePlaylistArgs) => Promise; @@ -1486,6 +1487,9 @@ export type InternalControllerEndpoint = { getTopSongs: (args: ReplaceApiClientProps) => Promise; getUserInfo: (args: ReplaceApiClientProps) => Promise; getUserList?: (args: ReplaceApiClientProps) => Promise; + jukeboxControl: ( + args: ReplaceApiClientProps, + ) => Promise; movePlaylistItem?: (args: ReplaceApiClientProps) => Promise; removeFromPlaylist: ( args: ReplaceApiClientProps, @@ -1506,6 +1510,37 @@ export type InternalControllerEndpoint = { ) => Promise; }; +export type JukeboxControlArgs = BaseEndpointArgs & { + query: JukeboxControlQuery; +}; + +export type JukeboxControlQuery = { + action: + | 'add' + | 'clear' + | 'get' + | 'remove' + | 'set' + | 'setGain' + | 'shuffle' + | 'skip' + | 'start' + | 'status' + | 'stop'; + gain?: number; + id?: string; + index?: number; + offset?: number; +}; + +export type JukeboxControlResponse = { + currentIndex: number; + gain: number; + playing: boolean; + position: number; + songs: Song[]; +}; + export type LyricGetQuery = { remoteSongId: string; remoteSource: LyricSource; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index c7cfcb959..b70832dcd 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -2,6 +2,7 @@ // For example: : "Playlists", : "Smart" = "PLAYLISTS_SMART" export enum ServerFeature { BFR = 'bfr', + JUKEBOX = 'jukebox', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect',