mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-10 14:22:46 +02:00
add playlist/artist image upload for jellyfin (#2105)
This commit is contained in:
@@ -54,6 +54,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deleteArtistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deleteArtistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylist: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
@@ -63,6 +72,15 @@ export const contract = c.router({
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
deletePlaylistImage: {
|
||||
body: null,
|
||||
method: 'DELETE',
|
||||
path: 'Items/:id/Images/Primary',
|
||||
responses: {
|
||||
204: jfType._response.deletePlaylistImage,
|
||||
400: jfType._response.error,
|
||||
},
|
||||
},
|
||||
getAlbumArtistDetail: {
|
||||
method: 'GET',
|
||||
path: 'users/:userId/items/:id',
|
||||
@@ -356,6 +374,24 @@ export const contract = c.router({
|
||||
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({});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import { set } from 'idb-keyval';
|
||||
import chunk from 'lodash/chunk';
|
||||
import filter from 'lodash/filter';
|
||||
@@ -13,6 +14,10 @@ import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/ap
|
||||
import {
|
||||
albumArtistListSortMap,
|
||||
albumListSortMap,
|
||||
DeleteArtistImageArgs,
|
||||
DeleteArtistImageResponse,
|
||||
DeletePlaylistImageArgs,
|
||||
DeletePlaylistImageResponse,
|
||||
Folder,
|
||||
genreListSortMap,
|
||||
ImageArgs,
|
||||
@@ -29,6 +34,10 @@ import {
|
||||
SortOrder,
|
||||
sortOrderMap,
|
||||
Tag,
|
||||
UploadArtistImageArgs,
|
||||
UploadArtistImageResponse,
|
||||
UploadPlaylistImageArgs,
|
||||
UploadPlaylistImageResponse,
|
||||
} from '/@/shared/types/domain-types';
|
||||
import { ServerFeature } from '/@/shared/types/features-types';
|
||||
|
||||
@@ -63,6 +72,94 @@ const formatCommaDelimitedString = (value: string[]) => {
|
||||
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
|
||||
// length of the full URL, since the ids are part of the query string and
|
||||
// not the POST body
|
||||
@@ -80,7 +177,14 @@ const VERSION_INFO: VersionInfo = [
|
||||
[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 = {
|
||||
@@ -231,6 +335,11 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
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) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -281,6 +390,13 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
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) => {
|
||||
const { apiClientProps, query } = args;
|
||||
|
||||
@@ -1847,6 +1963,28 @@ export const JellyfinController: InternalControllerEndpoint = {
|
||||
|
||||
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[]) {
|
||||
|
||||
@@ -397,6 +397,7 @@ const normalizeAlbumArtist = (
|
||||
playCount: item.UserData?.PlayCount || 0,
|
||||
similarArtists,
|
||||
songCount: item.SongCount ?? null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
userFavorite: item.UserData?.IsFavorite || false,
|
||||
userRating: null,
|
||||
};
|
||||
@@ -434,6 +435,7 @@ const normalizePlaylist = (
|
||||
size: null,
|
||||
songCount: item?.ChildCount || null,
|
||||
sync: null,
|
||||
uploadedImage: item.ImageTags?.Primary ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -705,6 +705,14 @@ const removeFromPlaylistParameters = z.object({
|
||||
|
||||
const deletePlaylist = z.null();
|
||||
|
||||
const deletePlaylistImage = z.null();
|
||||
|
||||
const deleteArtistImage = deletePlaylistImage;
|
||||
|
||||
const uploadPlaylistImage = z.null();
|
||||
|
||||
const uploadArtistImage = uploadPlaylistImage;
|
||||
|
||||
const deletePlaylistParameters = z.object({
|
||||
Id: z.string(),
|
||||
});
|
||||
@@ -886,7 +894,9 @@ export const jfType = {
|
||||
albumList,
|
||||
authenticate,
|
||||
createPlaylist,
|
||||
deleteArtistImage,
|
||||
deletePlaylist,
|
||||
deletePlaylistImage,
|
||||
error,
|
||||
favorite,
|
||||
filters,
|
||||
@@ -912,6 +922,8 @@ export const jfType = {
|
||||
studioList,
|
||||
topSongsList,
|
||||
updatePlaylist,
|
||||
uploadArtistImage,
|
||||
uploadPlaylistImage,
|
||||
user,
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user