Include image tags in item image URLs (#2031)

## Description

Wholphin builds item image URLs without Jellyfin's `tag` query
parameter. `ImageUrlService` already receives the item's `imageTags` and
`backdropTags`, and the low-level `getItemImageUrl` supports `tag`, but
no branch passed it. Today only chapter and now-playing images set it.

Before: `/Items/{id}/Images/Primary?quality=96&fillHeight=229`
After:
`/Items/{id}/Images/Primary?quality=96&fillHeight=229&tag={ImageTags.Primary}`

This is how the official Jellyfin clients request images. jellyfin-web
and the official Jellyfin Android TV app both copy `ImageTags[...]` (and
the parent/series tags) into the image URL.

I also added `ImageUrlServiceTest`, which covers each branch.


### Related issues

I tested each one on the emulator against a stock Jellyfin 12.1 server
(no plugins), comparing `main` with this branch:

- **#366 (Image cache expiration): fixed by this PR.**
- **#1249 (blank tile for a newly added movie) and #1338 (episode cover
never refreshes): already fixed on `main` since v1.0.3, not by this
PR.** 
- **#360 (Option to hide spoilers): partly.**

## AI or LLM usage

This change was made with AI assistance (Claude Code). It was used for:
- the code changes in `ImageUrlService` and the call sites listed above
- the new `ImageUrlServiceTest`
- the emulator testing and benchmarks
- drafting this description

The testing is what's described above: unit tests, an Android TV
emulator against Jellyfin 12.1 servers (with Jellyfin Enhanced and
stock), request logs from a proxy, and a check of every tag against the
server's metadata. I've reviewed the change and can explain it.

I'm also a maintainer on Jellyfin Enhanced, which is how I ran into
this. I'm happy to share examples of my own coding work if that helps
with reviewing this.
This commit is contained in:
Jake 2026-10-16 23:28:01 +08:00 • committed by GitHub
parent 3ef221bf91
commit 621686d8e2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 248 additions and 4 deletions

View file

@ -35,7 +35,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = false,
)
@ -50,7 +50,7 @@ data class Person(
name = dto.name,
role = personRole(context, dto.role, dto.type),
type = dto.type,
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY),
imageUrl = api.imageApi.getItemImageUrl(dto.id, ImageType.PRIMARY, tag = dto.primaryImageTag),
favorite = favorite,
)
}

View file

