add support for full playlist re-order (#1327)

This commit is contained in:
jeffvli
2025-12-06 17:41:10 -08:00
parent 126b5ed67d
commit 0a7029f7bc
28 changed files with 1301 additions and 59 deletions
+14
View File
@@ -628,6 +628,20 @@ export const controller: GeneralController = {
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
replacePlaylist(args) {
const server = getServerById(args.apiClientProps.serverId);
if (!server) {
throw new Error(
`${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: replacePlaylist`,
);
}
return apiController(
'replacePlaylist',
server.type,
)?.({ ...args, apiClientProps: { ...args.apiClientProps, server } });
},
scrobble(args) {
const server = getServerById(args.apiClientProps.serverId);
@@ -1,3 +1,4 @@
import { set } from 'idb-keyval';
import chunk from 'lodash/chunk';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
@@ -1162,6 +1163,113 @@ export const JellyfinController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
if (!apiClientProps.server?.userId) {
throw new Error('No userId found');
}
// 1. Fetch existing songs from the playlist
const existingSongsRes = await jfApiClient(apiClientProps).getPlaylistSongList({
params: {
id: query.id,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
IncludeItemTypes: 'Audio',
UserId: apiClientProps.server?.userId,
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs = existingSongsRes.body.Items.map((item) =>
jfNormalize.song(item, apiClientProps.server),
);
// 2. Get playlist detail to get the name
const playlistDetailRes = await jfApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
userId: apiClientProps.server?.userId,
},
query: {
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
Ids: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = jfNormalize.playlist(playlistDetailRes.body, apiClientProps.server);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist
if (existingSongs.length > 0) {
const existingPlaylistItemIds = existingSongs
.map((song) => song.playlistItemId)
.filter((id): id is string => id !== undefined && id !== null);
if (existingPlaylistItemIds.length > 0) {
const chunks = chunk(existingPlaylistItemIds, MAX_ITEMS_PER_PLAYLIST_ADD);
for (const chunk of chunks) {
const removeRes = await jfApiClient(apiClientProps).removeFromPlaylist({
params: {
id: query.id,
},
query: {
EntryIds: chunk.join(','),
},
});
if (removeRes.status !== 204) {
throw new Error('Failed to remove songs from playlist');
}
}
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
for (const chunk of chunks) {
const addRes = await jfApiClient(apiClientProps).addToPlaylist({
body: null,
params: {
id: query.id,
},
query: {
Ids: chunk.join(','),
UserId: apiClientProps.server?.userId,
},
});
if (addRes.status !== 204) {
throw new Error('Failed to add songs to playlist');
}
}
}
return null;
},
scrobble: async (args) => {
const { apiClientProps, query } = args;
@@ -1,3 +1,5 @@
import { set } from 'idb-keyval';
import { ndApiClient } from '/@/renderer/api/navidrome/navidrome-api';
import { ssApiClient } from '/@/renderer/api/subsonic/subsonic-api';
import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller';
@@ -782,6 +784,95 @@ export const NavidromeController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
// 1. Fetch existing songs from the playlist without any sorts
const existingSongsRes = await ndApiClient(apiClientProps as any).getPlaylistSongList({
params: {
id: query.id,
},
query: {
_end: -1,
_order: 'ASC',
_start: 0,
...excludeMissing(apiClientProps.server),
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs = existingSongsRes.body.data.map((item) =>
ndNormalize.song(item, apiClientProps.server),
);
// 2. Get playlist detail to get the name
const playlistDetailRes = await ndApiClient(apiClientProps).getPlaylistDetail({
params: {
id: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = ndNormalize.playlist(playlistDetailRes.body.data, apiClientProps.server);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist
if (existingSongs.length > 0) {
const existingPlaylistItemIds = existingSongs
.map((song) => song.playlistItemId)
.filter((id): id is string => id !== undefined && id !== null);
if (existingPlaylistItemIds.length > 0) {
const removeRes = await ndApiClient(apiClientProps).removeFromPlaylist({
params: {
id: query.id,
},
query: {
id: existingPlaylistItemIds,
},
});
if (removeRes.status !== 200) {
throw new Error('Failed to remove songs from playlist');
}
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const addRes = await ndApiClient(apiClientProps).addToPlaylist({
body: {
ids: body.songId,
},
params: {
id: query.id,
},
});
if (addRes.status !== 200) {
throw new Error('Failed to add songs to playlist');
}
}
return null;
},
scrobble: SubsonicController.scrobble,
search: SubsonicController.search,
setRating: SubsonicController.setRating,
@@ -1,6 +1,7 @@
import type { ServerInferResponses } from '@ts-rest/core';
import dayjs from 'dayjs';
import { set } from 'idb-keyval';
import filter from 'lodash/filter';
import orderBy from 'lodash/orderBy';
import md5 from 'md5';
@@ -1479,6 +1480,87 @@ export const SubsonicController: InternalControllerEndpoint = {
return null;
},
replacePlaylist: async (args) => {
const { apiClientProps, body, query } = args;
// 1. Fetch existing songs from the playlist
const existingSongsRes = await ssApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (existingSongsRes.status !== 200) {
throw new Error('Failed to fetch existing playlist songs');
}
const existingSongs =
existingSongsRes.body.playlist.entry?.map((song) =>
ssNormalize.song(song, apiClientProps.server),
) || [];
// 2. Get playlist detail to get the name
const playlistDetailRes = await ssApiClient(apiClientProps).getPlaylist({
query: {
id: query.id,
},
});
if (playlistDetailRes.status !== 200) {
throw new Error('Failed to get playlist detail');
}
const playlist = ssNormalize.playlist(
playlistDetailRes.body.playlist,
apiClientProps.server,
);
// 3. Make a backup of the playlist ids and their order, along with the id of the playlist and name
const backup = {
id: query.id,
name: playlist.name,
songIds: existingSongs.map((song) => song.id),
timestamp: Date.now(),
};
// Store backup in IndexedDB using idb-keyval
const backupKey = `playlist-backup-${query.id}`;
await set(backupKey, backup);
// 4. Remove all songs from the playlist (Subsonic uses indices, not IDs)
if (existingSongs.length > 0) {
// Get indices of all songs (0-based)
// Remove in reverse order to avoid index shifting issues
const songIndices = existingSongs.map((_, index) => index).reverse();
const removeRes = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
songIndexToRemove: songIndices.map((index) => index.toString()),
},
});
if (removeRes.status !== 200) {
throw new Error('Failed to remove songs from playlist');
}
}
// 5. Add the new song ids to the playlist
if (body.songId.length > 0) {
const addRes = await ssApiClient(apiClientProps).updatePlaylist({
query: {
playlistId: query.id,
songIdToAdd: body.songId,
},
});
if (addRes.status !== 200) {
throw new Error('Failed to add songs to playlist');
}
}
return null;
},
scrobble: async (args) => {
const { apiClientProps, query } = args;