mirror of
https://github.com/jeffvli/feishin.git
synced 2026-06-12 15:22:35 +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,
|
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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user