Add image URL generation at runtime to allow for dynamic image sizes (#1439)

* add getImageUrl to domain endpoints

* add new ItemImage component and hooks to generate image url

* add configuration for image resolution based on types
This commit is contained in:
Jeff
2025-12-23 20:18:52 -08:00
committed by GitHub
parent 96f38e597c
commit 25bfb65b6d
39 changed files with 823 additions and 670 deletions
+60 -150
View File
@@ -16,100 +16,6 @@ import { ServerListItem, ServerType } from '/@/shared/types/types';
const TICKS_PER_MS = 10000;
const getAlbumArtistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.albumArtist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getAlbumCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.album>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary && !args.item?.AlbumPrimaryImageTag) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const getSongCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.song>;
size: number;
}) => {
const size = args.size ? args.size : 100;
if (args.item.ImageTags.Primary) {
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96' +
// Invalidate the cache if the image chances. This appears to be
// how Jellyfin Web does it as well
`&tag=${args.item.ImageTags.Primary}`
);
}
if (args.item?.AlbumPrimaryImageTag) {
// Fall back to album art if no image embedded
return (
`${args.baseUrl}/Items` +
`/${args.item?.AlbumId}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
}
return null;
};
const getPlaylistCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.playlist>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
type AlbumOrSong = z.infer<typeof jfType._response.album> | z.infer<typeof jfType._response.song>;
const KEYS_TO_OMIT = new Set(['AlbumArtist', 'Artist']);
@@ -128,6 +34,7 @@ const getPeople = (item: AlbumOrSong): null | Record<string, RelatedArtist[]> =>
// for other roles, we just want to display this and not filter.
// filtering (and links) would require a separate field, PersonIds
id: '',
imageId: null,
imageUrl: null,
name: person.Name,
};
@@ -158,10 +65,47 @@ const getTags = (item: AlbumOrSong): null | Record<string, string[]> => {
return null;
};
const getSongImageId = (item: z.infer<typeof jfType._response.song>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
if (item.AlbumPrimaryImageTag && item.AlbumId) {
return item.AlbumId;
}
return null;
};
const getAlbumImageId = (item: z.infer<typeof jfType._response.album>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const getAlbumArtistImageId = (
item: z.infer<typeof jfType._response.albumArtist>,
): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const getPlaylistImageId = (item: z.infer<typeof jfType._response.playlist>): null | string => {
if (item.ImageTags?.Primary) {
return item.Id;
}
return null;
};
const normalizeSong = (
item: z.infer<typeof jfType._response.song>,
server: null | ServerListItem,
imageSize?: number,
): Song => {
let bitRate = 0;
let channels: null | number = null;
@@ -201,6 +145,7 @@ const normalizeSong = (
album: item.Album,
albumArtists: item.AlbumArtists?.map((entry) => ({
id: entry.Id,
imageId: entry.Id,
imageUrl: null,
name: entry.Name,
})),
@@ -209,6 +154,7 @@ const normalizeSong = (
artists: (item?.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
imageId: entry.Id,
imageUrl: null,
name: entry.Name,
}),
@@ -241,13 +187,14 @@ const normalizeSong = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getSongCoverArtUrl({ baseUrl: server?.url || '', item, size: imageSize || 100 }),
imageId: getSongImageId(item),
imageUrl: null,
lastPlayedAt: null,
lyrics: null,
mbzRecordingId: null,
@@ -273,7 +220,6 @@ const normalizeSong = (
const normalizeAlbum = (
item: z.infer<typeof jfType._response.album>,
server: null | ServerListItem,
imageSize?: number,
): Album => {
return {
_itemType: LibraryItem.ALBUM,
@@ -283,17 +229,18 @@ const normalizeAlbum = (
albumArtists:
item.AlbumArtists.map((entry) => ({
id: entry.Id,
imageId: entry.Id,
imageUrl: null,
name: entry.Name,
})) || [],
artists: (item.ArtistItems?.length ? item.ArtistItems : item.AlbumArtists)?.map(
(entry) => ({
id: entry.Id,
imageId: entry.Id,
imageUrl: null,
name: entry.Name,
}),
),
backdropImageUrl: null,
comment: null,
createdAt: item.DateCreated,
duration: item.RunTimeTicks / TICKS_PER_MS,
@@ -305,17 +252,14 @@ const normalizeAlbum = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})) || [],
id: item.Id,
imagePlaceholderUrl: null,
imageUrl: getAlbumCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
imageId: getAlbumImageId(item),
imageUrl: null,
isCompilation: null,
lastPlayedAt: null,
mbzId: item.ProviderIds?.MusicBrainzAlbum || null,
@@ -329,7 +273,7 @@ const normalizeAlbum = (
releaseYear: item.ProductionYear || null,
size: null,
songCount: item?.ChildCount || null,
songs: item.Songs?.map((song) => normalizeSong(song, server, imageSize)),
songs: item.Songs?.map((song) => normalizeSong(song, server)),
tags: getTags(item),
updatedAt: item?.DateLastMediaAdded || item.DateCreated,
userFavorite: item.UserData?.IsFavorite || false,
@@ -343,17 +287,13 @@ const normalizeAlbumArtist = (
similarArtists?: z.infer<typeof jfType._response.albumArtistList>;
},
server: null | ServerListItem,
imageSize?: number,
): AlbumArtist => {
const similarArtists =
item.similarArtists?.Items?.filter((entry) => entry.Name !== 'Various Artists').map(
(entry) => ({
id: entry.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item: entry,
size: imageSize || 300,
}),
imageId: entry.Id,
imageUrl: null,
name: entry.Name,
}),
) || [];
@@ -363,7 +303,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || '',
_serverType: ServerType.JELLYFIN,
albumCount: item.AlbumCount ?? null,
backgroundImageUrl: null,
biography: item.Overview || null,
duration: item.RunTimeTicks / TICKS_PER_MS,
genres: item.GenreItems?.map((entry) => ({
@@ -372,16 +311,14 @@ const normalizeAlbumArtist = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
imageUrl: getAlbumArtistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
}),
imageId: getAlbumArtistImageId(item),
imageUrl: null,
lastPlayedAt: null,
mbz: item.ProviderIds?.MusicBrainzArtist || null,
name: item.Name,
@@ -396,16 +333,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer<typeof jfType._response.playlist>,
server: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getPlaylistCoverArtUrl({
baseUrl: server?.url || '',
item,
size: imageSize || 300,
});
const imagePlaceholderUrl = null;
return {
_itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || '',
@@ -418,13 +346,14 @@ const normalizePlaylist = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: entry.Id,
imageId: null,
imageUrl: null,
name: entry.Name,
songCount: null,
})),
id: item.Id,
imagePlaceholderUrl,
imageUrl: imageUrl || null,
imageId: getPlaylistImageId(item),
imageUrl: null,
name: item.Name,
owner: null,
ownerId: null,
@@ -463,26 +392,6 @@ const normalizeMusicFolder = (item: z.infer<typeof jfType._response.musicFolder>
// };
// };
const getGenreCoverArtUrl = (args: {
baseUrl: string;
item: z.infer<typeof jfType._response.genre>;
size: number;
}) => {
const size = args.size ? args.size : 300;
if (!args.item.ImageTags?.Primary) {
return null;
}
return (
`${args.baseUrl}/Items` +
`/${args.item.Id}` +
'/Images/Primary' +
`?width=${size}` +
'&quality=96'
);
};
const normalizeGenre = (
item: z.infer<typeof jfType._response.genre>,
server: null | ServerListItem,
@@ -493,7 +402,8 @@ const normalizeGenre = (
_serverType: ServerType.JELLYFIN,
albumCount: null,
id: item.Id,
imageUrl: getGenreCoverArtUrl({ baseUrl: server?.url || '', item, size: 200 }),
imageId: item.Id,
imageUrl: null,
name: item.Name,
songCount: null,
};
+19 -81
View File
@@ -24,32 +24,6 @@ const getImageUrl = (args: { url: null | string }) => {
return url;
};
const getCoverArtUrl = (args: {
baseUrl: string | undefined;
coverArtId: string;
credential: string | undefined;
size: number;
updated: string;
}) => {
const size = args.size ? args.size : 250;
if (!args.coverArtId || args.coverArtId.match('2a96cbd8b46e442fc41c2b86b821562f')) {
return null;
}
return (
`${args.baseUrl}/rest/getCoverArt.view` +
`?id=${args.coverArtId}` +
`&${args.credential}` +
'&v=1.13.0' +
'&c=Feishin' +
`&size=${size}` +
// A dummy variable to invalidate the cached image if the item is updated
// This is adapted from how Navidrome web does it
`&_=${args.updated}`
);
};
interface WithDate {
playDate?: string;
}
@@ -74,6 +48,7 @@ const getArtists = (
if (role === 'albumartist' || role === 'artist') {
const roleList = list.map((item) => ({
id: item.id,
imageId: null,
imageUrl: null,
name: item.name,
}));
@@ -89,6 +64,7 @@ const getArtists = (
for (const artist of list) {
const item: RelatedArtist = {
id: artist.id,
imageId: null,
imageUrl: null,
name: artist.name,
};
@@ -112,11 +88,13 @@ const getArtists = (
}
if (albumArtists === undefined) {
albumArtists = [{ id: item.albumArtistId, imageUrl: null, name: item.albumArtist }];
albumArtists = [
{ id: item.albumArtistId, imageId: null, imageUrl: null, name: item.albumArtist },
];
}
if (artists === undefined) {
artists = [{ id: item.artistId, imageUrl: null, name: item.artist }];
artists = [{ id: item.artistId, imageId: null, imageUrl: null, name: item.artist }];
}
return { albumArtists, artists, participants };
@@ -125,7 +103,6 @@ const getArtists = (
const normalizeSong = (
item: z.infer<typeof ndType._response.playlistSong> | z.infer<typeof ndType._response.song>,
server?: null | ServerListItem,
imageSize?: number,
): Song => {
let id;
let playlistItemId;
@@ -138,15 +115,6 @@ const normalizeSong = (
id = item.id;
}
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: id,
credential: server?.credential,
size: imageSize || 100,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
return {
album: item.album,
albumId: item.albumId,
@@ -182,13 +150,14 @@ const normalizeSong = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id,
imagePlaceholderUrl,
imageUrl,
imageId: item.id,
imageUrl: null,
lastPlayedAt: normalizePlayDate(item),
lyrics: item.lyrics ? item.lyrics : null,
mbzRecordingId: item.mbzReleaseTrackId || null,
@@ -261,20 +230,7 @@ const normalizeAlbum = (
songs?: z.infer<typeof ndType._response.songList>;
},
server?: null | ServerListItem,
imageSize?: number,
): Album => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.coverArtId || item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
const imageBackdropUrl = imageUrl?.replace(/size=\d+/, 'size=1000') || null;
return {
...parseAlbumTags(item),
...getArtists(item),
@@ -282,7 +238,6 @@ const normalizeAlbum = (
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumArtist: item.albumArtist,
backdropImageUrl: imageBackdropUrl,
comment: item.comment || null,
createdAt: item.createdAt,
duration: item.duration !== undefined ? item.duration * 1000 : null,
@@ -298,13 +253,14 @@ const normalizeAlbum = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id: item.id,
imagePlaceholderUrl,
imageUrl,
imageId: item.coverArtId || item.id,
imageUrl: null,
isCompilation: item.compilation,
lastPlayedAt: normalizePlayDate(item),
mbzId: item.mbzAlbumId || null,
@@ -329,17 +285,7 @@ const normalizeAlbumArtist = (
},
server?: null | ServerListItem,
): AlbumArtist => {
let imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
if (!imageUrl) {
imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: `ar-${item.id}`,
credential: server?.credential,
size: 300,
updated: item.updatedAt || '',
});
}
const imageUrl = getImageUrl({ url: item?.largeImageUrl || null });
let albumCount: number;
let songCount: number;
@@ -363,7 +309,6 @@ const normalizeAlbumArtist = (
_serverId: server?.id || 'unknown',
_serverType: ServerType.NAVIDROME,
albumCount,
backgroundImageUrl: null,
biography: item.biography || null,
duration: null,
genres: (item.genres || []).map((genre) => ({
@@ -372,11 +317,13 @@ const normalizeAlbumArtist = (
_serverType: ServerType.NAVIDROME,
albumCount: null,
id: genre.id,
imageId: null,
imageUrl: null,
name: genre.name,
songCount: null,
})),
id: item.id,
imageId: item.id,
imageUrl: imageUrl || null,
lastPlayedAt: normalizePlayDate(item),
mbz: item.mbzArtistId || null,
@@ -385,6 +332,7 @@ const normalizeAlbumArtist = (
similarArtists:
item.similarArtists?.map((artist) => ({
id: artist.id,
imageId: null,
imageUrl: artist?.artistImageUrl || null,
name: artist.name,
})) || null,
@@ -397,18 +345,7 @@ const normalizeAlbumArtist = (
const normalizePlaylist = (
item: z.infer<typeof ndType._response.playlist>,
server?: null | ServerListItem,
imageSize?: number,
): Playlist => {
const imageUrl = getCoverArtUrl({
baseUrl: server?.url,
coverArtId: item.id,
credential: server?.credential,
size: imageSize || 300,
updated: item.updatedAt,
});
const imagePlaceholderUrl = null;
return {
_itemType: LibraryItem.PLAYLIST,
_serverId: server?.id || 'unknown',
@@ -417,8 +354,8 @@ const normalizePlaylist = (
duration: item.duration * 1000,
genres: [],
id: item.id,
imagePlaceholderUrl,
imageUrl,
imageId: item.id,
imageUrl: null,
name: item.name,
owner: item.ownerName,
ownerId: item.ownerId,
@@ -440,6 +377,7 @@ const normalizeGenre = (
_serverType: ServerType.NAVIDROME,
albumCount: item.albumCount ?? null,
id: item.id,
imageId: null,
imageUrl: null,
name: item.name,
songCount: item.songCount ?? null,
+10 -10
View File
@@ -15,16 +15,7 @@ import { Icon } from '/@/shared/components/icon/icon';
import { Skeleton } from '/@/shared/components/skeleton/skeleton';
import { useInViewport } from '/@/shared/hooks/use-in-viewport';
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
enableAnimation?: boolean;
}
interface ImageLoaderProps {
className?: string;
}
interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
export interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
containerClassName?: string;
enableAnimation?: boolean;
imageContainerProps?: Omit<ImageContainerProps, 'children'>;
@@ -34,6 +25,15 @@ interface ImageProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, 'src'> {
thumbHash?: string;
}
interface ImageContainerProps extends HTMLAttributes<HTMLDivElement> {
children: ReactNode;
enableAnimation?: boolean;
}
interface ImageLoaderProps {
className?: string;
}
interface ImageUnloaderProps {
className?: string;
}
+18 -5
View File
@@ -174,14 +174,13 @@ export type Album = {
albumArtist: string;
albumArtists: RelatedArtist[];
artists: RelatedArtist[];
backdropImageUrl: null | string;
comment: null | string;
createdAt: string;
duration: null | number;
explicitStatus: ExplicitStatus | null;
genres: Genre[];
id: string;
imagePlaceholderUrl: null | string;
imageId: null | string;
imageUrl: null | string;
isCompilation: boolean | null;
lastPlayedAt: null | string;
@@ -209,11 +208,11 @@ export type AlbumArtist = {
_serverId: string;
_serverType: ServerType;
albumCount: null | number;
backgroundImageUrl: null | string;
biography: null | string;
duration: null | number;
genres: Genre[];
id: string;
imageId: null | string;
imageUrl: null | string;
lastPlayedAt: null | string;
mbz: null | string;
@@ -294,6 +293,7 @@ export type Genre = {
_serverType: ServerType;
albumCount: null | number;
id: string;
imageId: null | string;
imageUrl: null | string;
name: string;
songCount: null | number;
@@ -334,7 +334,7 @@ export type Playlist = {
duration: null | number;
genres: Genre[];
id: string;
imagePlaceholderUrl: null | string;
imageId: null | string;
imageUrl: null | string;
name: string;
owner: null | string;
@@ -353,6 +353,7 @@ export type RelatedAlbumArtist = {
export type RelatedArtist = {
id: string;
imageId: null | string;
imageUrl: null | string;
name: string;
};
@@ -381,7 +382,7 @@ export type Song = {
gain: GainInfo | null;
genres: Genre[];
id: string;
imagePlaceholderUrl: null | string;
imageId: null | string;
imageUrl: null | string;
lastPlayedAt: null | string;
lyrics: null | string;
@@ -1340,6 +1341,7 @@ export type ControllerEndpoint = {
getDownloadUrl: (args: DownloadArgs) => string;
getFolder: (args: FolderArgs) => Promise<FolderResponse>;
getGenreList: (args: GenreListArgs) => Promise<GenreListResponse>;
getImageUrl: (args: ImageArgs) => null | string;
getInternetRadioStations: (
args: GetInternetRadioStationsArgs,
) => Promise<GetInternetRadioStationsResponse>;
@@ -1408,6 +1410,16 @@ export type GetQueueResponse = {
username: string;
};
export type ImageArgs = BaseEndpointArgs & {
query: ImageQuery;
};
export type ImageQuery = {
id: string;
itemType: LibraryItem;
size?: number;
};
export type InternalControllerEndpoint = {
addToPlaylist: (
args: ReplaceApiClientProps<AddToPlaylistArgs>,
@@ -1449,6 +1461,7 @@ export type InternalControllerEndpoint = {
getDownloadUrl: (args: ReplaceApiClientProps<DownloadArgs>) => string;
getFolder: (args: ReplaceApiClientProps<FolderArgs>) => Promise<FolderResponse>;
getGenreList: (args: ReplaceApiClientProps<GenreListArgs>) => Promise<GenreListResponse>;
getImageUrl: (args: ReplaceApiClientProps<ImageArgs>) => null | string;
getInternetRadioStations: (
args: ReplaceApiClientProps<GetInternetRadioStationsArgs>,
) => Promise<GetInternetRadioStationsResponse>;