mirror of
https://github.com/antebudimir/feishin.git
synced 2026-01-01 10:23:33 +00:00
* initial client-side only sort for all playlists * allow reordering jellyfin (assume it works properly) and navidrome * on playlist page, add to queue by sort order
1137 lines
38 KiB
TypeScript
1137 lines
38 KiB
TypeScript
import chunk from 'lodash/chunk';
|
|
import { z } from 'zod';
|
|
|
|
import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api';
|
|
import { JFSongListSort, JFSortOrder } from '/@/shared/api/jellyfin.types';
|
|
import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize';
|
|
import { jfType } from '/@/shared/api/jellyfin/jellyfin-types';
|
|
import { getFeatures, hasFeature, VersionInfo } from '/@/shared/api/utils';
|
|
import {
|
|
albumArtistListSortMap,
|
|
albumListSortMap,
|
|
ControllerEndpoint,
|
|
genreListSortMap,
|
|
LibraryItem,
|
|
Played,
|
|
playlistListSortMap,
|
|
Song,
|
|
songListSortMap,
|
|
sortOrderMap,
|
|
} from '/@/shared/types/domain-types';
|
|
import { ServerFeature } from '/@/shared/types/features-types';
|
|
|
|
const formatCommaDelimitedString = (value: string[]) => {
|
|
return value.join(',');
|
|
};
|
|
|
|
// Limit the query to 50 at a time to be *extremely* conservative on the
|
|
// length of the full URL, since the ids are part of the query string and
|
|
// not the POST body
|
|
const MAX_ITEMS_PER_PLAYLIST_ADD = 50;
|
|
|
|
const VERSION_INFO: VersionInfo = [
|
|
[
|
|
'10.9.0',
|
|
{
|
|
[ServerFeature.LYRICS_SINGLE_STRUCTURED]: [1],
|
|
[ServerFeature.PUBLIC_PLAYLIST]: [1],
|
|
},
|
|
],
|
|
['10.0.0', { [ServerFeature.TAGS]: [1] }],
|
|
];
|
|
|
|
export const JellyfinController: ControllerEndpoint = {
|
|
addToPlaylist: async (args) => {
|
|
const { apiClientProps, body, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const chunks = chunk(body.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
|
|
|
for (const chunk of chunks) {
|
|
const res = await jfApiClient(apiClientProps).addToPlaylist({
|
|
body: null,
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
query: {
|
|
Ids: chunk.join(','),
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 204) {
|
|
throw new Error('Failed to add to playlist');
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
authenticate: async (url, body) => {
|
|
const cleanServerUrl = url.replace(/\/$/, '');
|
|
|
|
const res = await jfApiClient({ server: null, url: cleanServerUrl }).authenticate({
|
|
body: {
|
|
Pw: body.password,
|
|
Username: body.username,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to authenticate');
|
|
}
|
|
|
|
return {
|
|
credential: res.body.AccessToken,
|
|
userId: res.body.User.Id,
|
|
username: res.body.User.Name,
|
|
};
|
|
},
|
|
createFavorite: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
for (const id of query.id) {
|
|
await jfApiClient(apiClientProps).createFavorite({
|
|
body: {},
|
|
params: {
|
|
id,
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
}
|
|
|
|
return null;
|
|
},
|
|
createPlaylist: async (args) => {
|
|
const { apiClientProps, body } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).createPlaylist({
|
|
body: {
|
|
IsPublic: body.public,
|
|
MediaType: 'Audio',
|
|
Name: body.name,
|
|
UserId: apiClientProps.server.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to create playlist');
|
|
}
|
|
|
|
return {
|
|
id: res.body.Id,
|
|
};
|
|
},
|
|
deleteFavorite: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
for (const id of query.id) {
|
|
await jfApiClient(apiClientProps).removeFavorite({
|
|
body: {},
|
|
params: {
|
|
id,
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
}
|
|
|
|
return null;
|
|
},
|
|
deletePlaylist: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).deletePlaylist({
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 204) {
|
|
throw new Error('Failed to delete playlist');
|
|
}
|
|
|
|
return null;
|
|
},
|
|
getAlbumArtistDetail: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getAlbumArtistDetail({
|
|
params: {
|
|
id: query.id,
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, Overview',
|
|
},
|
|
});
|
|
|
|
const similarArtistsRes = await jfApiClient(apiClientProps).getSimilarArtistList({
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
query: {
|
|
Limit: 10,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200 || similarArtistsRes.status !== 200) {
|
|
throw new Error('Failed to get album artist detail');
|
|
}
|
|
|
|
return jfNormalize.albumArtist(
|
|
{ ...res.body, similarArtists: similarArtistsRes.body },
|
|
apiClientProps.server,
|
|
);
|
|
},
|
|
getAlbumArtistList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
|
query: {
|
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
|
ImageTypeLimit: 1,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
UserId: apiClientProps.server?.userId || undefined,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get album artist list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) =>
|
|
jfNormalize.albumArtist(item, apiClientProps.server),
|
|
),
|
|
startIndex: query.startIndex,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getAlbumArtistListCount: async ({ apiClientProps, query }) =>
|
|
JellyfinController.getAlbumArtistList({
|
|
apiClientProps,
|
|
query: { ...query, limit: 1, startIndex: 0 },
|
|
}).then((result) => result!.totalRecordCount!),
|
|
getAlbumDetail: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getAlbumDetail({
|
|
params: {
|
|
id: query.id,
|
|
userId: apiClientProps.server.userId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, ChildCount, People, Tags',
|
|
},
|
|
});
|
|
|
|
const songsRes = await jfApiClient(apiClientProps).getSongList({
|
|
params: {
|
|
userId: apiClientProps.server.userId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
|
IncludeItemTypes: 'Audio',
|
|
ParentId: query.id,
|
|
SortBy: 'ParentIndexNumber,IndexNumber,SortName',
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200 || songsRes.status !== 200) {
|
|
throw new Error('Failed to get album detail');
|
|
}
|
|
|
|
return jfNormalize.album(
|
|
{ ...res.body, Songs: songsRes.body.Items },
|
|
apiClientProps.server,
|
|
);
|
|
},
|
|
getAlbumList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const yearsGroup: string[] = [];
|
|
if (query.minYear && query.maxYear) {
|
|
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
|
|
yearsGroup.push(String(i));
|
|
}
|
|
}
|
|
|
|
const yearsFilter = yearsGroup.length ? yearsGroup.join(',') : undefined;
|
|
|
|
let artistQuery:
|
|
| Omit<z.infer<typeof jfType._parameters.albumList>, 'IncludeItemTypes'>
|
|
| undefined;
|
|
|
|
if (query.artistIds) {
|
|
// Based mostly off of observation, this is the behavior I've seen:
|
|
// ContributingArtistIds is the _closest_ to where the album is a compilation and the artist is involved
|
|
// AlbumArtistIds is where the artist is an album artist
|
|
// ArtistIds is all credits
|
|
if (query.compilation) {
|
|
artistQuery = {
|
|
ContributingArtistIds: formatCommaDelimitedString(query.artistIds),
|
|
};
|
|
} else if (query.compilation === false) {
|
|
artistQuery = { AlbumArtistIds: formatCommaDelimitedString(query.artistIds) };
|
|
} else {
|
|
artistQuery = { ArtistIds: formatCommaDelimitedString(query.artistIds) };
|
|
}
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getAlbumList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
...artistQuery,
|
|
Fields: 'People, Tags',
|
|
GenreIds: query.genres ? query.genres.join(',') : undefined,
|
|
IncludeItemTypes: 'MusicAlbum',
|
|
IsFavorite: query.favorite,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: albumListSortMap.jellyfin[query.sortBy] || 'SortName',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
...query._custom?.jellyfin,
|
|
Years: yearsFilter,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get album list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
|
startIndex: query.startIndex,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getAlbumListCount: async ({ apiClientProps, query }) =>
|
|
JellyfinController.getAlbumList({
|
|
apiClientProps,
|
|
query: { ...query, limit: 1, startIndex: 0 },
|
|
}).then((result) => result!.totalRecordCount!),
|
|
getArtistList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).getArtistList({
|
|
query: {
|
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
|
ImageTypeLimit: 1,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: albumArtistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
UserId: apiClientProps.server?.userId || undefined,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get album artist list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) =>
|
|
jfNormalize.albumArtist(item, apiClientProps.server),
|
|
),
|
|
startIndex: query.startIndex,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getArtistListCount: async ({ apiClientProps, query }) =>
|
|
JellyfinController.getArtistList({
|
|
apiClientProps,
|
|
query: { ...query, limit: 1, startIndex: 0 },
|
|
}).then((result) => result!.totalRecordCount!),
|
|
getDownloadUrl: (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
return `${apiClientProps.server?.url}/items/${query.id}/download?api_key=${apiClientProps.server?.credential}`;
|
|
},
|
|
getGenreList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getGenreList({
|
|
query: {
|
|
Fields: 'ItemCounts',
|
|
ParentId: query?.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query?.searchTerm,
|
|
SortBy: genreListSortMap.jellyfin[query.sortBy] || 'SortName',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get genre list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.genre(item, apiClientProps.server)),
|
|
startIndex: query.startIndex || 0,
|
|
totalRecordCount: res.body?.TotalRecordCount || 0,
|
|
};
|
|
},
|
|
getLyrics: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getSongLyrics({
|
|
params: {
|
|
id: query.songId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get lyrics');
|
|
}
|
|
|
|
if (res.body.Lyrics.length > 0 && res.body.Lyrics[0].Start === undefined) {
|
|
return res.body.Lyrics.map((lyric) => lyric.Text).join('\n');
|
|
}
|
|
|
|
return res.body.Lyrics.map((lyric) => [lyric.Start! / 1e4, lyric.Text]);
|
|
},
|
|
getMusicFolderList: async (args) => {
|
|
const { apiClientProps } = args;
|
|
const userId = apiClientProps.server?.userId;
|
|
|
|
if (!userId) throw new Error('No userId found');
|
|
|
|
const res = await jfApiClient(apiClientProps).getMusicFolderList({
|
|
params: {
|
|
userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get genre list');
|
|
}
|
|
|
|
const musicFolders = res.body.Items.filter(
|
|
(folder) => folder.CollectionType === jfType._enum.collection.MUSIC,
|
|
);
|
|
|
|
return {
|
|
items: musicFolders.map(jfNormalize.musicFolder),
|
|
startIndex: 0,
|
|
totalRecordCount: musicFolders?.length || 0,
|
|
};
|
|
},
|
|
getPlaylistDetail: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getPlaylistDetail({
|
|
params: {
|
|
id: query.id,
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, ChildCount, ParentId',
|
|
Ids: query.id,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get playlist detail');
|
|
}
|
|
|
|
return jfNormalize.playlist(res.body, apiClientProps.server);
|
|
},
|
|
getPlaylistList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getPlaylistList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
Fields: 'ChildCount, Genres, DateCreated, ParentId, Overview',
|
|
IncludeItemTypes: 'Playlist',
|
|
Limit: query.limit,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: playlistListSortMap.jellyfin[query.sortBy],
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get playlist list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.playlist(item, apiClientProps.server)),
|
|
startIndex: 0,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getPlaylistListCount: async ({ apiClientProps, query }) =>
|
|
JellyfinController.getPlaylistList({
|
|
apiClientProps,
|
|
query: { ...query, limit: 1, startIndex: 0 },
|
|
}).then((result) => result!.totalRecordCount!),
|
|
getPlaylistSongList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getPlaylistSongList({
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, UserData, ParentId, People, Tags',
|
|
IncludeItemTypes: 'Audio',
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get playlist song list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
|
startIndex: 0,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getRandomSongList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const yearsGroup: string[] = [];
|
|
if (query.minYear && query.maxYear) {
|
|
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
|
|
yearsGroup.push(String(i));
|
|
}
|
|
}
|
|
|
|
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
|
|
|
const res = await jfApiClient(apiClientProps).getSongList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
|
GenreIds: query.genre ? query.genre : undefined,
|
|
IncludeItemTypes: 'Audio',
|
|
IsPlayed:
|
|
query.played === Played.Never
|
|
? false
|
|
: query.played === Played.Played
|
|
? true
|
|
: undefined,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SortBy: JFSongListSort.RANDOM,
|
|
SortOrder: JFSortOrder.ASC,
|
|
StartIndex: 0,
|
|
Years: yearsFilter,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get random songs');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
|
startIndex: 0,
|
|
totalRecordCount: res.body.Items.length || 0,
|
|
};
|
|
},
|
|
getRoles: async () => [],
|
|
getServerInfo: async (args) => {
|
|
const { apiClientProps } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).getServerInfo();
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get server info');
|
|
}
|
|
|
|
const features = getFeatures(VERSION_INFO, res.body.Version);
|
|
|
|
return {
|
|
features,
|
|
id: apiClientProps.server?.id,
|
|
version: res.body.Version,
|
|
};
|
|
},
|
|
getSimilarSongs: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
// Prefer getSimilarSongs, where possible. Fallback to InstantMix
|
|
// where no similar songs were found.
|
|
const res = await jfApiClient(apiClientProps).getSimilarSongs({
|
|
params: {
|
|
itemId: query.songId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
|
Limit: query.count,
|
|
UserId: apiClientProps.server?.userId || undefined,
|
|
},
|
|
});
|
|
|
|
if (res.status === 200 && res.body.Items.length) {
|
|
const results = res.body.Items.reduce<Song[]>((acc, song) => {
|
|
if (song.Id !== query.songId) {
|
|
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
|
|
if (results.length > 0) {
|
|
return results;
|
|
}
|
|
}
|
|
|
|
const mix = await jfApiClient(apiClientProps).getInstantMix({
|
|
params: {
|
|
itemId: query.songId,
|
|
},
|
|
query: {
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
|
Limit: query.count,
|
|
UserId: apiClientProps.server?.userId || undefined,
|
|
},
|
|
});
|
|
|
|
if (mix.status !== 200) {
|
|
throw new Error('Failed to get similar songs');
|
|
}
|
|
|
|
return mix.body.Items.reduce<Song[]>((acc, song) => {
|
|
if (song.Id !== query.songId) {
|
|
acc.push(jfNormalize.song(song, apiClientProps.server, ''));
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
},
|
|
getSongDetail: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).getSongDetail({
|
|
params: {
|
|
id: query.id,
|
|
userId: apiClientProps.server?.userId ?? '',
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get song detail');
|
|
}
|
|
|
|
return jfNormalize.song(res.body, apiClientProps.server, '');
|
|
},
|
|
getSongList: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const yearsGroup: string[] = [];
|
|
if (query.minYear && query.maxYear) {
|
|
for (let i = Number(query.minYear); i <= Number(query.maxYear); i += 1) {
|
|
yearsGroup.push(String(i));
|
|
}
|
|
}
|
|
|
|
const yearsFilter = yearsGroup.length ? formatCommaDelimitedString(yearsGroup) : undefined;
|
|
const artistIdsFilter = query.artistIds
|
|
? formatCommaDelimitedString(query.artistIds)
|
|
: query.albumArtistIds
|
|
? formatCommaDelimitedString(query.albumArtistIds)
|
|
: undefined;
|
|
|
|
let items: z.infer<typeof jfType._response.song>[] = [];
|
|
let totalRecordCount = 0;
|
|
const batchSize = 50;
|
|
|
|
// Handle albumIds fetches in batches to prevent HTTP 414 errors
|
|
if (query.albumIds && query.albumIds.length > batchSize) {
|
|
const albumIdBatches = chunk(query.albumIds, batchSize);
|
|
|
|
for (const batch of albumIdBatches) {
|
|
const albumIdsFilter = formatCommaDelimitedString(batch);
|
|
|
|
const res = await jfApiClient(apiClientProps).getSongList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
AlbumIds: albumIdsFilter,
|
|
ArtistIds: artistIdsFilter,
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
|
GenreIds: query.genreIds?.join(','),
|
|
IncludeItemTypes: 'Audio',
|
|
IsFavorite: query.favorite,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
...query._custom?.jellyfin,
|
|
Years: yearsFilter,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get song list');
|
|
}
|
|
|
|
items = [...items, ...res.body.Items];
|
|
totalRecordCount += res.body.Items.length;
|
|
}
|
|
} else {
|
|
const albumIdsFilter = query.albumIds
|
|
? formatCommaDelimitedString(query.albumIds)
|
|
: undefined;
|
|
|
|
const res = await jfApiClient(apiClientProps).getSongList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
AlbumIds: albumIdsFilter,
|
|
ArtistIds: artistIdsFilter,
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
|
GenreIds: query.genreIds?.join(','),
|
|
IncludeItemTypes: 'Audio',
|
|
IsFavorite: query.favorite,
|
|
Limit: query.limit,
|
|
ParentId: query.musicFolderId,
|
|
Recursive: true,
|
|
SearchTerm: query.searchTerm,
|
|
SortBy: songListSortMap.jellyfin[query.sortBy] || 'Album,SortName',
|
|
SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
StartIndex: query.startIndex,
|
|
...query._custom?.jellyfin,
|
|
Years: yearsFilter,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get song list');
|
|
}
|
|
|
|
// Jellyfin Bodge because of code from https://github.com/jellyfin/jellyfin/blob/c566ccb63bf61f9c36743ddb2108a57c65a2519b/Emby.Server.Implementations/Data/SqliteItemRepository.cs#L3622
|
|
// If the Album ID filter is passed, Jellyfin will search for
|
|
// 1. the matching album id
|
|
// 2. An album with the name of the album.
|
|
// It is this second condition causing issues,
|
|
if (query.albumIds) {
|
|
const albumIdSet = new Set(query.albumIds);
|
|
items = res.body.Items.filter((item) => albumIdSet.has(item.AlbumId!));
|
|
totalRecordCount = items.length;
|
|
} else {
|
|
items = res.body.Items;
|
|
totalRecordCount = res.body.TotalRecordCount;
|
|
}
|
|
}
|
|
|
|
return {
|
|
items: items.map((item) =>
|
|
jfNormalize.song(item, apiClientProps.server, '', query.imageSize),
|
|
),
|
|
startIndex: query.startIndex,
|
|
totalRecordCount,
|
|
};
|
|
},
|
|
getSongListCount: async ({ apiClientProps, query }) =>
|
|
JellyfinController.getSongList({
|
|
apiClientProps,
|
|
query: { ...query, limit: 1, startIndex: 0 },
|
|
}).then((result) => result!.totalRecordCount!),
|
|
getTags: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!hasFeature(apiClientProps.server, ServerFeature.TAGS)) {
|
|
return { boolTags: undefined, enumTags: undefined };
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getFilterList({
|
|
query: {
|
|
IncludeItemTypes: query.type === LibraryItem.SONG ? 'Audio' : 'MusicAlbum',
|
|
ParentId: query.folder,
|
|
UserId: apiClientProps.server?.userId ?? '',
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('failed to get tags');
|
|
}
|
|
|
|
return {
|
|
boolTags: res.body.Tags?.sort((a, b) =>
|
|
a.toLocaleLowerCase().localeCompare(b.toLocaleLowerCase()),
|
|
),
|
|
};
|
|
},
|
|
getTopSongs: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).getTopSongsList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
ArtistIds: query.artistId,
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId',
|
|
IncludeItemTypes: 'Audio',
|
|
Limit: query.limit,
|
|
Recursive: true,
|
|
SortBy: 'PlayCount,SortName',
|
|
SortOrder: 'Descending',
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get top song list');
|
|
}
|
|
|
|
return {
|
|
items: res.body.Items.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
|
startIndex: 0,
|
|
totalRecordCount: res.body.TotalRecordCount,
|
|
};
|
|
},
|
|
getTranscodingUrl: (args) => {
|
|
const { base, bitrate, format } = args.query;
|
|
let url = base.replace('transcodingProtocol=hls', 'transcodingProtocol=http');
|
|
if (format) {
|
|
url = url.replace('audioCodec=aac', `audioCodec=${format}`);
|
|
url = url.replace('transcodingContainer=ts', `transcodingContainer=${format}`);
|
|
}
|
|
if (bitrate !== undefined) {
|
|
url += `&maxStreamingBitrate=${bitrate * 1000}`;
|
|
}
|
|
|
|
return url;
|
|
},
|
|
movePlaylistItem: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const res = await jfApiClient(apiClientProps).movePlaylistItem({
|
|
params: {
|
|
itemId: query.trackId,
|
|
newIdx: query.endingIndex.toString(),
|
|
playlistId: query.playlistId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 204) {
|
|
throw new Error('Failed to move item in playlist');
|
|
}
|
|
},
|
|
removeFromPlaylist: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const chunks = chunk(query.songId, MAX_ITEMS_PER_PLAYLIST_ADD);
|
|
|
|
for (const chunk of chunks) {
|
|
const res = await jfApiClient(apiClientProps).removeFromPlaylist({
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
query: {
|
|
EntryIds: chunk.join(','),
|
|
},
|
|
});
|
|
|
|
if (res.status !== 204) {
|
|
throw new Error('Failed to remove from playlist');
|
|
}
|
|
}
|
|
|
|
return null;
|
|
},
|
|
scrobble: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
const position = query.position && Math.round(query.position);
|
|
|
|
if (query.submission) {
|
|
// Checked by jellyfin-plugin-lastfm for whether or not to send the "finished" scrobble (uses PositionTicks)
|
|
jfApiClient(apiClientProps).scrobbleStopped({
|
|
body: {
|
|
IsPaused: true,
|
|
ItemId: query.id,
|
|
PositionTicks: position,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
if (query.event === 'start') {
|
|
jfApiClient(apiClientProps).scrobblePlaying({
|
|
body: {
|
|
ItemId: query.id,
|
|
PositionTicks: position,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
if (query.event === 'pause') {
|
|
jfApiClient(apiClientProps).scrobbleProgress({
|
|
body: {
|
|
EventName: query.event,
|
|
IsPaused: true,
|
|
ItemId: query.id,
|
|
PositionTicks: position,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
if (query.event === 'unpause') {
|
|
jfApiClient(apiClientProps).scrobbleProgress({
|
|
body: {
|
|
EventName: query.event,
|
|
IsPaused: false,
|
|
ItemId: query.id,
|
|
PositionTicks: position,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
}
|
|
|
|
jfApiClient(apiClientProps).scrobbleProgress({
|
|
body: {
|
|
ItemId: query.id,
|
|
PositionTicks: position,
|
|
},
|
|
});
|
|
|
|
return null;
|
|
},
|
|
search: async (args) => {
|
|
const { apiClientProps, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
let albums: z.infer<typeof jfType._response.albumList>['Items'] = [];
|
|
let albumArtists: z.infer<typeof jfType._response.albumArtistList>['Items'] = [];
|
|
let songs: z.infer<typeof jfType._response.songList>['Items'] = [];
|
|
|
|
if (query.albumLimit) {
|
|
const res = await jfApiClient(apiClientProps).getAlbumList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
EnableTotalRecordCount: true,
|
|
Fields: 'People, Tags',
|
|
ImageTypeLimit: 1,
|
|
IncludeItemTypes: 'MusicAlbum',
|
|
Limit: query.albumLimit,
|
|
Recursive: true,
|
|
SearchTerm: query.query,
|
|
SortBy: 'SortName',
|
|
SortOrder: 'Ascending',
|
|
StartIndex: query.albumStartIndex || 0,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get album list');
|
|
}
|
|
|
|
albums = res.body.Items;
|
|
}
|
|
|
|
if (query.albumArtistLimit) {
|
|
const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
|
query: {
|
|
EnableTotalRecordCount: true,
|
|
Fields: 'Genres, DateCreated, ExternalUrls, Overview',
|
|
ImageTypeLimit: 1,
|
|
IncludeArtists: true,
|
|
Limit: query.albumArtistLimit,
|
|
Recursive: true,
|
|
SearchTerm: query.query,
|
|
StartIndex: query.albumArtistStartIndex || 0,
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get album artist list');
|
|
}
|
|
|
|
albumArtists = res.body.Items;
|
|
}
|
|
|
|
if (query.songLimit) {
|
|
const res = await jfApiClient(apiClientProps).getSongList({
|
|
params: {
|
|
userId: apiClientProps.server?.userId,
|
|
},
|
|
query: {
|
|
EnableTotalRecordCount: true,
|
|
Fields: 'Genres, DateCreated, MediaSources, ParentId, People, Tags',
|
|
IncludeItemTypes: 'Audio',
|
|
Limit: query.songLimit,
|
|
Recursive: true,
|
|
SearchTerm: query.query,
|
|
SortBy: 'Album,SortName',
|
|
SortOrder: 'Ascending',
|
|
StartIndex: query.songStartIndex || 0,
|
|
UserId: apiClientProps.server?.userId,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 200) {
|
|
throw new Error('Failed to get song list');
|
|
}
|
|
|
|
songs = res.body.Items;
|
|
}
|
|
|
|
return {
|
|
albumArtists: albumArtists.map((item) =>
|
|
jfNormalize.albumArtist(item, apiClientProps.server),
|
|
),
|
|
albums: albums.map((item) => jfNormalize.album(item, apiClientProps.server)),
|
|
songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server, '')),
|
|
};
|
|
},
|
|
updatePlaylist: async (args) => {
|
|
const { apiClientProps, body, query } = args;
|
|
|
|
if (!apiClientProps.server?.userId) {
|
|
throw new Error('No userId found');
|
|
}
|
|
|
|
const res = await jfApiClient(apiClientProps).updatePlaylist({
|
|
body: {
|
|
Genres: body.genres?.map((item) => ({ Id: item.id, Name: item.name })) || [],
|
|
IsPublic: body.public,
|
|
MediaType: 'Audio',
|
|
Name: body.name,
|
|
PremiereDate: null,
|
|
ProviderIds: {},
|
|
Tags: [],
|
|
UserId: apiClientProps.server?.userId, // Required
|
|
},
|
|
params: {
|
|
id: query.id,
|
|
},
|
|
});
|
|
|
|
if (res.status !== 204) {
|
|
throw new Error('Failed to update playlist');
|
|
}
|
|
|
|
return null;
|
|
},
|
|
};
|
|
|
|
// const getArtistList = async (args: ArtistListArgs): Promise<AlbumArtistListResponse> => {
|
|
// const { query, apiClientProps } = args;
|
|
|
|
// const res = await jfApiClient(apiClientProps).getAlbumArtistList({
|
|
// query: {
|
|
// Limit: query.limit,
|
|
// ParentId: query.musicFolderId,
|
|
// Recursive: true,
|
|
// SortBy: artistListSortMap.jellyfin[query.sortBy] || 'SortName,Name',
|
|
// SortOrder: sortOrderMap.jellyfin[query.sortOrder],
|
|
// StartIndex: query.startIndex,
|
|
// },
|
|
// });
|
|
|
|
// if (res.status !== 200) {
|
|
// throw new Error('Failed to get artist list');
|
|
// }
|
|
|
|
// return {
|
|
// items: res.body.Items.map((item) => jfNormalize.albumArtist(item, apiClientProps.server)),
|
|
// startIndex: query.startIndex,
|
|
// totalRecordCount: res.body.TotalRecordCount,
|
|
// };
|
|
// };
|