@ -753,6 +753,7 @@ class HomeSettingsService
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.THUMB,
tag = it.imageTags?.get(ImageType.THUMB),
)
BaseItem(
it,

View file

@ -35,6 +35,11 @@ class ImageUrlService
parentBackdropId: UUID? = null,
fillWidth: Int? = null,
fillHeight: Int? = null,
seriesPrimaryTag: String? = null,
parentLogoId: UUID? = null,
parentLogoTag: String? = null,
parentThumbTag: String? = null,
parentBackdropTags: List<String> = emptyList(),
): String? =
when (imageType) {
ImageType.LOGO -> {
@ -44,6 +49,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = parentLogoTag.takeIf { parentLogoId == seriesId },
)
} else {
getItemImageUrl(
@ -51,6 +57,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = imageTags[imageType],
)
}
}
@ -63,6 +70,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = parentBackdropTags.firstOrNull().takeIf { parentBackdropId == seriesId },
)
} else if (backdropTags.isNotEmpty()) {
getItemImageUrl(
@ -70,6 +78,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = backdropTags.first(),
)
} else {
null
@ -86,6 +95,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = parentThumbTag,
)
} else if (useSeriesForPrimary && parentBackdropId != null &&
(itemType == BaseItemKind.EPISODE || itemType == BaseItemKind.SEASON)
@ -96,6 +106,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = parentBackdropTags.firstOrNull(),
)
} else if (parentThumbId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@ -103,6 +114,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = parentThumbTag,
)
} else if (useSeriesForPrimary &&
parentThumbId == null &&
@ -115,6 +127,7 @@ class ImageUrlService
imageType = ImageType.PRIMARY,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = imageTags[ImageType.PRIMARY],
)
} else if (imageType !in imageTags && backdropTags.isNotEmpty()) {
// If no thumb, use backdrop if available
@ -123,6 +136,7 @@ class ImageUrlService
imageType = ImageType.BACKDROP,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = backdropTags.first(),
)
} else {
getItemImageUrl(
@ -130,6 +144,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = imageTags[imageType],
)
}
}
@ -145,6 +160,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else if (seriesId != null && itemType == BaseItemKind.SEASON && imageType !in imageTags) {
getItemImageUrl(
@ -152,6 +168,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = seriesPrimaryTag.takeIf { imageType == ImageType.PRIMARY },
)
} else {
getItemImageUrl(
@ -159,6 +176,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = imageTags[imageType],
)
}
}
@ -169,6 +187,7 @@ class ImageUrlService
imageType = imageType,
fillWidth = fillWidth,
fillHeight = fillHeight,
tag = imageTags[imageType],
)
}
}
@ -193,6 +212,11 @@ class ImageUrlService
backdropTags = item.data.backdropImageTags.orEmpty(),
fillWidth = fillWidth,
fillHeight = fillHeight,
seriesPrimaryTag = item.data.seriesPrimaryImageTag,
parentLogoId = item.data.parentLogoItemId,
parentLogoTag = item.data.parentLogoImageTag,
parentThumbTag = item.data.parentThumbImageTag,
parentBackdropTags = item.data.parentBackdropImageTags.orEmpty(),
)
} else {
null

View file

@ -327,6 +327,7 @@ class MusicService
imageUrlService.getItemImageUrl(
itemId = albumId,
imageType = ImageType.PRIMARY,
tag = audio.data.albumPrimaryImageTag,
)
}
return MediaItem

View file

@ -97,6 +97,7 @@ class StudioViewModel
itemId = it.id,
imageType = ImageType.THUMB,
fillWidth = cardWidthPx,
tag = it.imageTags?.get(ImageType.THUMB),
)
Studio(
id = it.id,

View file

@ -95,7 +95,11 @@ fun rememberLogoUrl(item: BaseItem?): String? {
item.data.seriesId != null &&
item.data.parentLogoImageTag != null
) {
imageUrlService.getItemImageUrl(item.data.seriesId!!, ImageType.LOGO)
imageUrlService.getItemImageUrl(
itemId = item.data.seriesId!!,
imageType = ImageType.LOGO,
tag = item.data.parentLogoImageTag.takeIf { item.data.parentLogoItemId == item.data.seriesId },
)
} else if (ImageType.LOGO in item?.data?.imageTags.orEmpty()) {
imageUrlService.getItemImageUrl(item, ImageType.LOGO)
} else {

View file

@ -257,6 +257,7 @@ fun PersonPage(
imageUrlService.getItemImageUrl(
itemId = person.id,
imageType = ImageType.PRIMARY,
tag = person.data.imageTags?.get(ImageType.PRIMARY),
)
}
// A long filmography is hard to navigate as a row, so it is cut at the same

View file

@ -149,7 +149,11 @@ class LiveTvViewModel
number = it.channelNumber,
name = it.channelName ?: it.name,
imageUrl =
imageUrlService.getItemImageUrl(it.id, ImageType.PRIMARY),
imageUrlService.getItemImageUrl(
itemId = it.id,
imageType = ImageType.PRIMARY,
tag = it.imageTags?.get(ImageType.PRIMARY),
),
favorite = it.userData?.isFavorite == true,
)
}
@ -299,6 +303,7 @@ class LiveTvViewModel
imageUrlService.getItemImageUrl(
dto.id,
ImageType.PRIMARY,
tag = dto.imageTags?.get(ImageType.PRIMARY),
),
)
if (index == 0) {