feat: sync play queue for navidrome/subsonic (#1335)

---------

Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
Kendall Garner
2025-12-13 05:05:00 +00:00
committed by GitHub
parent 13afd3d9c4
commit ed5d590a6b
31 changed files with 648 additions and 107 deletions
+28
View File
@@ -426,6 +426,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getPlayQueue(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getPlayQueue`,
);
}
return apiController(
'getPlayQueue',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
getRandomSongList(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -656,6 +670,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
savePlayQueue(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: savePlayQueue`,
);
}
return apiController(
'savePlayQueue',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
scrobble(args) {
const server = getServerById(args.apiClientProps.serverId);
+18
View File
@@ -178,6 +178,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
getPlayQueue: {
method: 'GET',
path: 'sessions',
query: jfType._parameters.getQueue,
responses: {
200: jfType._response.getSessions,
400: jfType._response.error,
},
},
getServerInfo: {
method: 'GET',
path: 'system/info',
@@ -283,6 +292,15 @@ export const contract = c.router({
400: jfType._response.error,
},
},
savePlayQueue: {
body: jfType._parameters.saveQueue,
method: 'POST',
path: 'sessions/playing',
responses: {
200: jfType._response.scrobble,
400: jfType._response.error,
},
},
scrobblePlaying: {
body: jfType._parameters.scrobble,
method: 'POST',
@@ -772,6 +772,9 @@ export const JellyfinController: InternalControllerEndpoint = {
totalRecordCount: res.body.TotalRecordCount,
};
},
getPlayQueue: async () => {
throw new Error('Not supported');
},
getRandomSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -1292,6 +1295,9 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async () => {
throw new Error('Not supported');
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
@@ -123,6 +123,14 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
getQueue: {
method: 'GET',
path: 'queue',
responses: {
200: resultWithHeaders(ndType._response.queue),
500: resultWithHeaders(ndType._response.error),
},
},
getSongDetail: {
method: 'GET',
path: 'song/:id',
@@ -177,6 +185,15 @@ export const contract = c.router({
500: resultWithHeaders(ndType._response.error),
},
},
saveQueue: {
body: ndType._parameters.saveQueue,
method: 'POST',
path: 'queue',
responses: {
200: resultWithHeaders(ndType._response.saveQueue),
500: resultWithHeaders(ndType._response.error),
},
},
shareItem: {
body: ndType._parameters.shareItem,
method: 'POST',
@@ -6,7 +6,7 @@ import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller
import { ndNormalize } from '/@/shared/api/navidrome/navidrome-normalize';
import { NDSongListSort } from '/@/shared/api/navidrome/navidrome-types';
import { ssNormalize } from '/@/shared/api/subsonic/subsonic-normalize';
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
import { getFeatures, hasFeature, hasFeatureWithVersion, VersionInfo } from '/@/shared/api/utils';
import {
albumArtistListSortMap,
albumListSortMap,
@@ -25,6 +25,9 @@ import {
import { ServerFeature } from '/@/shared/types/features-types';
const VERSION_INFO: VersionInfo = [
// Why 2? Subsonic controller will return 1 for its own implementation
// Use 2 to denote that Navidrome's own API has a different endpoint
['0.57.0', { [ServerFeature.SERVER_PLAY_QUEUE]: [2] }],
['0.56.0', { [ServerFeature.TRACK_ALBUM_ARTIST_SEARCH]: [1] }],
['0.55.0', { [ServerFeature.BFR]: [1], [ServerFeature.TAGS]: [1] }],
['0.49.3', { [ServerFeature.SHARING_ALBUM_SONG]: [1] }],
@@ -527,6 +530,32 @@ export const NavidromeController: InternalControllerEndpoint = {
totalRecordCount: Number(res.body.headers.get('x-total-count') || 0),
};
},
getPlayQueue: async (args) => {
const { apiClientProps } = args;
if (hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2)) {
const res = await ndApiClient(apiClientProps).getQueue();
if (res.status !== 200) {
throw new Error('Failed to get play queue');
}
const { changedBy, current, items, position, updatedAt } = res.body.data;
const entries = items.map((song) => ndNormalize.song(song, apiClientProps.server));
return {
changed: updatedAt,
changedBy,
currentIndex: current !== undefined ? current : 0,
entry: entries,
positionMs: position,
username: apiClientProps.server?.username ?? '',
};
}
return SubsonicController.getPlayQueue(args);
},
getRandomSongList: SubsonicController.getRandomSongList,
getRoles: async ({ apiClientProps }) =>
hasFeature(apiClientProps.server, ServerFeature.BFR) ? NAVIDROME_ROLES : [],
@@ -548,12 +577,18 @@ export const NavidromeController: InternalControllerEndpoint = {
const subsonicArgs = await SubsonicController.getServerInfo(args);
const features = {
...navidromeFeatures,
...subsonicArgs.features,
...navidromeFeatures,
publicPlaylist: [1],
[ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1],
};
if (subsonicArgs.features.serverPlayQueue && navidromeFeatures.serverPlayQueue) {
features.serverPlayQueue = navidromeFeatures.serverPlayQueue.concat(
subsonicArgs.features.serverPlayQueue,
);
}
return {
features,
id: apiClientProps.serverId,
@@ -847,6 +882,31 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async (args) => {
const { apiClientProps, query } = args;
// Prefer using Navidrome's API only in the situation where the OpenSubsonic extension is not present
// OpenSubsonic extension is preferable as the credentials never expire
if (
hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 2) &&
!hasFeatureWithVersion(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE, 1)
) {
const res = await ndApiClient(apiClientProps).saveQueue({
body: {
current: query.currentIndex !== undefined ? query.currentIndex : undefined,
ids: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
return;
}
return SubsonicController.savePlayQueue(args);
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
+30
View File
@@ -141,6 +141,20 @@ export const contract = c.router({
200: ssType._response.getPlaylists,
},
},
getPlayQueue: {
method: 'GET',
path: 'getPlayQueue.view',
responses: {
200: ssType._response.playQueue,
},
},
getPlayQueueByIndex: {
method: 'GET',
path: 'getPlayQueueByIndex.view',
responses: {
200: ssType._response.playQueueByIndex,
},
},
getRandomSongList: {
method: 'GET',
path: 'getRandomSongs.view',
@@ -227,6 +241,22 @@ export const contract = c.router({
200: ssType._response.removeFavorite,
},
},
savePlayQueue: {
method: 'GET',
path: 'savePlayQueue.view',
query: ssType._parameters.saveQueue,
responses: {
200: ssType._response.saveQueue,
},
},
savePlayQueueByIndex: {
method: 'GET',
path: 'savePlayQueueByIndex.view',
query: ssType._parameters.savePlayQueueByIndex,
responses: {
200: ssType._response.saveQueue,
},
},
scrobble: {
method: 'GET',
path: 'scrobble.view',
@@ -15,7 +15,7 @@ import {
ssType,
SubsonicExtensions,
} from '/@/shared/api/subsonic/subsonic-types';
import { sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
import { hasFeature, sortAlbumArtistList, sortAlbumList, sortSongList } from '/@/shared/api/utils';
import {
AlbumListSort,
GenreListSort,
@@ -27,7 +27,7 @@ import {
SongListSort,
SortOrder,
} from '/@/shared/types/domain-types';
import { ServerFeatures } from '/@/shared/types/features-types';
import { ServerFeature, ServerFeatures } from '/@/shared/types/features-types';
const ALBUM_LIST_SORT_MAPPING: Record<AlbumListSort, AlbumListSortType | undefined> = {
[AlbumListSort.ALBUM_ARTIST]: AlbumListSortType.ALPHABETICAL_BY_ARTIST,
@@ -913,6 +913,44 @@ export const SubsonicController: InternalControllerEndpoint = {
totalRecordCount: items.length,
};
},
getPlayQueue: async ({ apiClientProps }) => {
if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {
const res = await ssApiClient(apiClientProps).getPlayQueueByIndex();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
const { changed, changedBy, currentIndex, entry, position, username } =
res.body.playQueueByIndex;
return {
changed,
changedBy,
currentIndex: currentIndex ?? 0,
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
positionMs: position ?? 0,
username,
};
} else {
const res = await ssApiClient(apiClientProps).getPlayQueue();
if (res.status !== 200) {
throw new Error('Failed to get random songs');
}
const { changed, changedBy, current, entry, position, username } = res.body.playQueue;
return {
changed,
changedBy,
currentIndex: current ? entry.findIndex((item) => item.id === current) : 0,
entry: entry?.map((song) => ssNormalize.song(song, apiClientProps.server)) || [],
positionMs: position ?? 0,
username,
};
}
},
getRandomSongList: async (args) => {
const { apiClientProps, query } = args;
@@ -967,6 +1005,7 @@ export const SubsonicController: InternalControllerEndpoint = {
final.splice(0, 0, { label: 'all artists', value: '' });
return final;
},
getServerInfo: async (args) => {
const { apiClientProps } = args;
@@ -1003,6 +1042,10 @@ export const SubsonicController: InternalControllerEndpoint = {
features.osFormPost = [1];
}
if (subsonicFeatures[SubsonicExtensions.INDEX_BASED_QUEUE]) {
features.serverPlayQueue = [1];
}
return { features, id: apiClientProps.server?.id, version: ping.body.serverVersion };
},
getSimilarSongs: async (args) => {
@@ -1586,6 +1629,36 @@ export const SubsonicController: InternalControllerEndpoint = {
return null;
},
savePlayQueue: async ({ apiClientProps, query }) => {
if (hasFeature(apiClientProps.server, ServerFeature.SERVER_PLAY_QUEUE)) {
const res = await ssApiClient(apiClientProps).savePlayQueueByIndex({
query: {
currentIndex: query.currentIndex !== undefined ? query.currentIndex : undefined,
id: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
} else {
const res = await ssApiClient(apiClientProps).savePlayQueue({
query: {
current:
query.currentIndex !== undefined && query.currentIndex < query.songs.length
? query.songs[query.currentIndex]
: undefined,
id: query.songs,
position: query.positionMs,
},
});
if (res.status !== 200) {
throw new Error('Failed to save play queue');
}
}
},
scrobble: async (args) => {
const { apiClientProps, query } = args;