mirror of
https://github.com/jeffvli/feishin.git
synced 2026-05-07 12:30:12 +02:00
feat: sync play queue for navidrome/subsonic (#1335)
--------- Co-authored-by: jeffvli <jeffvictorli@gmail.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user