add playlist/artist image upload for jellyfin (#2105)

This commit is contained in:
jeffvli
2026-06-03 00:07:31 -07:00
parent be3f959354
commit c4da44a443
4 changed files with 189 additions and 1 deletions
+36
View File
@@ -54,6 +54,15 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
deleteArtistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deleteArtistImage,
400: jfType._response.error,
},
},
deletePlaylist: { deletePlaylist: {
body: null, body: null,
method: 'DELETE', method: 'DELETE',
@@ -63,6 +72,15 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
deletePlaylistImage: {
body: null,
method: 'DELETE',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.deletePlaylistImage,
400: jfType._response.error,
},
},
getAlbumArtistDetail: { getAlbumArtistDetail: {
method: 'GET', method: 'GET',
path: 'users/:userId/items/:id', path: 'users/:userId/items/:id',
@@ -356,6 +374,24 @@ export const contract = c.router({
400: jfType._response.error, 400: jfType._response.error,
}, },
}, },
uploadArtistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadArtistImage,
400: jfType._response.error,
},
},
uploadPlaylistImage: {
body: z.string(),
method: 'POST',
path: 'Items/:id/Images/Primary',
responses: {
204: jfType._response.uploadPlaylistImage,
400: jfType._response.error,
},
},
}); });
const axiosClient = axios.create({}); const axiosClient = axios.create({});
@@ -1,3 +1,4 @@
import axios from 'axios';
import { set } from 'idb-keyval'; import { set } from 'idb-keyval';
import chunk from 'lodash/chunk'; import chunk from 'lodash/chunk';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
import { import {
albumArtistListSortMap, albumArtistListSortMap,
albumListSortMap, albumListSortMap,
DeleteArtistImageArgs,
DeleteArtistImageResponse,
DeletePlaylistImageArgs,
DeletePlaylistImageResponse,
Folder, Folder,
genreListSortMap, genreListSortMap,
ImageArgs, ImageArgs,
@@ -29,6 +34,10 @@ import {
SortOrder, SortOrder,
sortOrderMap, sortOrderMap,
Tag, Tag,
UploadArtistImageArgs,
UploadArtistImageResponse,
UploadPlaylistImageArgs,
UploadPlaylistImageResponse,
} from '/@/shared/types/domain-types'; } from '/@/shared/types/domain-types';
import { ServerFeature } from '/@/shared/types/features-types'; import { ServerFeature } from '/@/shared/types/features-types';
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
return value.join(','); return value.join(',');
}; };
const getImageContentType = (bytes: Uint8Array): string => {
if (bytes[0] === 0x89 && bytes[1] === 0x50) {
return 'image/png';
}
if (bytes[0] === 0xff && bytes[1] === 0xd8) {
return 'image/jpeg';
}
if (bytes[0] === 0x47 && bytes[1] === 0x49) {
return 'image/gif';
}
if (bytes[0] === 0x52 && bytes[1] === 0x49) {
return 'image/webp';
}
return 'image/jpeg';
};
const uint8ArrayToBase64 = (bytes: Uint8Array): string => {
let binary = '';
const chunkSize = 0x8000;
for (let i = 0; i < bytes.length; i += chunkSize) {
const chunk = bytes.subarray(i, i + chunkSize);
binary += String.fromCharCode(...chunk);
}
return btoa(binary);
};
type JellyfinApiClientProps = DeletePlaylistImageArgs['apiClientProps'];
const deleteItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
errorMessage: string,
): Promise<boolean> => {
const res = await jfApiClient({
...apiClientProps,
server: apiClientProps.server ?? null,
}).deleteArtistImage({
params: {
id,
},
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
const uploadItemPrimaryImage = async (
apiClientProps: JellyfinApiClientProps,
id: string,
image: Uint8Array,
errorMessage: string,
): Promise<boolean> => {
const server = apiClientProps.server;
const serverUrl = getServerUrl(server);
if (!serverUrl) {
throw new Error('Server is required');
}
const contentType = getImageContentType(image);
const base64 = uint8ArrayToBase64(image);
const authHeader = createAuthHeader();
const authorization = server?.credential
? authHeader.concat(`, Token="${server.credential}"`)
: authHeader;
const res = await axios.post(`${serverUrl}/Items/${id}/Images/Primary`, base64, {
headers: {
Authorization: authorization,
'Content-Type': contentType,
},
signal: apiClientProps.signal,
});
if (res.status !== 204) {
throw new Error(errorMessage);
}
return true;
};
// Limit the query to 50 at a time to be *extremely* conservative on the // 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 // length of the full URL, since the ids are part of the query string and
// not the POST body // not the POST body
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
[ServerFeature.PUBLIC_PLAYLIST]: [1], [ServerFeature.PUBLIC_PLAYLIST]: [1],
}, },
], ],
['10.0.0', { [ServerFeature.TAGS]: [1] }], [
'10.0.0',
{
[ServerFeature.ARTIST_IMAGE_UPLOAD]: [1],
[ServerFeature.PLAYLIST_IMAGE_UPLOAD]: [1],
[ServerFeature.TAGS]: [1],
},
],
]; ];
const JF_FIELDS = { const JF_FIELDS = {
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
id: res.body.Id, id: res.body.Id,
}; };
}, },
deleteArtistImage: async (args: DeleteArtistImageArgs): Promise<DeleteArtistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete artist image');
},
deleteFavorite: async (args) => { deleteFavorite: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
return null; return null;
}, },
deletePlaylistImage: async (
args: DeletePlaylistImageArgs,
): Promise<DeletePlaylistImageResponse> => {
const { apiClientProps, query } = args;
return deleteItemPrimaryImage(apiClientProps, query.id, 'Failed to delete playlist image');
},
getAlbumArtistDetail: async (args) => { getAlbumArtistDetail: async (args) => {
const { apiClientProps, query } = args; const { apiClientProps, query } = args;
@@ -1847,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
return null; return null;
}, },
uploadArtistImage: async (args: UploadArtistImageArgs): Promise<UploadArtistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload artist image',
);
},
uploadPlaylistImage: async (
args: UploadPlaylistImageArgs,
): Promise<UploadPlaylistImageResponse> => {
const { apiClientProps, body, query } = args;
return uploadItemPrimaryImage(
apiClientProps,
query.id,
body.image,
'Failed to upload playlist image',
);
},
}; };
function getLibraryId(musicFolderId?: string | string[]) { function getLibraryId(musicFolderId?: string | string[]) {
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
playCount: item.UserData?.PlayCount || 0, playCount: item.UserData?.PlayCount || 0,
similarArtists, similarArtists,
songCount: item.SongCount ?? null, songCount: item.SongCount ?? null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
userFavorite: item.UserData?.IsFavorite || false, userFavorite: item.UserData?.IsFavorite || false,
userRating: null, userRating: null,
}; };
@@ -434,6 +435,7 @@ const normalizePlaylist = (
size: null, size: null,
songCount: item?.ChildCount || null, songCount: item?.ChildCount || null,
sync: null, sync: null,
uploadedImage: item.ImageTags?.Primary ?? undefined,
}; };
}; };
+12
View File
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
const deletePlaylist = z.null(); const deletePlaylist = z.null();
const deletePlaylistImage = z.null();
const deleteArtistImage = deletePlaylistImage;
const uploadPlaylistImage = z.null();
const uploadArtistImage = uploadPlaylistImage;
const deletePlaylistParameters = z.object({ const deletePlaylistParameters = z.object({
Id: z.string(), Id: z.string(),
}); });
@@ -886,7 +894,9 @@ export const jfType = {
albumList, albumList,
authenticate, authenticate,
createPlaylist, createPlaylist,
deleteArtistImage,
deletePlaylist, deletePlaylist,
deletePlaylistImage,
error, error,
favorite, favorite,
filters, filters,
@@ -912,6 +922,8 @@ export const jfType = {
studioList, studioList,
topSongsList, topSongsList,
updatePlaylist, updatePlaylist,
uploadArtistImage,
uploadPlaylistImage,
user, user,
}, },
}; };