Spaces:
Paused
Paused
Download api/src/image/serve.rs from stremio-db/a: direct link, hf CLI and curl.
- Browser
- Download file 114 kB
-
https://huggingface.co/spaces/stremio-db/a/resolve/main/api/src/image/serve.rs
- Command line
-
hf download hf://spaces/stremio-db/a/api/src/image/serve.rs
-
curl -L -o serve.rs https://huggingface.co/spaces/stremio-db/a/resolve/main/api/src/image/serve.rs
114 kB
use axum::http::header;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use sha2::{Sha256, Digest};
use std::sync::Arc;
use std::time::Instant;
use crate::cache::{self, MemCacheEntry};
use crate::error::AppError;
use crate::id::{self, IdType, MediaType, format_tmdb_id_value};
use crate::image::generate;
use crate::services::db::{BadgeAppearance, BadgeDirection, BadgePosition, BadgeSize, BadgeStyle, ImageSize, LabelStyle, RenderSettings};
use crate::services::fanart::{FanartClient, FanartImages, FanartPoster, PosterMatch};
use crate::services::lang::lang_base;
use crate::services::ratings;
use crate::AppState;
/// Threshold (ms) above which requests are logged as slow.
const SLOW_REQUEST_MS: u64 = 2000;
/// Logo or backdrop — image types served via TMDB images API (primary) with
/// fanart.tv fallback, or vice versa when the user prefers fanart.
/// Posters are excluded because they use a separate code path (`handle_inner`) with
/// staleness checks and background refresh that these endpoints don't need.
#[derive(Debug, Clone, Copy)]
pub enum LogoBackdropKind {
Logo,
Backdrop,
}
impl From<LogoBackdropKind> for cache::ImageType {
fn from(k: LogoBackdropKind) -> Self {
match k {
LogoBackdropKind::Logo => cache::ImageType::Logo,
LogoBackdropKind::Backdrop => cache::ImageType::Backdrop,
}
}
}
/// Resolved per-type render parameters for logo/backdrop generation.
///
/// For logos, `position` and `badge_direction` are hardcoded (TopRight / Vertical)
/// and not passed to `generate_logo` — only `badge_style`, `label_style`, and
/// `badge_size` are used. They exist here so backdrop callers can read them
/// without a separate code path.
struct LbRenderParams {
position: BadgePosition,
badge_direction: BadgeDirection,
badge_style: BadgeStyle,
label_style: LabelStyle,
badge_size: BadgeSize,
appearance: BadgeAppearance,
/// Backdrop edge insets (percent of dimension). Zero for logos.
edge_inset_x: i32,
edge_inset_y: i32,
}
impl LbRenderParams {
fn from_settings(lb_kind: LogoBackdropKind, settings: &RenderSettings) -> Self {
let position = match lb_kind {
LogoBackdropKind::Logo => BadgePosition::TopRight,
LogoBackdropKind::Backdrop => settings.backdrop_position,
};
let badge_direction = match lb_kind {
LogoBackdropKind::Logo => BadgeDirection::Vertical,
LogoBackdropKind::Backdrop => settings.backdrop_badge_direction.resolve(position),
};
let badge_style = match lb_kind {
LogoBackdropKind::Logo => settings.logo_badge_style,
LogoBackdropKind::Backdrop => settings.backdrop_badge_style,
}
.resolve(badge_direction);
let label_style = match lb_kind {
LogoBackdropKind::Logo => settings.logo_label_style,
LogoBackdropKind::Backdrop => settings.backdrop_label_style,
};
let badge_size = match lb_kind {
LogoBackdropKind::Logo => settings.logo_badge_size,
LogoBackdropKind::Backdrop => settings.backdrop_badge_size,
};
let appearance = match lb_kind {
LogoBackdropKind::Logo => settings.logo_appearance(),
LogoBackdropKind::Backdrop => settings.backdrop_appearance(),
};
// Edge insets are backdrop-only; logos render at a fixed anchor.
let (edge_inset_x, edge_inset_y) = match lb_kind {
LogoBackdropKind::Logo => (0, 0),
LogoBackdropKind::Backdrop => (
crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_x),
crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_y),
),
};
Self { position, badge_direction, badge_style, label_style, badge_size, appearance, edge_inset_x, edge_inset_y }
}
}
/// Returns a cache key suffix for badge position.
pub fn position_cache_suffix(position: &str) -> String {
format!(".p{position}")
}
/// Returns a cache key suffix for badge style.
pub fn badge_style_cache_suffix(style: &str) -> String {
format!(".s{style}")
}
/// Returns a cache key suffix for label style.
pub fn label_style_cache_suffix(style: &str) -> String {
format!(".l{style}")
}
/// Returns a cache key suffix for badge direction.
pub fn badge_direction_cache_suffix(dir: &str) -> String {
format!(".d{dir}")
}
/// Returns a cache key suffix for badge shape.
pub fn badge_shape_cache_suffix(shape: &str) -> String {
format!(".sh{shape}")
}
/// Returns a cache key suffix for badge background.
pub fn badge_background_cache_suffix(background: &str) -> String {
format!(".bg{background}")
}
/// Returns a cache key suffix for the backdrop edge inset.
///
/// Empty when the (position-gated) inset is zero, so the default configuration
/// keeps its existing cache keys — no migration needed. This mirrors the
/// optional `.x1` (poster split) and `.blur` (episode) tokens. Each axis is only
/// tokenized when it actually affects placement: horizontal for left/right
/// positions, vertical for top/bottom positions.
pub fn edge_inset_cache_suffix(position: BadgePosition, inset_x: i32, inset_y: i32) -> String {
// Clamp to the supported range so an out-of-range stored value can't mint a
// cache key (e.g. `.eh80`) that the renderer — which clamps to MAX_EDGE_INSET —
// would never actually produce, splitting the cache for identical output.
let inset_x = crate::services::db::clamp_edge_inset(inset_x);
let inset_y = crate::services::db::clamp_edge_inset(inset_y);
let mut out = String::new();
if (position.is_left() || position.is_right()) && inset_x > 0 {
out.push_str(&format!(".eh{inset_x}"));
}
if (position.is_top() || position.is_bottom()) && inset_y > 0 {
out.push_str(&format!(".ev{inset_y}"));
}
out
}
/// Resolve an optional image size, defaulting to Medium.
pub fn resolve_image_size(size: Option<ImageSize>) -> ImageSize {
size.unwrap_or(ImageSize::Medium)
}
/// Returns a cache key suffix for image size.
pub fn image_size_cache_suffix(size: Option<ImageSize>) -> &'static str {
resolve_image_size(size).cache_suffix()
}
/// Build the cache suffix string from settings for a given image kind.
///
/// Exhaustively destructures `RenderSettings` so adding a field without
/// handling it here produces a compile error.
///
/// Uses `ratings_cache_suffix()` to predict the ratings portion from user
/// settings. For cache keys that reflect *actual* rendered badges, use
/// `settings_cache_suffix_with_ratings()` with a pre-computed ratings suffix.
pub fn settings_cache_suffix(
settings: &RenderSettings,
kind: cache::ImageType,
image_size: Option<ImageSize>,
) -> String {
let ratings_suffix = match kind {
cache::ImageType::Poster => ratings::ratings_cache_suffix(&settings.ratings_order, &settings.ratings_exclude, settings.ratings_limit),
cache::ImageType::Logo => ratings::ratings_cache_suffix(&settings.ratings_order, &settings.ratings_exclude, settings.logo_ratings_limit),
cache::ImageType::Backdrop => ratings::ratings_cache_suffix(&settings.ratings_order, &settings.ratings_exclude, settings.backdrop_ratings_limit),
cache::ImageType::Episode => ratings::ratings_cache_suffix(&settings.ratings_order, &settings.ratings_exclude, settings.episode_ratings_limit),
};
settings_cache_suffix_with_ratings(settings, kind, image_size, &ratings_suffix)
}
/// Build the cache suffix string using a pre-computed ratings suffix.
///
/// This variant accepts the `@xyz` ratings portion directly (e.g. from
/// `badges_cache_suffix()`) so callers can use the *actual* badge sources
/// rather than the predicted ones from user settings.
pub fn settings_cache_suffix_with_ratings(
settings: &RenderSettings,
kind: cache::ImageType,
image_size: Option<ImageSize>,
ratings_suffix: &str,
) -> String {
// Exhaustive destructure ensures new fields trigger a compile error here.
let RenderSettings {
image_source: _, // handled by code path selection, not suffix
lang: _, // handled by variant string, not suffix
textless: _, // handled by variant string, not suffix
ratings_limit: _,
ratings_order: _,
ratings_exclude: _, // folded into the ratings suffix via ratings_cache_suffix
is_default: _, // metadata, not a render setting
poster_position: _,
logo_ratings_limit: _,
backdrop_ratings_limit: _,
poster_badge_style: _,
logo_badge_style: _,
backdrop_badge_style: _,
poster_label_style: _,
logo_label_style: _,
backdrop_label_style: _,
poster_badge_direction: _,
poster_badge_split: _,
poster_fit: _,
poster_badge_size: _,
logo_badge_size: _,
backdrop_badge_size: _,
backdrop_position: _,
backdrop_badge_direction: _,
backdrop_edge_inset_x: _,
backdrop_edge_inset_y: _,
episode_ratings_limit: _,
episode_badge_style: _,
episode_label_style: _,
episode_badge_size: _,
episode_position: _,
episode_badge_direction: _,
episode_blur: _,
poster_badge_shape: _,
logo_badge_shape: _,
backdrop_badge_shape: _,
episode_badge_shape: _,
poster_badge_background: _,
logo_badge_background: _,
backdrop_badge_background: _,
episode_badge_background: _,
} = settings;
let resolved_size = resolve_image_size(image_size);
let is_suffix = resolved_size.cache_suffix();
let rs = ratings_suffix;
match kind {
cache::ImageType::Poster => {
let ps = position_cache_suffix(settings.poster_position.as_str());
// Pills always render horizontally, so normalize the style token to
// match — keeps pill+vertical and pill+horizontal on one cache key.
let bs = badge_style_cache_suffix(settings.poster_badge_style.for_shape(settings.poster_badge_shape).as_str());
let ls = label_style_cache_suffix(settings.poster_label_style.as_str());
let bd = badge_direction_cache_suffix(settings.poster_badge_direction.as_str());
let bsz = settings.poster_badge_size.cache_suffix();
let shp = badge_shape_cache_suffix(settings.poster_badge_shape.as_str());
let bgd = badge_background_cache_suffix(settings.poster_badge_background.as_str());
// Split badges onto opposite sides — only tokenized when enabled, so
// the default (no split) keeps existing cache keys unchanged.
let split = if settings.poster_badge_split { ".x1" } else { "" };
// Poster aspect-ratio fit. `Native` (the default) emits no token, so
// pre-feature poster cache keys — all native — stay valid and the
// default reuses them. Opting into cover/pad/blur emits a token.
let fit = settings.poster_fit.cache_suffix();
// Shape/background sit immediately after badge size (before the
// optional split token) so the v003 cache-key migration can insert
// their defaults with a single uniform rule across all image types.
format!("{rs}{ps}{bs}{ls}{bd}{bsz}{shp}{bgd}{split}{fit}{is_suffix}")
}
cache::ImageType::Logo => {
let bs = badge_style_cache_suffix(settings.logo_badge_style.for_shape(settings.logo_badge_shape).as_str());
let ls = label_style_cache_suffix(settings.logo_label_style.as_str());
let bsz = settings.logo_badge_size.cache_suffix();
let shp = badge_shape_cache_suffix(settings.logo_badge_shape.as_str());
let bgd = badge_background_cache_suffix(settings.logo_badge_background.as_str());
format!("{rs}{bs}{ls}{bsz}{shp}{bgd}{is_suffix}")
}
cache::ImageType::Backdrop => {
let ps = position_cache_suffix(settings.backdrop_position.as_str());
let bs = badge_style_cache_suffix(settings.backdrop_badge_style.for_shape(settings.backdrop_badge_shape).as_str());
let ls = label_style_cache_suffix(settings.backdrop_label_style.as_str());
let bd = badge_direction_cache_suffix(settings.backdrop_badge_direction.as_str());
let bsz = settings.backdrop_badge_size.cache_suffix();
let shp = badge_shape_cache_suffix(settings.backdrop_badge_shape.as_str());
let bgd = badge_background_cache_suffix(settings.backdrop_badge_background.as_str());
// Edge inset sits after background (before image size) and is only
// present when non-zero, so default backdrop keys are unchanged.
let ei = edge_inset_cache_suffix(settings.backdrop_position, settings.backdrop_edge_inset_x, settings.backdrop_edge_inset_y);
format!("{rs}{ps}{bs}{ls}{bd}{bsz}{shp}{bgd}{ei}{is_suffix}")
}
cache::ImageType::Episode => {
let ps = position_cache_suffix(settings.episode_position.as_str());
let bs = badge_style_cache_suffix(settings.episode_badge_style.for_shape(settings.episode_badge_shape).as_str());
let ls = label_style_cache_suffix(settings.episode_label_style.as_str());
let bd = badge_direction_cache_suffix(settings.episode_badge_direction.as_str());
let bsz = settings.episode_badge_size.cache_suffix();
let shp = badge_shape_cache_suffix(settings.episode_badge_shape.as_str());
let bgd = badge_background_cache_suffix(settings.episode_badge_background.as_str());
let blur = if settings.episode_blur { ".blur" } else { "" };
format!("{rs}{ps}{bs}{ls}{bd}{bsz}{shp}{bgd}{blur}{is_suffix}")
}
}
}
/// Compute a stable 12-hex-char settings hash for CDN content-addressed URLs.
/// Two users with identical effective settings for the same image type produce
/// the same hash, enabling Cloudflare cache deduplication.
///
/// **Important:** Every field that affects rendered output must be included here.
/// When adding new settings, add them to the hash to prevent CDN cache collisions.
pub fn settings_hash(settings: &RenderSettings, kind: cache::ImageType, image_size: Option<ImageSize>) -> String {
let mut hasher = Sha256::new();
hasher.update(kind.label().as_bytes());
hasher.update(b"\0");
// Render-affecting settings (via exhaustive destructure in settings_cache_suffix)
hasher.update(settings_cache_suffix(settings, kind, image_size).as_bytes());
hasher.update(b"\0");
// `ratings_exclude` independently: the predicted ratings suffix collapses an
// excluded source that sits beyond the limit in the all-sources-present
// prediction, but such configs still render differently for titles with
// partial source availability. Hash a canonical exclude token so two configs
// differing only in ratings_exclude never share a CDN URL.
hasher.update(ratings::exclude_cache_token(&settings.ratings_exclude).as_bytes());
hasher.update(b"\0");
// Source-selection settings (not in cache suffix because handled by code path/variant)
hasher.update(settings.image_source.as_str().as_bytes());
hasher.update(b"\0");
hasher.update(settings.lang.as_bytes());
hasher.update(b"\0");
hasher.update(if settings.textless { b"1" } else { b"0" });
let hash = hasher.finalize();
format!(
"{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
hash[0], hash[1], hash[2], hash[3], hash[4], hash[5], hash[6], hash[7],
hash[8], hash[9], hash[10], hash[11], hash[12], hash[13], hash[14], hash[15]
)
}
/// 302 redirect response for CDN content-addressed URLs.
/// Compute `max-age` for CDN responses based on film age.
///
/// New/unreleased films change frequently (ratings settling) → short TTL.
/// Old films are stable → long TTL. Maps the same release-date logic used
/// for internal cache staleness to CDN-appropriate durations:
///
/// - Unreleased / unknown release date → 1 day
/// - Just released → 1 day
/// - Linearly scales up to 1 year as film age approaches `max_age_secs`
/// - Older than `max_age_secs` → 1 year
pub fn compute_cdn_max_age(release_date: Option<&str>, min_stale_secs: u64, max_age_secs: u64) -> u64 {
let stale = cache::compute_stale_secs(release_date, min_stale_secs, max_age_secs);
if stale == 0 {
// Film older than max_age — ratings are stable, cache for 1 year
365 * 24 * 3600
} else {
// Use the staleness interval as the CDN TTL — the image won't be
// regenerated before then anyway, so it's safe to cache that long.
stale
}
}
/// `public` lets the CDN cache the redirect at the edge so it can be served
/// during origin downtime. The cache is keyed by the full URL (which includes
/// the API key), so one user's redirect is never served to another.
/// `stale-while-revalidate` allows the edge to keep serving the cached redirect
/// while the origin is unreachable.
pub fn cdn_redirect_response(location: &str) -> Response {
(
StatusCode::FOUND,
[
(header::LOCATION, location),
(header::CACHE_CONTROL, "public, max-age=300, stale-while-revalidate=3600"),
],
)
.into_response()
}
/// Image response with dynamic CDN cache TTL for content-addressed `/c/` routes.
pub fn cdn_image_response(bytes: Bytes, max_age: u64, content_type: &'static str) -> Response {
let swr = max_age.saturating_mul(7);
let cache_control = format!("public, max-age={max_age}, stale-while-revalidate={swr}");
(
[
(header::CONTENT_TYPE, header::HeaderValue::from_static(content_type)),
// SAFETY: the format string above only produces ASCII digits and commas.
(header::CACHE_CONTROL, header::HeaderValue::from_str(&cache_control).unwrap()),
],
bytes,
)
.into_response()
}
/// Read available rating sources for a movie, checking the in-memory cache
/// before falling through to SQLite.
async fn read_available_ratings_cached(state: &AppState, id_key: &str) -> Option<String> {
let db = state.db.clone();
let min_stale = state.config.ratings_min_stale_secs;
let max_age = state.config.ratings_max_age_secs;
let id_key_owned = id_key.to_owned();
state
.available_ratings_cache
.try_get_with(id_key.to_string(), async move {
Ok::<_, std::convert::Infallible>(
cache::read_available_ratings(&db, &id_key_owned, min_stale, max_age).await,
)
})
.await
.unwrap_or(None)
}
/// Persist available sources to SQLite and update the in-memory cache.
async fn upsert_available_ratings_cached(
state: &AppState,
id_key: &str,
sources: &str,
release_date: Option<&str>,
) {
if let Err(e) = cache::upsert_available_ratings(&state.db, id_key, sources, release_date).await {
tracing::warn!(error = %e, key = %id_key, "available_ratings upsert failed");
}
// Update the in-memory cache so subsequent requests don't hit SQLite
state
.available_ratings_cache
.insert(id_key.to_string(), Some(sources.to_string()))
.await;
}
/// Resolve an ID and fetch ratings.
///
/// When `uplift_episodes` is true and the ID resolves to an episode, the
/// episode is transparently re-resolved to its parent series *before*
/// fetching ratings. This avoids wasted episode-level TMDB calls when the
/// caller only needs series-level data (poster, logo, backdrop endpoints).
///
/// When `skip_ratings` is true, the external ratings fetch (OMDB, MDBList,
/// TMDB) is skipped entirely and an empty `RatingsResult` is returned.
/// Use this when the caller's ratings limit is 0, fetching ratings only
/// to discard them wastes rate-limit quota and stalls image delivery.
async fn resolve_with_ratings(
state: &AppState,
id_type: IdType,
id_value: &str,
uplift_episodes: bool,
skip_ratings: bool,
) -> Result<(id::ResolvedId, ratings::RatingsResult, CrossIdInfo), AppError> {
let id_resolve_start = Instant::now();
let mut resolved = id::resolve(id_type, id_value, &state.tmdb, &state.id_cache, &state.db).await?;
// When an episode ID hits a non-episode endpoint, re-resolve to the
// parent series so ratings and assets are series-level.
if uplift_episodes && resolved.media_type == MediaType::Episode {
if let Some(ref ep) = resolved.episode {
let series_id = id::format_tmdb_id_value(ep.show_tmdb_id, &MediaType::Tv, None);
resolved = id::resolve(IdType::Tmdb, &series_id, &state.tmdb, &state.id_cache, &state.db).await?;
}
}
let id_resolve_ms = id_resolve_start.elapsed().as_millis() as u64;
let ratings_result = if skip_ratings {
ratings::RatingsResult::default()
} else {
ratings::fetch_ratings(
&resolved,
&state.tmdb,
state.omdb.as_ref(),
state.mdblist.as_ref(),
state.trakt.as_ref(),
&state.ratings_cache,
&state.db,
)
.await?
};
if id_resolve_ms > SLOW_REQUEST_MS {
tracing::warn!(
id = %id_value,
id_resolve_ms,
"slow id resolution"
);
}
let cross_ids = CrossIdInfo::from_resolved(&resolved, &ratings_result);
Ok((resolved, ratings_result, cross_ids))
}
/// IDs available for cross-ID cache population, built from the resolved ID
/// with optional backfill from MDBList ratings response.
#[derive(Clone)]
struct CrossIdInfo {
imdb_id: Option<String>,
tmdb_id: u64,
tvdb_id: Option<u64>,
media_type: MediaType,
release_date: Option<String>,
episode: Option<id::EpisodeInfo>,
}
impl CrossIdInfo {
/// Build from a resolved ID, merging in any extra IDs from the ratings response.
fn from_resolved(resolved: &id::ResolvedId, ratings: &ratings::RatingsResult) -> Self {
Self {
imdb_id: resolved.imdb_id.clone().or_else(|| ratings.imdb_id.clone()),
tmdb_id: resolved.tmdb_id,
tvdb_id: resolved.tvdb_id.or(ratings.tvdb_id),
media_type: resolved.media_type,
release_date: resolved.release_date.clone(),
episode: resolved.episode.clone(),
}
}
}
/// Spawn a background task to write cache entries for all alternate IDs.
/// Uses `CrossIdInfo` to determine alternate ID paths.
/// All writes are best-effort — errors are logged but not propagated.
/// Does NOT populate memory cache; alternate keys get promoted on first actual request.
fn spawn_cross_id_cache(
state: &AppState,
cross_ids: CrossIdInfo,
id_type: IdType,
cache_suffix: String,
image_type: cache::ImageType,
bytes: Bytes,
key_id: Option<i32>,
) {
let permit = match state.cross_id_semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
tracing::debug!("cross-id cache skipped: semaphore full");
return;
}
};
let state = state.clone();
tokio::spawn(async move {
let _permit = permit;
// Build list of (id_type_str, id_value) for alternate IDs
let mut alternates: Vec<(&str, String)> = Vec::new();
if let Some(ref imdb_id) = cross_ids.imdb_id {
if id_type != IdType::Imdb {
alternates.push(("imdb", imdb_id.clone()));
}
}
{
let tmdb_val = format_tmdb_id_value(cross_ids.tmdb_id, &cross_ids.media_type, cross_ids.episode.as_ref());
if id_type != IdType::Tmdb {
alternates.push(("tmdb", tmdb_val));
}
}
if let Some(tvdb_id) = cross_ids.tvdb_id {
if id_type != IdType::Tvdb {
alternates.push(("tvdb", tvdb_id.to_string()));
}
}
let mut set = tokio::task::JoinSet::new();
for (alt_type, alt_value) in &alternates {
let cache_value = format!("{alt_value}{cache_suffix}");
let alt_cache_path = match cache::typed_cache_path(&state.config.cache_dir, image_type, alt_type, &cache_value) {
Ok(p) => p,
Err(e) => {
tracing::warn!(error = %e, alt_type, alt_value, "cross-id cache path failed");
continue;
}
};
let alt_cache_key = format!("{alt_type}/{alt_value}{cache_suffix}");
let state = state.clone();
let bytes = bytes.clone();
let release_date = cross_ids.release_date.clone();
let external_cache_only = state.config.external_cache_only;
set.spawn(async move {
if !external_cache_only {
if let Err(e) = cache::write(&alt_cache_path, &bytes).await {
tracing::warn!(error = %e, key = %alt_cache_key, "cross-id cache write failed");
}
}
if let Err(e) = cache::upsert_meta_db(&state.db, &alt_cache_key, release_date.as_deref(), image_type, key_id).await {
tracing::warn!(error = %e, key = %alt_cache_key, "cross-id meta write failed");
}
});
}
while let Some(result) = set.join_next().await {
if let Err(e) = result {
tracing::error!(error = %e, "cross-id cache task panicked");
}
}
});
}
/// Write a freshly rendered image to disk cache, update the meta DB, and
/// distribute to cross-ID cache entries. Returns the final `Bytes`.
///
/// Cache write failures are logged but do not fail the request — the image is
/// already rendered and should still be served to the caller.
async fn post_render_cache(
state: &AppState,
rendered: Vec<u8>,
cache_path: &std::path::Path,
cache_key: &str,
release_date: Option<&str>,
image_type: cache::ImageType,
cross_ids: CrossIdInfo,
id_type: IdType,
cache_suffix: String,
key_id: Option<i32>,
) -> Bytes {
if !state.config.external_cache_only {
if let Err(e) = cache::write(cache_path, &rendered).await {
tracing::warn!(cache_key, error = %e, "failed to write image to disk cache");
}
}
tracing::warn!(cache_key, key_id = ?key_id, "post_render_cache: about to upsert meta DB");
match cache::upsert_meta_db(&state.db, cache_key, release_date, image_type, key_id).await {
Ok(()) => tracing::warn!(cache_key, "post_render_cache: upsert meta DB succeeded"),
Err(e) => tracing::warn!(cache_key, error = %e, "failed to upsert cache meta DB"),
}
let bytes = Bytes::from(rendered);
spawn_cross_id_cache(state, cross_ids, id_type, cache_suffix, image_type, bytes.clone(), key_id);
bytes
}
/// Check in-memory and filesystem caches for a cached image, triggering a
/// background refresh when the entry is stale. Returns `Ok(Some(bytes))` on
/// cache hit, `Ok(None)` on miss.
///
/// `on_stale` is called when a stale entry is found — it should spawn a
/// background refresh task.
async fn check_caches(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
on_stale: impl Fn(&AppState, &str, &std::path::Path),
) -> Result<Option<Bytes>, AppError> {
// Check in-memory cache
if let Some(entry) = state.image_mem_cache.get(cache_key).await {
if !state.config.external_cache_only
&& entry.last_checked.elapsed() >= std::time::Duration::from_secs(60)
{
let release_date = cache::read_meta_db(&state.db, cache_key).await;
let stale_secs = cache::compute_stale_secs(
release_date.as_deref(),
state.config.ratings_min_stale_secs,
state.config.ratings_max_age_secs,
);
if let Some(fs_entry) = cache::read(cache_path, stale_secs).await
&& fs_entry.is_stale
{
on_stale(state, cache_key, cache_path);
}
state
.image_mem_cache
.insert(
cache_key.to_string(),
MemCacheEntry {
bytes: entry.bytes.clone(),
last_checked: Instant::now(),
},
)
.await;
}
return Ok(Some(entry.bytes.clone()));
}
// No filesystem cache when external_cache_only — no files or metadata on disk
if state.config.external_cache_only {
return Ok(None);
}
// Check filesystem cache
let release_date = cache::read_meta_db(&state.db, cache_key).await;
let stale_secs = cache::compute_stale_secs(
release_date.as_deref(),
state.config.ratings_min_stale_secs,
state.config.ratings_max_age_secs,
);
if let Some(entry) = cache::read(cache_path, stale_secs).await {
if entry.is_stale {
on_stale(state, cache_key, cache_path);
}
let bytes: Bytes = entry.bytes.into();
state
.image_mem_cache
.insert(
cache_key.to_string(),
MemCacheEntry {
bytes: bytes.clone(),
last_checked: Instant::now(),
},
)
.await;
return Ok(Some(bytes));
}
Ok(None)
}
pub async fn handle_inner(
state: &AppState,
id_type_str: &str,
id_value_jpg: &str,
mut settings: RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) -> Result<(Bytes, Option<String>), AppError> {
let request_start = Instant::now();
let id_type = IdType::parse(id_type_str)?;
let id_value = id_value_jpg.strip_suffix(".jpg").unwrap_or(id_value_jpg);
// Reject path traversal, null bytes, etc. before any network calls
cache::validate_id_value(id_value)?;
// Resolve "default" badge direction and style early, before cache key construction
settings.poster_badge_direction = settings.poster_badge_direction.resolve(settings.poster_position);
settings.poster_badge_style = settings.poster_badge_style.resolve(settings.poster_badge_direction);
let use_fanart = settings.image_source.is_fanart();
let id_key = format!("{id_type_str}/{id_value}");
let variant = tmdb_poster_variant(&settings.lang, settings.textless);
// Fast path (non-fanart): try to reconstruct the cache key from SQLite-stored
// available sources, avoiding external API calls entirely on cache hits.
if !use_fanart {
let fast_path_start = Instant::now();
// When ratings are disabled the suffix is always "@" regardless of which
// sources a title actually has, skip the SQLite lookup entirely.
let fast_path_available = if settings.ratings_limit == 0 {
Some(String::new())
} else {
read_available_ratings_cached(state, &id_key).await
};
if let Some(available) = fast_path_available {
let available_ratings_ms = fast_path_start.elapsed().as_millis() as u64;
let ratings_suffix = ratings::badges_suffix_from_available(&available, &settings.ratings_order, &settings.ratings_exclude, settings.ratings_limit);
let suffix = settings_cache_suffix_with_ratings(&settings, cache::ImageType::Poster, image_size, &ratings_suffix);
let cache_value = format!("{id_value}{variant}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, cache::ImageType::Poster, id_type_str, &cache_value)?;
let cache_key = format!("{id_type_str}/{cache_value}");
let cache_suffix: Arc<str> = suffix.into();
{
let id_value = id_value.to_string();
let cache_suffix = cache_suffix.clone();
let settings = settings.clone();
let cache_check_start = Instant::now();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_background_refresh(s, k, p, id_type, &id_value, &cache_suffix, &settings, image_size, key_id);
}).await? {
let cache_check_ms = cache_check_start.elapsed().as_millis() as u64;
let meta_start = Instant::now();
let release_date = cache::read_meta_db(&state.db, &cache_key).await;
let meta_ms = meta_start.elapsed().as_millis() as u64;
let total_ms = request_start.elapsed().as_millis() as u64;
if total_ms > SLOW_REQUEST_MS {
tracing::warn!(
id = %id_key,
total_ms,
available_ratings_ms,
cache_check_ms,
meta_db_ms = meta_ms,
"slow poster request (fast path hit)"
);
}
return Ok((bytes, release_date));
}
}
let total_fast_path_ms = fast_path_start.elapsed().as_millis() as u64;
if total_fast_path_ms > SLOW_REQUEST_MS {
tracing::warn!(
id = %id_key,
total_fast_path_ms,
available_ratings_ms,
"slow fast path — cache miss, falling to slow path"
);
}
}
}
// Slow path: resolve ID and fetch ratings (moka-cached, so still fast on
// repeat requests within the 30-min TTL). Episodes are uplifted to their
// parent series — the poster endpoint returns series-level assets.
let slow_path_start = Instant::now();
let resolve_start = Instant::now();
let skip_ratings = settings.ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(state, id_type, id_value, true, skip_ratings).await?;
let resolve_ms = resolve_start.elapsed().as_millis() as u64;
// Persist available sources for future fast-path lookups (always write,
// even with external_cache_only — this is an optimization index, not a
// disk cache, and the fast path depends on it).
// Skip when ratings are disabled: we have no real source data to store,
// and writing "" would erase any previously cached good data.
if !skip_ratings {
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(state, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
// Fanart → TMDB fallback strategy:
//
// 1. If the user's image_source is fanart, try fanart first. On hit, return.
//
// 2. On fanart miss, fall through to TMDB. The user's badge/position settings
// are preserved — only the image source changes.
if use_fanart {
if let Some(bytes) = try_fanart_path(state, id_type_str, id_value, id_type, &resolved, &ratings_result, &cross_ids, &settings, image_size, key_id).await? {
return Ok((bytes, cross_ids.release_date));
}
}
// TMDB path (default, or fanart fallback)
let settings = &settings;
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, settings.ratings_limit);
let ratings_suffix = ratings::badges_cache_suffix(&badges);
let suffix = settings_cache_suffix_with_ratings(settings, cache::ImageType::Poster, image_size, &ratings_suffix);
let cache_value = format!("{id_value}{variant}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, cache::ImageType::Poster, id_type_str, &cache_value)?;
let cache_key = format!("{id_type_str}/{cache_value}");
// Check caches (memory → filesystem)
let cache_suffix: Arc<str> = suffix.into();
let release_date = cross_ids.release_date.clone();
{
let id_type = id_type;
let id_value = id_value.to_string();
let cache_suffix = cache_suffix.clone();
let settings = settings.clone();
let slow_cache_check_start = Instant::now();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_background_refresh(s, k, p, id_type, &id_value, &cache_suffix, &settings, image_size, key_id);
}).await? {
let total_ms = request_start.elapsed().as_millis() as u64;
if total_ms > SLOW_REQUEST_MS {
tracing::warn!(
id = %id_key,
total_ms,
resolve_ms,
cache_check_ms = slow_cache_check_start.elapsed().as_millis() as u64,
"slow poster request (slow path cache hit)"
);
}
return Ok((bytes, release_date));
}
}
// Request coalescing — concurrent requests for the same poster share one generation
let generate_start = Instant::now();
let state2 = state.clone();
let cache_key2 = cache_key.clone();
let cache_path2 = cache_path.clone();
let settings2 = settings.clone();
let bytes: Bytes = state
.image_inflight
.try_get_with(cache_key.clone(), async move {
let (rendered, rd, _used_fanart, gen_cross_ids) =
generate_poster_with_source(&state2, &resolved, badges, &cross_ids, &settings2, image_size, use_fanart).await?;
let bytes = post_render_cache(&state2, rendered, &cache_path2, &cache_key2, rd.as_deref(), cache::ImageType::Poster, gen_cross_ids, id_type, cache_suffix.to_string(), key_id).await;
Ok::<_, AppError>(bytes)
})
.await
.map_err(AppError::from_cached)?;
let total_ms = request_start.elapsed().as_millis() as u64;
if total_ms > SLOW_REQUEST_MS {
tracing::warn!(
id = %id_key,
total_ms,
resolve_ms,
generate_ms = generate_start.elapsed().as_millis() as u64,
slow_path_ms = slow_path_start.elapsed().as_millis() as u64,
"slow poster request (generated)"
);
}
// Insert into memory cache
state
.image_mem_cache
.insert(
cache_key,
MemCacheEntry {
bytes: bytes.clone(),
last_checked: Instant::now(),
},
)
.await;
Ok((bytes, release_date))
}
/// Check a single fanart cache variant (memory → filesystem).
/// Returns `Ok(Some(bytes))` on cache hit, `Ok(None)` on miss.
async fn check_fanart_cache_variant(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
id_type: IdType,
id_value: &str,
cache_suffix: &str,
settings: &RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) -> Result<Option<Bytes>, AppError> {
let id_value = id_value.to_string();
let cache_suffix = cache_suffix.to_string();
let settings = settings.clone();
check_caches(state, cache_key, cache_path, |s, k, p| {
trigger_background_refresh(s, k, p, id_type, &id_value, &cache_suffix, &settings, image_size, key_id);
}).await
}
/// Build a fanart cache key and filesystem path from a variant suffix (e.g. "_f_tl").
fn fanart_variant_paths(
cache_dir: &str,
id_type_str: &str,
id_value: &str,
variant: &str,
suffix: &str,
) -> Result<(String, std::path::PathBuf), AppError> {
let cache_key = format!("{id_type_str}/{id_value}{variant}{suffix}");
let cache_path_base = format!("{id_value}{variant}{suffix}");
let cache_path = cache::typed_cache_path(cache_dir, cache::ImageType::Poster, id_type_str, &cache_path_base)?;
Ok((cache_key, cache_path))
}
/// Try to serve a poster from the fanart cache (memory → filesystem → fresh generation).
/// Returns `Ok(Some(bytes))` on hit, `Ok(None)` to fall through to TMDB, or `Err` on hard failure.
///
/// Accepts pre-resolved data from the caller to avoid duplicate resolve+fetch work.
async fn try_fanart_path(
state: &AppState,
id_type_str: &str,
id_value: &str,
id_type: IdType,
resolved: &id::ResolvedId,
ratings_result: &ratings::RatingsResult,
cross_ids: &CrossIdInfo,
settings: &RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) -> Result<Option<Bytes>, AppError> {
// Build the list of cache variants to check.
// When textless is requested but we know it's unavailable (negative cache),
// skip the textless key and go straight to language.
let neg_key = format!("{id_type_str}/{id_value}_f_tl_neg");
let textless_known_missing = settings.textless
&& state.fanart_negative.get(&neg_key).await.is_some();
let lang_variant = format!("_f_{}", settings.lang);
let lang_neg_key = format!("{id_type_str}/{id_value}_f_{}_neg", settings.lang);
let lang_known_missing = state.fanart_negative.get(&lang_neg_key).await.is_some();
// All fanart variants are known-missing — skip generation and fall through to TMDB
if lang_known_missing && (!settings.textless || textless_known_missing) {
return Ok(None);
}
let badges = ratings::apply_rating_preferences(ratings_result.badges.clone(), &settings.ratings_order, &settings.ratings_exclude, settings.ratings_limit);
let ratings_suffix = ratings::badges_cache_suffix(&badges);
// Compute settings suffix once for all fanart variants
let suffix = settings_cache_suffix_with_ratings(settings, cache::ImageType::Poster, image_size, &ratings_suffix);
// Check cached variants (textless first if requested, then language)
let mut variants_to_check: Vec<String> = Vec::new();
if settings.textless && !textless_known_missing {
variants_to_check.push("_f_tl".to_string());
}
if !lang_known_missing {
variants_to_check.push(lang_variant.clone());
}
for variant in &variants_to_check {
let (cache_key, cache_path) =
fanart_variant_paths(&state.config.cache_dir, id_type_str, id_value, variant, &suffix)?;
let variant_cache_suffix = format!("{variant}{suffix}");
if let Some(bytes) =
check_fanart_cache_variant(state, &cache_key, &cache_path, id_type, id_value, &variant_cache_suffix, settings, image_size, key_id).await?
{
return Ok(Some(bytes));
}
}
// No cache hit — generate with fanart. Cache under the key matching the actual
// tier used (textless vs language). If no fanart match, fall through to TMDB.
let result = generate_poster_with_source(state, resolved, badges, cross_ids, settings, image_size, false).await;
match result {
Ok((bytes, rd, Some(tier), cross_ids)) => {
if settings.textless && tier == PosterMatch::Language {
state.fanart_negative.insert(neg_key, ()).await;
}
let actual_variant = match tier {
PosterMatch::Textless => "_f_tl".to_string(),
PosterMatch::Language => format!("_f_{}", settings.lang),
};
let (cache_key, cache_path) =
fanart_variant_paths(&state.config.cache_dir, id_type_str, id_value, &actual_variant, &suffix)?;
let fanart_cache_suffix = format!("{actual_variant}{suffix}");
let bytes = post_render_cache(state, bytes, &cache_path, &cache_key, rd.as_deref(), cache::ImageType::Poster, cross_ids, id_type, fanart_cache_suffix, key_id).await;
state
.image_mem_cache
.insert(
cache_key,
MemCacheEntry {
bytes: bytes.clone(),
last_checked: Instant::now(),
},
)
.await;
Ok(Some(bytes))
}
Ok((_bytes, _rd, None, _cross_ids)) => {
if settings.textless {
state.fanart_negative.insert(neg_key, ()).await;
}
state.fanart_negative.insert(lang_neg_key, ()).await;
Ok(None)
}
Err(e) => {
tracing::warn!(error = %e, "fanart generation failed, falling through to TMDB");
Ok(None)
}
}
}
/// Spawn a background refresh task. The `generate` future produces
/// `(image_bytes, release_date, image_type, cross_id_info)` on success.
/// `cross_id` optionally provides (id_type, cache_suffix) for cross-ID cache writes.
fn spawn_background_refresh<F>(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
cross_id: Option<(IdType, String)>,
key_id: Option<i32>,
generate: F,
)
where
F: std::future::Future<Output = Result<(Vec<u8>, Option<String>, cache::ImageType, CrossIdInfo), AppError>>
+ Send
+ 'static,
{
if state.refresh_locks.contains_key(cache_key) {
return;
}
state.refresh_locks.insert(cache_key.to_string(), ());
let state = state.clone();
let cache_path = cache_path.to_path_buf();
let cache_key = cache_key.to_string();
tokio::spawn(async move {
tracing::info!(key = %cache_key, "background refresh started");
match generate.await {
Ok((bytes, rd, image_type, cross_ids)) => {
if !state.config.external_cache_only {
if let Err(e) = cache::write(&cache_path, &bytes).await {
tracing::error!(error = %e, "failed to write cache");
}
}
if let Err(e) =
cache::upsert_meta_db(&state.db, &cache_key, rd.as_deref(), image_type, key_id).await
{
tracing::error!(error = %e, "failed to write meta to db");
}
let bytes = Bytes::from(bytes);
if let Some((id_type, suffix)) = cross_id {
spawn_cross_id_cache(&state, cross_ids, id_type, suffix, image_type, bytes.clone(), key_id);
}
state
.image_mem_cache
.insert(
cache_key.clone(),
MemCacheEntry {
bytes,
last_checked: Instant::now(),
},
)
.await;
}
Err(e) => {
tracing::error!(error = %e, "background refresh failed");
}
}
state.refresh_locks.invalidate(&cache_key);
});
}
fn trigger_background_refresh(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
id_type: IdType,
id_value: &str,
cache_suffix: &str,
settings: &RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) {
let state2 = state.clone();
let id_value = id_value.to_string();
let settings = settings.clone();
let cross_id = Some((id_type, cache_suffix.to_string()));
spawn_background_refresh(state, cache_key, cache_path, cross_id, key_id, async move {
let skip_ratings = settings.ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(&state2, id_type, &id_value, true, skip_ratings).await?;
if !skip_ratings {
let id_key = format!("{}/{id_value}", id_type.as_str());
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(&state2, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, settings.ratings_limit);
let (bytes, rd, _tier, cross_ids) =
generate_poster_with_source(&state2, &resolved, badges, &cross_ids, &settings, image_size, false).await?;
Ok((bytes, rd, cache::ImageType::Poster, cross_ids))
});
}
fn trigger_logo_backdrop_refresh(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
id_type: IdType,
id_value: &str,
cache_suffix: &str,
settings: &RenderSettings,
lb_kind: LogoBackdropKind,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) {
let state2 = state.clone();
let id_value = id_value.to_string();
let settings = settings.clone();
let cross_id = Some((id_type, cache_suffix.to_string()));
spawn_background_refresh(state, cache_key, cache_path, cross_id, key_id, async move {
let fanart = state2.fanart.as_ref();
let kind: cache::ImageType = lb_kind.into();
let lang = match kind {
cache::ImageType::Backdrop => "",
_ => &settings.lang,
};
// Logos/backdrops never use textless
let textless = false;
let type_ratings_limit = match lb_kind {
LogoBackdropKind::Logo => settings.logo_ratings_limit,
LogoBackdropKind::Backdrop => settings.backdrop_ratings_limit,
};
let skip_ratings = type_ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(&state2, id_type, &id_value, true, skip_ratings).await?;
if !skip_ratings {
let id_key = format!("{}/{id_value}", id_type.as_str());
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(&state2, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, type_ratings_limit);
let label = kind.label();
let not_found = || AppError::IdNotFound(format!("no {label} available"));
// Use the same primary/fallback pattern as the generation path
let fanart_is_primary = settings.image_source.is_fanart() && fanart.is_some();
let image_bytes = if fanart_is_primary {
// Fanart primary → TMDB fallback
let primary = if let Some(fc) = fanart {
fetch_fanart_image(fc, &state2.tmdb, &state2.fanart_cache, &resolved, lang, textless, kind, &state2.config.cache_dir, state2.config.external_cache_only).await.map(|r| r.bytes)
} else {
None
};
match primary {
Some(b) => b,
None => try_tmdb_logo_backdrop(&state2, &resolved, kind, lang, textless, image_size)
.await
.ok_or_else(not_found)?,
}
} else {
// TMDB primary → Fanart fallback
let primary = try_tmdb_logo_backdrop(&state2, &resolved, kind, lang, textless, image_size).await;
match primary {
Some(b) => b,
None => {
if let Some(fc) = fanart {
fetch_fanart_image(fc, &state2.tmdb, &state2.fanart_cache, &resolved, lang, textless, kind, &state2.config.cache_dir, state2.config.external_cache_only)
.await
.map(|r| r.bytes)
.ok_or_else(not_found)?
} else {
return Err(not_found());
}
}
}
};
let image_type: cache::ImageType = lb_kind.into();
let params = LbRenderParams::from_settings(lb_kind, &settings);
let resolved_size = resolve_image_size(image_size);
let badge_size_factor = params.badge_size.scale_factor();
let (target_width, badge_scale) = match lb_kind {
LogoBackdropKind::Logo => (
resolved_size.logo_target_width(),
resolved_size.badge_scale(cache::ImageType::Logo) * badge_size_factor,
),
LogoBackdropKind::Backdrop => (
resolved_size.backdrop_target_width(),
resolved_size.badge_scale(cache::ImageType::Backdrop) * badge_size_factor,
),
};
let bytes = match lb_kind {
LogoBackdropKind::Logo => generate::generate_logo(image_bytes, badges, state2.font.clone(), params.badge_style, params.label_style, params.appearance, state2.render_semaphore.clone(), target_width, badge_scale).await?,
LogoBackdropKind::Backdrop => generate::generate_backdrop(image_bytes, badges, state2.font.clone(), state2.config.image_quality, params.position, params.badge_style, params.label_style, params.appearance, params.badge_direction, state2.render_semaphore.clone(), target_width, badge_scale, params.badge_size, params.edge_inset_x, params.edge_inset_y).await?,
};
Ok((bytes, cross_ids.release_date.clone(), image_type, cross_ids))
});
}
/// Fetch the base image for an episode and render rating badges on top.
///
/// Shared by `handle_episode_inner` (request path) and `trigger_episode_refresh`
/// (background stale-cache refresh) so that sizing, badge-scale, TMDB-fetch, and
/// render logic live in exactly one place.
async fn generate_episode(
state: &AppState,
resolved: &id::ResolvedId,
badges: Vec<ratings::RatingBadge>,
settings: &RenderSettings,
image_size: Option<ImageSize>,
) -> Result<Vec<u8>, AppError> {
let poster_path = resolved
.poster_path
.as_deref()
.ok_or_else(|| AppError::IdNotFound(format!("no image available for episode (tmdb:{})", resolved.tmdb_id)))?;
let resolved_size = resolve_image_size(image_size);
let is_episode_still = resolved.episode.as_ref().map_or(false, |ep| ep.still_path.is_some());
let target_width = if is_episode_still {
resolved_size.episode_target_width()
} else {
resolved_size.poster_target_width()
};
let scale_image_type = if is_episode_still { cache::ImageType::Episode } else { cache::ImageType::Poster };
let badge_scale = resolved_size.badge_scale(scale_image_type) * settings.episode_badge_size.scale_factor();
let tmdb_size: Arc<str> = resolved_size.tmdb_size().into();
let image_bytes = if state.config.external_cache_only {
state.tmdb.fetch_poster_bytes(poster_path, &tmdb_size).await?
} else {
let poster_cache = cache::base_poster_path(&state.config.cache_dir, poster_path, &tmdb_size)?;
if let Some(entry) = cache::read(&poster_cache, state.config.image_stale_secs).await {
entry.bytes
} else {
let bytes = state.tmdb.fetch_poster_bytes(poster_path, &tmdb_size).await?;
cache::write(&poster_cache, &bytes).await?;
bytes
}
};
let font = state.font.clone();
let quality = state.config.image_quality;
let position = settings.episode_position;
let badge_style = settings.episode_badge_style;
let label_style = settings.episode_label_style;
let badge_appearance = settings.episode_appearance();
let badge_direction = settings.episode_badge_direction;
let episode_badge_size = settings.episode_badge_size;
let blur = settings.episode_blur;
let render_semaphore = state.render_semaphore.clone();
let _permit = render_semaphore.acquire().await
.map_err(|_| AppError::Other("render queue closed".into()))?;
let rendered = tokio::task::spawn_blocking(move || {
generate::render_episode_sync(&image_bytes, &badges, &font, quality, position, badge_style, label_style, badge_appearance, badge_direction, target_width, badge_scale, episode_badge_size, blur)
})
.await
.map_err(|e| AppError::Other(e.to_string()))??;
Ok(rendered)
}
fn trigger_episode_refresh(
state: &AppState,
cache_key: &str,
cache_path: &std::path::Path,
id_type: IdType,
id_value: &str,
cache_suffix: &str,
settings: &RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) {
let state2 = state.clone();
let id_value = id_value.to_string();
let settings = settings.clone();
let cross_id = Some((id_type, cache_suffix.to_string()));
spawn_background_refresh(state, cache_key, cache_path, cross_id, key_id, async move {
let skip_ratings = settings.episode_ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(&state2, id_type, &id_value, false, skip_ratings).await?;
if !skip_ratings {
let id_key = format!("{}/{id_value}", id_type.as_str());
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(&state2, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, settings.episode_ratings_limit);
let bytes = generate_episode(&state2, &resolved, badges, &settings, image_size).await?;
Ok((bytes, cross_ids.release_date.clone(), cache::ImageType::Episode, cross_ids))
});
}
/// Returns (poster_bytes, release_date, fanart_match_tier, cross_id_info)
///
/// Accepts pre-fetched `ResolvedId`, badges, and `CrossIdInfo` so that callers
/// who already resolved/fetched ratings (for cache key construction) don't
/// duplicate that work.
async fn generate_poster_with_source(
state: &AppState,
resolved: &id::ResolvedId,
badges: Vec<ratings::RatingBadge>,
cross_ids: &CrossIdInfo,
settings: &RenderSettings,
image_size: Option<ImageSize>,
fanart_already_tried: bool,
) -> Result<(Vec<u8>, Option<String>, Option<PosterMatch>, CrossIdInfo), AppError> {
let default_poster_path = resolved
.poster_path
.as_deref()
.ok_or_else(|| {
let id_desc = resolved.imdb_id.as_deref()
.unwrap_or_else(|| "unknown");
AppError::IdNotFound(format!("no poster available for {id_desc} / tmdb:{} (TMDB has no poster_path)", resolved.tmdb_id))
})?;
// Unified fallback chain for posters:
// TMDB preferred: TMDB(lang) → Fanart(lang) → TMDB(default)
// Fanart preferred: Fanart(lang) → TMDB(lang) → TMDB(default)
// The default case (lang=en, textless=false) skips lang-specific lookups
// entirely — `resolved.poster_path` is already the TMDB(default).
let is_default = &*settings.lang == "en" && !settings.textless;
let try_fanart = || async {
if fanart_already_tried {
return None;
}
if let Some(ref fanart) = state.fanart {
fetch_fanart_image(
fanart,
&state.tmdb,
&state.fanart_cache,
resolved,
&settings.lang,
settings.textless,
cache::ImageType::Poster,
&state.config.cache_dir,
state.config.external_cache_only,
)
.await
} else {
None
}
};
let (lang_poster_path, fanart_result) = if settings.image_source.is_fanart() {
// Fanart(lang) → TMDB(lang) → TMDB(default)
let fr = try_fanart().await;
let tp = if fr.is_none() && !is_default {
resolve_tmdb_poster_path(state, resolved, &settings.lang, settings.textless).await
} else {
None
};
(tp, fr)
} else {
// TMDB(lang) → Fanart(lang) → TMDB(default)
let tp = if !is_default {
resolve_tmdb_poster_path(state, resolved, &settings.lang, settings.textless).await
} else {
None
};
let fr = if tp.is_none() && !is_default {
try_fanart().await
} else {
None
};
(tp, fr)
};
// When both None → poster_path = default_poster_path → generate_poster fetches TMDB(default)
let poster_path = lang_poster_path.as_deref().unwrap_or(default_poster_path);
let match_tier = fanart_result.as_ref().map(|r| r.match_tier);
let fanart_bytes = fanart_result.map(|r| r.bytes);
let resolved_size = resolve_image_size(image_size);
let target_width = resolved_size.poster_target_width();
let badge_scale = resolved_size.badge_scale(cache::ImageType::Poster) * settings.poster_badge_size.scale_factor();
let tmdb_size: Arc<str> = resolved_size.tmdb_size().into();
let bytes = generate::generate_poster(generate::ImageParams {
poster_path,
badges: &badges,
tmdb: &state.tmdb,
font: &state.font,
quality: state.config.image_quality,
cache_dir: &state.config.cache_dir,
image_stale_secs: state.config.image_stale_secs,
poster_bytes_override: fanart_bytes,
poster_position: settings.poster_position,
badge_style: settings.poster_badge_style,
label_style: settings.poster_label_style,
badge_appearance: settings.poster_appearance(),
badge_direction: settings.poster_badge_direction,
poster_badge_split: settings.poster_badge_split,
poster_fit: settings.poster_fit,
render_semaphore: state.render_semaphore.clone(),
target_width,
badge_scale,
badge_size: settings.poster_badge_size,
tmdb_size,
external_cache_only: state.config.external_cache_only,
})
.await?;
Ok((bytes, cross_ids.release_date.clone(), match_tier, cross_ids.clone()))
}
/// Result of a fanart poster fetch, indicating what tier matched.
struct FanartResult {
bytes: Vec<u8>,
match_tier: PosterMatch,
}
async fn resolve_tvdb_id(
tmdb: &crate::services::tmdb::TmdbClient,
tmdb_id: u64,
) -> Option<u64> {
#[derive(serde::Deserialize)]
struct TvExternalIds {
tvdb_id: Option<u64>,
}
#[derive(serde::Deserialize)]
struct TvExtIds {
external_ids: Option<TvExternalIds>,
}
let ext: Result<TvExtIds, _> = tmdb
.get(
&format!("/tv/{tmdb_id}"),
&[("append_to_response", "external_ids")],
)
.await;
ext.ok().and_then(|e| e.external_ids).and_then(|e| e.tvdb_id)
}
/// Fetch all fanart images (cached) and return the appropriate list for the given kind.
async fn fetch_fanart_images(
fanart: &FanartClient,
tmdb: &crate::services::tmdb::TmdbClient,
cache: &moka::future::Cache<String, Arc<FanartImages>>,
resolved: &id::ResolvedId,
) -> Option<Arc<FanartImages>> {
let (cache_key, images_result) = match resolved.media_type {
MediaType::Movie => {
let key = format!("movie:{}", resolved.tmdb_id);
let fanart = fanart.clone();
let tmdb_id = resolved.tmdb_id;
let images = cache
.try_get_with(key.clone(), async move {
let imgs = fanart.get_movie_images(tmdb_id).await?;
Ok::<_, AppError>(Arc::new(imgs))
})
.await;
(key, images)
}
// Episode included for exhaustiveness — episodes use `handle_episode_inner`
// which does not call fanart functions.
MediaType::Tv | MediaType::Episode => {
let tv_id = match resolved.tvdb_id {
Some(id) => id,
None => resolve_tvdb_id(tmdb, resolved.tmdb_id).await.unwrap_or(resolved.tmdb_id),
};
let key = format!("tv:{tv_id}");
let fanart = fanart.clone();
let images = cache
.try_get_with(key.clone(), async move {
let imgs = fanart.get_tv_images(tv_id).await?;
Ok::<_, AppError>(Arc::new(imgs))
})
.await;
(key, images)
}
};
match images_result {
Ok(imgs) => Some(imgs),
Err(e) => {
tracing::warn!(error = %e, key = %cache_key, "failed to fetch fanart images");
None
}
}
}
fn select_images_for_kind(images: &FanartImages, kind: cache::ImageType) -> &[FanartPoster] {
match kind {
// Episode included for exhaustiveness — episodes don't use fanart.
cache::ImageType::Poster | cache::ImageType::Episode => &images.posters,
cache::ImageType::Logo => &images.logos,
cache::ImageType::Backdrop => &images.backdrops,
}
}
async fn fetch_fanart_image(
fanart: &FanartClient,
tmdb: &crate::services::tmdb::TmdbClient,
cache: &moka::future::Cache<String, Arc<FanartImages>>,
resolved: &id::ResolvedId,
lang: &str,
textless: bool,
kind: cache::ImageType,
cache_dir: &str,
external_cache_only: bool,
) -> Option<FanartResult> {
let images = fetch_fanart_images(fanart, tmdb, cache, resolved).await?;
let candidates = select_images_for_kind(&images, kind);
let (selected, match_tier) = FanartClient::select_image(candidates, lang, textless)?;
let url = selected.url.clone();
let fanart_id = selected.id.clone();
// Try to serve from base fanart cache
let ext = kind.ext();
let base_path = cache::base_fanart_path(cache_dir, &fanart_id, ext).ok()?;
let bytes = match cache::read(&base_path, 0).await {
Some(entry) => entry.bytes,
None => {
match fanart.fetch_poster_bytes(&url).await {
Ok(fresh) => {
if !external_cache_only {
let _ = cache::write(&base_path, &fresh).await;
}
fresh
}
Err(e) => {
tracing::warn!(error = %e, url = %url, "failed to download fanart image");
return None;
}
}
}
};
Some(FanartResult { bytes, match_tier })
}
pub fn image_response(bytes: Bytes, content_type: &'static str) -> Response {
(
[
(header::CONTENT_TYPE, content_type),
(
header::CACHE_CONTROL,
"public, max-age=3600, stale-while-revalidate=86400",
),
],
bytes,
)
.into_response()
}
/// Build a cache key variant for TMDB poster language/textless combinations.
///
/// The default case (`lang=en, textless=false`) returns an empty string for
/// backward compatibility — existing cache keys are unchanged.
fn tmdb_poster_variant(lang: &str, textless: bool) -> String {
match (lang == "en", textless) {
(true, false) => String::new(),
(true, true) => "_t_tl".into(),
(false, false) => format!("_t_{lang}"),
(false, true) => format!("_t_{lang}_tl"),
}
}
/// Fetch TMDB images metadata (cached) and select the best candidate for the
/// given image type, language, and textless preference.
async fn get_tmdb_images_cached(
state: &AppState,
resolved: &id::ResolvedId,
lang: &str,
) -> Option<std::sync::Arc<crate::services::tmdb::TmdbImagesResponse>> {
let media_type_str = match resolved.media_type {
MediaType::Movie => "movie",
// Episode included for exhaustiveness — episodes are served by
// `handle_episode_inner`, which does not use TMDB images API lookups.
MediaType::Tv | MediaType::Episode => "tv",
};
let cache_key = format!("{}:{}:{}", media_type_str, resolved.tmdb_id, lang_base(lang));
let tmdb = state.tmdb.clone();
let tmdb_id = resolved.tmdb_id;
let lang_owned = lang.to_string();
let mt = media_type_str.to_string();
match state
.tmdb_images_cache
.try_get_with(cache_key, async move {
let imgs = tmdb.get_images(&mt, tmdb_id, &lang_owned).await?;
Ok::<_, AppError>(std::sync::Arc::new(imgs))
})
.await
{
Ok(imgs) => Some(imgs),
Err(e) => {
tracing::warn!(tmdb_id = resolved.tmdb_id, %e, "TMDB images API request failed");
None
}
}
}
/// Try to resolve a language-specific poster path via TMDB's images API.
///
/// Returns `Some(file_path)` on hit, `None` on miss (caller falls back to
/// `resolved.poster_path`).
async fn resolve_tmdb_poster_path(
state: &AppState,
resolved: &id::ResolvedId,
lang: &str,
textless: bool,
) -> Option<String> {
let tmdb_images = get_tmdb_images_cached(state, resolved, lang).await?;
let selected = crate::services::tmdb::TmdbClient::select_poster(&tmdb_images.posters, lang, textless)?;
Some(selected.file_path.clone())
}
/// Try to fetch a logo or backdrop image from TMDB's images API.
/// Returns `Some(bytes)` on success, `None` on miss.
async fn try_tmdb_logo_backdrop(
state: &AppState,
resolved: &id::ResolvedId,
kind: cache::ImageType,
lang: &str,
textless: bool,
image_size: Option<ImageSize>,
) -> Option<Vec<u8>> {
let tmdb_images = get_tmdb_images_cached(state, resolved, lang).await?;
let candidates = match kind {
cache::ImageType::Logo => &tmdb_images.logos,
cache::ImageType::Backdrop => &tmdb_images.backdrops,
// Episode included for exhaustiveness — episodes don't use TMDB images API.
cache::ImageType::Poster | cache::ImageType::Episode => &tmdb_images.posters,
};
let selected = crate::services::tmdb::TmdbClient::select_image(candidates, lang, textless)?;
let size = resolve_image_size(image_size).tmdb_size();
state.tmdb.fetch_image_bytes(&selected.file_path, size).await.ok()
}
/// Try to fetch an image from fanart.tv, updating negative caches on miss.
/// Returns `Some(bytes)` on hit, `None` on miss.
async fn try_fanart_with_negative_cache(
fanart_client: &FanartClient,
state: &AppState,
resolved: &id::ResolvedId,
lang: &str,
textless: bool,
kind: cache::ImageType,
neg_textless_key: &str,
neg_lang_key: &str,
) -> Option<Vec<u8>> {
let result = fetch_fanart_image(
fanart_client,
&state.tmdb,
&state.fanart_cache,
resolved,
lang,
textless,
kind,
&state.config.cache_dir,
state.config.external_cache_only,
).await;
match result {
Some(r) => {
if textless && r.match_tier == PosterMatch::Language {
state.fanart_negative.insert(neg_textless_key.to_string(), ()).await;
}
Some(r.bytes)
}
None => {
if textless {
state.fanart_negative.insert(neg_textless_key.to_string(), ()).await;
}
state.fanart_negative.insert(neg_lang_key.to_string(), ()).await;
None
}
}
}
/// Serve an episode image with episode-specific settings (position, direction, blur).
/// Uses the poster pipeline for ID resolution and ratings, but renders with episode
/// settings and landscape-aware target width.
pub async fn handle_episode_inner(
state: &AppState,
id_type_str: &str,
id_value_jpg: &str,
mut settings: RenderSettings,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) -> Result<(Bytes, Option<String>), AppError> {
let id_type = id::IdType::parse(id_type_str)?;
let id_value = id_value_jpg.strip_suffix(".jpg").unwrap_or(id_value_jpg);
cache::validate_id_value(id_value)?;
// Resolve episode-specific defaults
settings.episode_badge_direction = settings.episode_badge_direction.resolve(settings.episode_position);
settings.episode_badge_style = settings.episode_badge_style.resolve(settings.episode_badge_direction);
let id_key = format!("{id_type_str}/{id_value}");
// Fast path: try to reconstruct the cache key from SQLite-stored available
// sources, avoiding external API calls entirely on cache hits.
// When ratings are disabled the suffix is always "@", skip the SQLite lookup.
let fast_path_available = if settings.episode_ratings_limit == 0 {
Some(String::new())
} else {
read_available_ratings_cached(state, &id_key).await
};
if let Some(available) = fast_path_available {
let ratings_suffix = ratings::badges_suffix_from_available(&available, &settings.ratings_order, &settings.ratings_exclude, settings.episode_ratings_limit);
let suffix = settings_cache_suffix_with_ratings(&settings, cache::ImageType::Episode, image_size, &ratings_suffix);
let cache_value = format!("{id_value}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, cache::ImageType::Episode, id_type_str, &cache_value)?;
let cache_key = format!("{id_type_str}/{cache_value}");
let cache_suffix: Arc<str> = suffix.into();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_episode_refresh(s, k, p, id_type, id_value, &cache_suffix, &settings, image_size, key_id);
}).await? {
let release_date = cache::read_meta_db(&state.db, &cache_key).await;
return Ok((bytes, release_date));
}
}
// Slow path: resolve ID and fetch ratings (no episode uplift — this IS the episode endpoint)
let skip_ratings = settings.episode_ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(state, id_type, id_value, false, skip_ratings).await?;
// The episode endpoint only serves episodes — reject movies and series.
if resolved.media_type != MediaType::Episode {
return Err(AppError::BadRequest(format!(
"{id_value} is a {}, not an episode — use the /{}-default/ endpoint instead",
match resolved.media_type {
MediaType::Movie => "movie",
MediaType::Tv => "series",
MediaType::Episode => unreachable!(),
},
match resolved.media_type {
MediaType::Movie => "poster",
MediaType::Tv => "poster",
MediaType::Episode => unreachable!(),
},
)));
}
// Persist available sources for fast-path lookups.
if !skip_ratings {
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(state, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, settings.episode_ratings_limit);
let ratings_suffix = ratings::badges_cache_suffix(&badges);
let suffix = settings_cache_suffix_with_ratings(&settings, cache::ImageType::Episode, image_size, &ratings_suffix);
let cache_value = format!("{id_value}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, cache::ImageType::Episode, id_type_str, &cache_value)?;
let cache_key = format!("{id_type_str}/{cache_value}");
// Check caches (memory → filesystem) — may hit now if fast-path suffix
// differed from actual badges (e.g. a rating source appeared/disappeared).
let cache_suffix: Arc<str> = suffix.into();
let release_date = cross_ids.release_date.clone();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_episode_refresh(s, k, p, id_type, id_value, &cache_suffix, &settings, image_size, key_id);
}).await? {
return Ok((bytes, release_date));
}
// Request coalescing — concurrent requests for the same episode share one generation
let rd = release_date.clone();
let state2 = state.clone();
let cache_path2 = cache_path.clone();
let cache_key2 = cache_key.clone();
let settings2 = settings.clone();
let bytes: Bytes = state
.image_inflight
.try_get_with(cache_key.clone(), async move {
let rendered = generate_episode(&state2, &resolved, badges, &settings2, image_size).await?;
let bytes = post_render_cache(&state2, rendered, &cache_path2, &cache_key2, rd.as_deref(), cache::ImageType::Episode, cross_ids, id_type, cache_suffix.to_string(), key_id).await;
Ok::<_, AppError>(bytes)
})
.await
.map_err(AppError::from_cached)?;
state
.image_mem_cache
.insert(cache_key, MemCacheEntry { bytes: bytes.clone(), last_checked: Instant::now() })
.await;
Ok((bytes, release_date))
}
/// Serve a logo or backdrop image. Tries TMDB primary with fanart fallback (default),
/// or fanart primary with TMDB fallback when image_source is Fanart.
/// Handles caching and negative-cache lookups.
pub async fn handle_logo_backdrop_inner(
state: &AppState,
id_type_str: &str,
id_value_raw: &str,
settings: &RenderSettings,
lb_kind: LogoBackdropKind,
image_size: Option<ImageSize>,
key_id: Option<i32>,
) -> Result<(Bytes, Option<String>), AppError> {
let fanart = state.fanart.as_ref();
let kind: cache::ImageType = lb_kind.into();
let id_type = IdType::parse(id_type_str)?;
let id_value = kind.strip_ext(id_value_raw);
cache::validate_id_value(id_value)?;
let kind_prefix = kind.kind_prefix();
let label = kind.label();
// Use per-type rating limit for logos/backdrops
let type_ratings_limit = match lb_kind {
LogoBackdropKind::Logo => settings.logo_ratings_limit,
LogoBackdropKind::Backdrop => settings.backdrop_ratings_limit,
};
let params = LbRenderParams::from_settings(lb_kind, settings);
let type_badge_style = params.badge_style;
let type_label_style = params.label_style;
// Backdrops are language-agnostic (no text) — skip lang/textless entirely.
// Logos ARE the text — textless makes no sense, only lang matters.
let fanart_lang = match kind {
cache::ImageType::Backdrop => "",
_ => &settings.lang,
};
// Logos/backdrops never use textless — logos ARE text, backdrops have none.
let fanart_textless = false;
let neg_textless_key = format!("{id_type_str}/{id_value}{kind_prefix}_f_tl_neg");
let textless_known_missing = fanart_textless
&& state.fanart_negative.get(&neg_textless_key).await.is_some();
let neg_lang_key = format!("{id_type_str}/{id_value}{kind_prefix}_f_{}_neg", fanart_lang);
let lang_known_missing = state.fanart_negative.get(&neg_lang_key).await.is_some();
// When all fanart variants are known-missing, skip the fanart API call in
// the generation path. We still try TMDB as a fallback rather than
// returning 404 immediately.
let skip_fanart = lang_known_missing && (!fanart_textless || textless_known_missing);
let source_prefix = if settings.image_source.is_fanart() { "_f" } else { "_t" };
let variant = match kind {
cache::ImageType::Backdrop => format!("{kind_prefix}{source_prefix}"),
cache::ImageType::Logo => format!("{kind_prefix}{source_prefix}_{fanart_lang}"),
cache::ImageType::Poster | cache::ImageType::Episode => return Err(AppError::Other("handle_logo_backdrop_inner only handles logos and backdrops".into())),
};
let image_type: cache::ImageType = lb_kind.into();
let id_key = format!("{id_type_str}/{id_value}");
// Fast path: try to reconstruct the cache key from SQLite-stored available
// sources, avoiding external API calls on cache hits.
// When ratings are disabled the suffix is always "@", skip the SQLite lookup.
let fast_path_available = if type_ratings_limit == 0 {
Some(String::new())
} else {
read_available_ratings_cached(state, &id_key).await
};
if let Some(available) = fast_path_available {
let ratings_suffix = ratings::badges_suffix_from_available(&available, &settings.ratings_order, &settings.ratings_exclude, type_ratings_limit);
let suffix = settings_cache_suffix_with_ratings(settings, kind, image_size, &ratings_suffix);
let cache_key = format!("{id_type_str}/{id_value}{variant}{suffix}");
let cache_path_base = format!("{id_value}{variant}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, image_type, id_type_str, &cache_path_base)?;
let lb_cache_suffix: Arc<str> = format!("{variant}{suffix}").into();
{
let id_value = id_value.to_string();
let lb_cache_suffix = lb_cache_suffix.clone();
let settings = settings.clone();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_logo_backdrop_refresh(s, k, p, id_type, &id_value, &lb_cache_suffix, &settings, lb_kind, image_size, key_id);
}).await? {
let release_date = cache::read_meta_db(&state.db, &cache_key).await;
return Ok((bytes, release_date));
}
}
}
// Slow path: resolve ID and fetch ratings. Episodes are uplifted to
// their parent series — logos/backdrops don't apply to episodes.
let skip_ratings = type_ratings_limit == 0;
let (resolved, ratings_result, cross_ids) =
resolve_with_ratings(state, id_type, id_value, true, skip_ratings).await?;
// Persist available sources for future fast-path lookups (always write,
// even with external_cache_only — this is an optimization index, not a
// disk cache, and the fast path depends on it).
// Skip when ratings are disabled: no real source data to store.
if !skip_ratings {
let sources = ratings::available_sources_string(&ratings_result.badges);
upsert_available_ratings_cached(state, &id_key, &sources, cross_ids.release_date.as_deref()).await;
}
let badges = ratings::apply_rating_preferences(ratings_result.badges, &settings.ratings_order, &settings.ratings_exclude, type_ratings_limit);
let ratings_suffix = ratings::badges_cache_suffix(&badges);
let suffix = settings_cache_suffix_with_ratings(settings, kind, image_size, &ratings_suffix);
let cache_key = format!("{id_type_str}/{id_value}{variant}{suffix}");
let cache_path_base = format!("{id_value}{variant}{suffix}");
let cache_path = cache::typed_cache_path(&state.config.cache_dir, image_type, id_type_str, &cache_path_base)?;
// Check caches (memory → filesystem) — may hit if SQLite was stale but
// the correct cache entry already exists under the updated key.
let lb_cache_suffix: Arc<str> = format!("{variant}{suffix}").into();
{
let id_value = id_value.to_string();
let lb_cache_suffix = lb_cache_suffix.clone();
let settings = settings.clone();
if let Some(bytes) = check_caches(state, &cache_key, &cache_path, |s, k, p| {
trigger_logo_backdrop_refresh(s, k, p, id_type, &id_value, &lb_cache_suffix, &settings, lb_kind, image_size, key_id);
}).await? {
return Ok((bytes, cross_ids.release_date));
}
}
// Request coalescing — concurrent requests for the same logo/backdrop share one generation
struct LbGenCtx {
state: AppState,
cache_key: String,
cache_path: std::path::PathBuf,
lb_cache_suffix: Arc<str>,
fanart: Option<FanartClient>,
image_source_is_fanart: bool,
skip_fanart: bool,
neg_textless_key: String,
neg_lang_key: String,
type_badge_style: BadgeStyle,
type_label_style: LabelStyle,
type_appearance: BadgeAppearance,
type_position: BadgePosition,
type_badge_direction: BadgeDirection,
type_badge_size: BadgeSize,
type_edge_inset_x: i32,
type_edge_inset_y: i32,
badge_size_factor: f32,
label: &'static str,
resolved: id::ResolvedId,
badges: Vec<ratings::RatingBadge>,
cross_ids: CrossIdInfo,
key_id: Option<i32>,
}
let ctx = LbGenCtx {
state: state.clone(),
cache_key: cache_key.clone(),
cache_path: cache_path.clone(),
lb_cache_suffix: lb_cache_suffix.clone(),
fanart: fanart.cloned(),
image_source_is_fanart: settings.image_source.is_fanart(),
skip_fanart,
neg_textless_key,
neg_lang_key,
type_badge_style,
type_label_style,
type_appearance: params.appearance,
type_position: params.position,
type_badge_direction: params.badge_direction,
type_badge_size: params.badge_size,
type_edge_inset_x: params.edge_inset_x,
type_edge_inset_y: params.edge_inset_y,
badge_size_factor: params.badge_size.scale_factor(),
label,
resolved: resolved.clone(),
badges,
cross_ids: cross_ids.clone(),
key_id,
};
let bytes: Bytes = state
.image_inflight
.try_get_with(cache_key.clone(), async move {
let ctx = ctx;
// Determine source priority.
// When skip_fanart is set (all fanart variants negative-cached),
// avoid the fanart API call but still try TMDB.
let fanart_is_primary = ctx.image_source_is_fanart && ctx.fanart.is_some();
let try_fanart = !ctx.skip_fanart;
let not_found = || AppError::IdNotFound(format!("no {} available", ctx.label));
// Primary source, then fallback
let (primary, fallback) = if fanart_is_primary {
// Fanart preferred — try fanart first (unless skipped), then TMDB fallback
let primary = if try_fanart {
if let Some(ref fc) = ctx.fanart {
try_fanart_with_negative_cache(
fc, &ctx.state, &ctx.resolved,
fanart_lang, fanart_textless, kind,
&ctx.neg_textless_key, &ctx.neg_lang_key,
).await
} else {
None
}
} else {
None
};
let fallback = if primary.is_none() {
try_tmdb_logo_backdrop(&ctx.state, &ctx.resolved, kind, fanart_lang, fanart_textless, image_size).await
} else {
None
};
(primary, fallback)
} else {
// TMDB preferred — try TMDB first, then fanart fallback (unless skipped)
let primary = try_tmdb_logo_backdrop(&ctx.state, &ctx.resolved, kind, fanart_lang, fanart_textless, image_size).await;
let fallback = if primary.is_none() && try_fanart {
if let Some(ref fc) = ctx.fanart {
try_fanart_with_negative_cache(
fc, &ctx.state, &ctx.resolved,
fanart_lang, fanart_textless, kind,
&ctx.neg_textless_key, &ctx.neg_lang_key,
).await
} else {
None
}
} else {
None
};
(primary, fallback)
};
let image_bytes = match primary.or(fallback) {
Some(b) => b,
// Logos: try TMDB(default=en) as final fallback when the
// requested language wasn't English (avoids a redundant call)
None if kind == cache::ImageType::Logo && fanart_lang != "en" => {
try_tmdb_logo_backdrop(&ctx.state, &ctx.resolved, kind, "en", false, image_size)
.await
.ok_or_else(not_found)?
}
None => return Err(not_found()),
};
let resolved_size = resolve_image_size(image_size);
let (target_width, badge_scale) = match lb_kind {
LogoBackdropKind::Logo => (
resolved_size.logo_target_width(),
resolved_size.badge_scale(cache::ImageType::Logo) * ctx.badge_size_factor,
),
LogoBackdropKind::Backdrop => (
resolved_size.backdrop_target_width(),
resolved_size.badge_scale(cache::ImageType::Backdrop) * ctx.badge_size_factor,
),
};
let bytes = match lb_kind {
LogoBackdropKind::Logo => generate::generate_logo(image_bytes, ctx.badges, ctx.state.font.clone(), ctx.type_badge_style, ctx.type_label_style, ctx.type_appearance, ctx.state.render_semaphore.clone(), target_width, badge_scale).await?,
LogoBackdropKind::Backdrop => generate::generate_backdrop(image_bytes, ctx.badges, ctx.state.font.clone(), ctx.state.config.image_quality, ctx.type_position, ctx.type_badge_style, ctx.type_label_style, ctx.type_appearance, ctx.type_badge_direction, ctx.state.render_semaphore.clone(), target_width, badge_scale, ctx.type_badge_size, ctx.type_edge_inset_x, ctx.type_edge_inset_y).await?,
};
let release_date = ctx.cross_ids.release_date.clone();
let bytes = post_render_cache(&ctx.state, bytes, &ctx.cache_path, &ctx.cache_key, release_date.as_deref(), image_type, ctx.cross_ids, id_type, ctx.lb_cache_suffix.to_string(), ctx.key_id).await;
Ok::<_, AppError>(bytes)
})
.await
.map_err(AppError::from_cached)?;
state
.image_mem_cache
.insert(cache_key, MemCacheEntry { bytes: bytes.clone(), last_checked: Instant::now() })
.await;
Ok((bytes, cross_ids.release_date))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::services::db::{BadgeDirection, BadgePosition, BadgeSize, BadgeStyle, ImageSource, LabelStyle, RenderSettings};
#[test]
fn image_kind_prefix() {
assert_eq!(cache::ImageType::Poster.kind_prefix(), "");
assert_eq!(cache::ImageType::Logo.kind_prefix(), "_l");
assert_eq!(cache::ImageType::Backdrop.kind_prefix(), "_b");
}
#[test]
fn image_kind_file_ext() {
assert_eq!(cache::ImageType::Poster.ext(), "jpg");
assert_eq!(cache::ImageType::Logo.ext(), "png");
assert_eq!(cache::ImageType::Backdrop.ext(), "jpg");
}
#[test]
fn image_kind_strip_ext() {
assert_eq!(cache::ImageType::Poster.strip_ext("tt123.jpg"), "tt123");
assert_eq!(cache::ImageType::Poster.strip_ext("tt123"), "tt123");
assert_eq!(cache::ImageType::Logo.strip_ext("tt123.png"), "tt123");
assert_eq!(cache::ImageType::Logo.strip_ext("tt123"), "tt123");
assert_eq!(cache::ImageType::Backdrop.strip_ext("tt123.jpg"), "tt123");
assert_eq!(cache::ImageType::Backdrop.strip_ext("tt123"), "tt123");
// Wrong extension is not stripped
assert_eq!(cache::ImageType::Logo.strip_ext("tt123.jpg"), "tt123.jpg");
assert_eq!(cache::ImageType::Poster.strip_ext("tt123.png"), "tt123.png");
}
#[test]
fn image_kind_label() {
assert_eq!(cache::ImageType::Poster.label(), "poster");
assert_eq!(cache::ImageType::Logo.label(), "logo");
assert_eq!(cache::ImageType::Backdrop.label(), "backdrop");
}
#[test]
fn position_cache_suffix_all_positions() {
assert_eq!(position_cache_suffix("bc"), ".pbc");
assert_eq!(position_cache_suffix("tc"), ".ptc");
assert_eq!(position_cache_suffix("l"), ".pl");
assert_eq!(position_cache_suffix("r"), ".pr");
assert_eq!(position_cache_suffix("tl"), ".ptl");
assert_eq!(position_cache_suffix("tr"), ".ptr");
assert_eq!(position_cache_suffix("bl"), ".pbl");
assert_eq!(position_cache_suffix("br"), ".pbr");
}
#[test]
fn badge_style_cache_suffix_values() {
assert_eq!(badge_style_cache_suffix("h"), ".sh");
assert_eq!(badge_style_cache_suffix("v"), ".sv");
}
#[test]
fn label_style_cache_suffix_values() {
assert_eq!(label_style_cache_suffix("t"), ".lt");
assert_eq!(label_style_cache_suffix("i"), ".li");
}
#[test]
fn badge_direction_cache_suffix_values() {
assert_eq!(badge_direction_cache_suffix("h"), ".dh");
assert_eq!(badge_direction_cache_suffix("v"), ".dv");
assert_eq!(badge_direction_cache_suffix("d"), ".dd");
}
#[test]
fn cross_id_info_merges_resolved_and_ratings() {
let resolved = id::ResolvedId {
imdb_id: Some("tt1234567".into()),
tmdb_id: 100,
tvdb_id: None,
media_type: MediaType::Movie,
poster_path: None,
release_date: Some("2020-01-01".into()),
episode: None,
};
let ratings = ratings::RatingsResult {
badges: vec![],
tmdb_id: Some(100),
tvdb_id: Some(999),
imdb_id: Some("tt1234567".into()),
};
let info = CrossIdInfo::from_resolved(&resolved, &ratings);
assert_eq!(info.imdb_id.as_deref(), Some("tt1234567"));
assert_eq!(info.tmdb_id, 100);
// tvdb_id backfilled from ratings when resolved has None
assert_eq!(info.tvdb_id, Some(999));
assert_eq!(info.release_date.as_deref(), Some("2020-01-01"));
}
#[test]
fn cross_id_info_resolved_takes_precedence() {
let resolved = id::ResolvedId {
imdb_id: Some("tt1111111".into()),
tmdb_id: 200,
tvdb_id: Some(500),
media_type: MediaType::Tv,
poster_path: None,
release_date: None,
episode: None,
};
let ratings = ratings::RatingsResult {
badges: vec![],
tmdb_id: Some(200),
tvdb_id: Some(999),
imdb_id: Some("tt2222222".into()),
};
let info = CrossIdInfo::from_resolved(&resolved, &ratings);
// Resolved values take precedence over ratings
assert_eq!(info.imdb_id.as_deref(), Some("tt1111111"));
assert_eq!(info.tvdb_id, Some(500));
}
#[test]
fn settings_hash_deterministic() {
let s = RenderSettings::default();
let h1 = settings_hash(&s, cache::ImageType::Poster, None);
let h2 = settings_hash(&s, cache::ImageType::Poster, None);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 32); // 16 bytes = 32 hex chars
}
#[test]
fn settings_hash_differs_by_kind() {
let s = RenderSettings::default();
let poster = settings_hash(&s, cache::ImageType::Poster, None);
let logo = settings_hash(&s, cache::ImageType::Logo, None);
let backdrop = settings_hash(&s, cache::ImageType::Backdrop, None);
assert_ne!(poster, logo);
assert_ne!(poster, backdrop);
assert_ne!(logo, backdrop);
}
#[test]
fn settings_hash_differs_by_settings() {
let s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
s2.ratings_limit = 5;
assert_ne!(
settings_hash(&s1, cache::ImageType::Poster, None),
settings_hash(&s2, cache::ImageType::Poster, None)
);
}
#[test]
fn poster_fit_cache_suffix_tokens() {
use crate::services::db::PosterFit;
let mut s = RenderSettings::default();
s.poster_fit = PosterFit::Native;
let native = settings_cache_suffix(&s, cache::ImageType::Poster, None);
s.poster_fit = PosterFit::Cover;
let cover = settings_cache_suffix(&s, cache::ImageType::Poster, None);
s.poster_fit = PosterFit::Pad;
let pad = settings_cache_suffix(&s, cache::ImageType::Poster, None);
// Native emits no token so pre-feature (native) poster cache keys stay valid.
assert!(!native.contains(".f"), "native must not emit a fit token: {native}");
assert!(cover.contains(".fc"), "cover token missing: {cover}");
assert!(pad.contains(".fp"), "pad token missing: {pad}");
assert_ne!(native, cover);
assert_ne!(cover, pad);
}
#[test]
fn settings_hash_differs_by_poster_fit() {
use crate::services::db::PosterFit;
let mut a = RenderSettings::default();
a.poster_fit = PosterFit::Cover;
let mut b = RenderSettings::default();
b.poster_fit = PosterFit::Blur;
assert_ne!(
settings_hash(&a, cache::ImageType::Poster, None),
settings_hash(&b, cache::ImageType::Poster, None)
);
}
#[test]
fn settings_hash_differs_by_ratings_exclude() {
let s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
s2.ratings_exclude = std::sync::Arc::from("rt");
assert_ne!(
settings_hash(&s1, cache::ImageType::Poster, None),
settings_hash(&s2, cache::ImageType::Poster, None)
);
}
#[test]
fn settings_hash_differs_by_ratings_exclude_beyond_limit() {
// Regression: `trakt` is beyond the default limit (3) in the all-sources
// present prediction, so `ratings_cache_suffix` collapses it — but the
// CDN hash must still differ, or partial-availability titles collide.
let mut s1 = RenderSettings::default();
s1.ratings_limit = 3;
let mut s2 = s1.clone();
s2.ratings_exclude = std::sync::Arc::from("trakt");
assert_ne!(
settings_hash(&s1, cache::ImageType::Poster, None),
settings_hash(&s2, cache::ImageType::Poster, None),
"configs differing only in ratings_exclude must not share a CDN hash"
);
}
#[test]
fn settings_hash_same_for_reordered_ratings_exclude() {
// Canonical token means semantically-equal excludes dedupe (no false miss).
let mut s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
s1.ratings_exclude = std::sync::Arc::from("rt,tmdb");
s2.ratings_exclude = std::sync::Arc::from("tmdb,rt");
assert_eq!(
settings_hash(&s1, cache::ImageType::Poster, None),
settings_hash(&s2, cache::ImageType::Poster, None)
);
}
#[test]
fn settings_hash_same_for_equivalent_settings() {
let mut s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
// Different is_default flag shouldn't affect hash (it's metadata)
s1.is_default = true;
s2.is_default = false;
assert_eq!(
settings_hash(&s1, cache::ImageType::Poster, None),
settings_hash(&s2, cache::ImageType::Poster, None)
);
}
#[test]
fn settings_hash_differs_by_episode_settings() {
let s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
s2.episode_blur = true;
assert_ne!(
settings_hash(&s1, cache::ImageType::Episode, None),
settings_hash(&s2, cache::ImageType::Episode, None)
);
let mut s3 = RenderSettings::default();
s3.episode_position = crate::services::db::BadgePosition::BottomCenter;
assert_ne!(
settings_hash(&s1, cache::ImageType::Episode, None),
settings_hash(&s3, cache::ImageType::Episode, None)
);
}
#[test]
fn image_size_cache_suffix_values() {
use crate::services::db::ImageSize;
assert_eq!(image_size_cache_suffix(None), ".zm");
assert_eq!(image_size_cache_suffix(Some(ImageSize::Small)), ".zs");
assert_eq!(image_size_cache_suffix(Some(ImageSize::Medium)), ".zm");
assert_eq!(image_size_cache_suffix(Some(ImageSize::Large)), ".zl");
assert_eq!(image_size_cache_suffix(Some(ImageSize::VeryLarge)), ".zvl");
}
#[test]
fn cross_id_info_backfills_imdb_from_ratings() {
let resolved = id::ResolvedId {
imdb_id: None,
tmdb_id: 300,
tvdb_id: None,
media_type: MediaType::Movie,
poster_path: None,
release_date: None,
episode: None,
};
let ratings = ratings::RatingsResult {
badges: vec![],
tmdb_id: None,
tvdb_id: Some(777),
imdb_id: Some("tt9999999".into()),
};
let info = CrossIdInfo::from_resolved(&resolved, &ratings);
// Both backfilled from ratings
assert_eq!(info.imdb_id.as_deref(), Some("tt9999999"));
assert_eq!(info.tvdb_id, Some(777));
}
#[test]
fn settings_cache_suffix_poster_includes_all_parts() {
let s = RenderSettings::default();
let suffix = settings_cache_suffix(&s, cache::ImageType::Poster, None);
// Should contain ratings, position, badge style, label style, direction, and size suffixes
assert!(suffix.contains(".p"), "missing position suffix");
assert!(suffix.contains(".s"), "missing badge style suffix");
assert!(suffix.contains(".l"), "missing label style suffix");
assert!(suffix.contains(".d"), "missing badge direction suffix");
assert!(suffix.contains(".z"), "missing image size suffix");
}
#[test]
fn settings_cache_suffix_logo_no_position_or_direction() {
let s = RenderSettings::default();
let suffix = settings_cache_suffix(&s, cache::ImageType::Logo, None);
// Logos don't have position or direction
assert!(!suffix.contains(".p"), "logo should not have position suffix");
assert!(!suffix.contains(".d"), "logo should not have direction suffix");
// But should have badge style, label style, and size
assert!(suffix.contains(".s"), "missing badge style suffix");
assert!(suffix.contains(".l"), "missing label style suffix");
assert!(suffix.contains(".z"), "missing image size suffix");
}
#[test]
fn settings_cache_suffix_backdrop_includes_position_and_direction() {
let s = RenderSettings::default();
let suffix = settings_cache_suffix(&s, cache::ImageType::Backdrop, None);
assert!(suffix.contains(".p"), "backdrop should have position suffix");
assert!(suffix.contains(".d"), "backdrop should have direction suffix");
assert!(suffix.contains(".s"), "missing badge style suffix");
assert!(suffix.contains(".l"), "missing label style suffix");
assert!(suffix.contains(".z"), "missing image size suffix");
}
#[test]
fn settings_cache_suffix_uses_per_kind_settings() {
let mut s = RenderSettings::default();
s.poster_badge_style = BadgeStyle::Horizontal;
s.logo_badge_style = BadgeStyle::Vertical;
s.backdrop_badge_style = BadgeStyle::Vertical;
let poster = settings_cache_suffix(&s, cache::ImageType::Poster, None);
let logo = settings_cache_suffix(&s, cache::ImageType::Logo, None);
assert!(poster.contains(".sh"), "poster should use poster_badge_style");
assert!(logo.contains(".sv"), "logo should use logo_badge_style");
}
#[test]
fn settings_cache_suffix_pill_normalizes_style_token() {
use crate::services::db::BadgeShape;
// Pills always render horizontally, so pill+vertical and pill+horizontal
// must collapse to the same cache key (no duplicate cache entries for
// byte-identical output). The rounded variants stay distinct.
let mut pill_v = RenderSettings::default();
pill_v.poster_badge_style = BadgeStyle::Vertical;
pill_v.poster_badge_shape = BadgeShape::Pill;
let mut pill_h = RenderSettings::default();
pill_h.poster_badge_style = BadgeStyle::Horizontal;
pill_h.poster_badge_shape = BadgeShape::Pill;
assert_eq!(
settings_cache_suffix(&pill_v, cache::ImageType::Poster, None),
settings_cache_suffix(&pill_h, cache::ImageType::Poster, None),
"pill+vertical and pill+horizontal should share a cache key",
);
// The configured vertical style is normalized away (no `.sv` token), and
// the pill shape suffix is present.
let suffix = settings_cache_suffix(&pill_v, cache::ImageType::Poster, None);
assert!(!suffix.contains(".sv"), "pill should not keep the vertical style token");
assert!(suffix.contains(".shp"), "pill shape suffix present");
// Rounded badges still honour the configured style.
let mut round_v = RenderSettings::default();
round_v.poster_badge_style = BadgeStyle::Vertical;
let mut round_h = RenderSettings::default();
round_h.poster_badge_style = BadgeStyle::Horizontal;
assert_ne!(
settings_cache_suffix(&round_v, cache::ImageType::Poster, None),
settings_cache_suffix(&round_h, cache::ImageType::Poster, None),
"rounded vertical and horizontal should stay distinct",
);
}
#[test]
fn settings_cache_suffix_uses_per_kind_ratings_limit() {
let mut s = RenderSettings::default();
s.ratings_limit = 3;
s.logo_ratings_limit = 5;
s.backdrop_ratings_limit = 2;
let poster = settings_cache_suffix(&s, cache::ImageType::Poster, None);
let logo = settings_cache_suffix(&s, cache::ImageType::Logo, None);
let backdrop = settings_cache_suffix(&s, cache::ImageType::Backdrop, None);
// Different ratings limits should produce different suffixes
assert_ne!(poster, logo);
assert_ne!(logo, backdrop);
}
#[test]
fn settings_cache_suffix_varies_with_image_size() {
let s = RenderSettings::default();
let medium = settings_cache_suffix(&s, cache::ImageType::Poster, None);
let large = settings_cache_suffix(&s, cache::ImageType::Poster, Some(ImageSize::Large));
assert_ne!(medium, large);
assert!(medium.ends_with(".zm"));
assert!(large.ends_with(".zl"));
}
#[test]
fn settings_cache_suffix_ignores_source_fields() {
let mut s1 = RenderSettings::default();
let mut s2 = RenderSettings::default();
// These fields are handled by code path / variant, not suffix
s1.image_source = ImageSource::Tmdb;
s2.image_source = ImageSource::Fanart;
s1.lang = "en".into();
s2.lang = "de".into();
s1.textless = false;
s2.textless = true;
assert_eq!(
settings_cache_suffix(&s1, cache::ImageType::Poster, None),
settings_cache_suffix(&s2, cache::ImageType::Poster, None)
);
}
#[test]
fn cache_suffix_differs_by_actual_badges() {
// Two movies with the same settings but different available rating sources
// should produce different cache keys when using settings_cache_suffix_with_ratings.
let s = RenderSettings::default();
let suffix_imdb_rt = settings_cache_suffix_with_ratings(&s, cache::ImageType::Poster, None, "@ir");
let suffix_imdb_rt_lb = settings_cache_suffix_with_ratings(&s, cache::ImageType::Poster, None, "@irl");
let suffix_none = settings_cache_suffix_with_ratings(&s, cache::ImageType::Poster, None, "@");
assert_ne!(suffix_imdb_rt, suffix_imdb_rt_lb, "different badge sets must produce different suffixes");
assert_ne!(suffix_imdb_rt, suffix_none, "badges vs no badges must differ");
assert_ne!(suffix_imdb_rt_lb, suffix_none);
// Same badges should produce the same suffix
let suffix_imdb_rt_2 = settings_cache_suffix_with_ratings(&s, cache::ImageType::Poster, None, "@ir");
assert_eq!(suffix_imdb_rt, suffix_imdb_rt_2);
}
// --- LbRenderParams tests ---
#[test]
fn lb_render_params_logo_uses_hardcoded_defaults() {
let settings = RenderSettings {
logo_badge_style: BadgeStyle::Horizontal,
logo_label_style: LabelStyle::Icon,
logo_badge_size: BadgeSize::Large,
// Backdrop fields should be ignored for logo
backdrop_position: BadgePosition::BottomLeft,
backdrop_badge_direction: BadgeDirection::Horizontal,
backdrop_badge_style: BadgeStyle::Vertical,
..RenderSettings::default()
};
let p = LbRenderParams::from_settings(LogoBackdropKind::Logo, &settings);
assert_eq!(p.position, BadgePosition::TopRight);
assert_eq!(p.badge_direction, BadgeDirection::Vertical);
assert_eq!(p.badge_style, BadgeStyle::Horizontal);
assert_eq!(p.label_style, LabelStyle::Icon);
assert_eq!(p.badge_size, BadgeSize::Large);
}
#[test]
fn lb_render_params_backdrop_reads_settings() {
let settings = RenderSettings {
backdrop_position: BadgePosition::BottomCenter,
backdrop_badge_direction: BadgeDirection::Horizontal,
backdrop_badge_style: BadgeStyle::Vertical,
backdrop_label_style: LabelStyle::Official,
backdrop_badge_size: BadgeSize::ExtraSmall,
..RenderSettings::default()
};
let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
assert_eq!(p.position, BadgePosition::BottomCenter);
assert_eq!(p.badge_direction, BadgeDirection::Horizontal);
// badge_style Vertical stays Vertical (not Default), unaffected by direction
assert_eq!(p.badge_style, BadgeStyle::Vertical);
assert_eq!(p.label_style, LabelStyle::Official);
assert_eq!(p.badge_size, BadgeSize::ExtraSmall);
}
#[test]
fn lb_render_params_backdrop_resolves_default_direction() {
// BottomCenter is center-horizontal → Default direction resolves to Horizontal
let settings = RenderSettings {
backdrop_position: BadgePosition::BottomCenter,
backdrop_badge_direction: BadgeDirection::Default,
backdrop_badge_style: BadgeStyle::Default,
..RenderSettings::default()
};
let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
assert_eq!(p.badge_direction, BadgeDirection::Horizontal);
// Default style resolves to match direction (Horizontal)
assert_eq!(p.badge_style, BadgeStyle::Horizontal);
}
#[test]
fn lb_render_params_backdrop_resolves_default_direction_vertical() {
// TopRight is not center-horizontal → Default direction resolves to Vertical
let settings = RenderSettings {
backdrop_position: BadgePosition::TopRight,
backdrop_badge_direction: BadgeDirection::Default,
backdrop_badge_style: BadgeStyle::Default,
..RenderSettings::default()
};
let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
assert_eq!(p.badge_direction, BadgeDirection::Vertical);
assert_eq!(p.badge_style, BadgeStyle::Vertical);
}
#[test]
fn lb_render_params_backdrop_reads_edge_insets() {
let settings = RenderSettings {
backdrop_position: BadgePosition::TopRight,
backdrop_edge_inset_x: 8,
backdrop_edge_inset_y: 3,
..RenderSettings::default()
};
let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
assert_eq!(p.edge_inset_x, 8);
assert_eq!(p.edge_inset_y, 3);
// Logos never inset.
let logo = LbRenderParams::from_settings(LogoBackdropKind::Logo, &settings);
assert_eq!(logo.edge_inset_x, 0);
assert_eq!(logo.edge_inset_y, 0);
}
#[test]
fn edge_inset_suffix_only_tokenizes_active_axes() {
// Corner: both axes apply.
assert_eq!(edge_inset_cache_suffix(BadgePosition::TopRight, 8, 3), ".eh8.ev3");
assert_eq!(edge_inset_cache_suffix(BadgePosition::BottomLeft, 5, 10), ".eh5.ev10");
// Right edge is vertically centered → vertical inset is dropped.
assert_eq!(edge_inset_cache_suffix(BadgePosition::Right, 8, 3), ".eh8");
// Top center is horizontally centered → horizontal inset is dropped.
assert_eq!(edge_inset_cache_suffix(BadgePosition::TopCenter, 8, 3), ".ev3");
// Zero (or default) insets add no token, so existing keys are unchanged.
assert_eq!(edge_inset_cache_suffix(BadgePosition::TopRight, 0, 0), "");
}
#[test]
fn backdrop_cache_suffix_includes_edge_inset_only_when_set() {
let base = RenderSettings { backdrop_position: BadgePosition::TopRight, ..RenderSettings::default() };
let without = settings_cache_suffix(&base, cache::ImageType::Backdrop, None);
let with = settings_cache_suffix(
&RenderSettings { backdrop_edge_inset_x: 8, backdrop_edge_inset_y: 3, ..base.clone() },
cache::ImageType::Backdrop,
None,
);
// Default (0/0) must match the pre-feature key; a non-zero inset diverges.
assert!(!without.contains(".eh") && !without.contains(".ev"));
assert!(with.contains(".eh8.ev3"));
assert_ne!(without, with);
}
#[test]
fn tmdb_poster_variant_all_combinations() {
// Default case: no variant (backward compatible)
assert_eq!(tmdb_poster_variant("en", false), "");
// English + textless
assert_eq!(tmdb_poster_variant("en", true), "_t_tl");
// Non-English, no textless
assert_eq!(tmdb_poster_variant("de", false), "_t_de");
// Non-English + textless
assert_eq!(tmdb_poster_variant("de", true), "_t_de_tl");
assert_eq!(tmdb_poster_variant("fr", false), "_t_fr");
assert_eq!(tmdb_poster_variant("ja", true), "_t_ja_tl");
}
}
use axum::http::header;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use bytes::Bytes;
use sha2::{Sha256, Digest};
use std::sync::Arc;
use std::time::Instant;
use crate::cache::{self, MemCacheEntry};
use crate::error::AppError;
use crate::id::{self, IdType, MediaType, format_tmdb_id_value};
use crate::image::generate;
use crate::services::db::{BadgeAppearance, BadgeDirection, BadgePosition, BadgeSize, BadgeStyle, ImageSize, LabelStyle, RenderSettings};
use crate::services::fanart::{FanartClient, FanartImages, FanartPoster, PosterMatch};
use crate::services::lang::lang_base;
use crate::services::ratings;
use crate::AppState;
/// Threshold (ms) above which requests are logged as slow.
const SLOW_REQUEST_MS: u64 = 2000;
/// Logo or backdrop — image types served via TMDB images API (primary) with
/// fanart.tv fallback, or vice versa when the user prefers fanart.
/// Posters are excluded because they use a separate code path (`handle_inner`) with
/// staleness checks and background refresh that these endpoints don't need.
pub enum LogoBackdropKind {
Logo,
Backdrop,
}
impl From<LogoBackdropKind> for cache::ImageType {
fn from(k: LogoBackdropKind) -> Self {
match k {
LogoBackdropKind::Logo => cache::ImageType::Logo,
LogoBackdropKind::Backdrop => cache::ImageType::Backdrop,
}
}
}
/// Resolved per-type render parameters for logo/backdrop generation.
///
/// For logos, `position` and `badge_direction` are hardcoded (TopRight / Vertical)
/// and not passed to `generate_logo` — only `badge_style`, `label_style`, and
/// `badge_size` are used. They exist here so backdrop callers can read them
/// without a separate code path.
struct LbRenderParams {
position: BadgePosition,
badge_direction: BadgeDirection,
badge_style: BadgeStyle,
label_style: LabelStyle,
badge_size: BadgeSize,
appearance: BadgeAppearance,
/// Backdrop edge insets (percent of dimension). Zero for logos.
edge_inset_x: i32,
edge_inset_y: i32,
}
impl LbRenderParams {
fn from_settings(lb_kind: LogoBackdropKind, settings: &RenderSettings) -> Self {
let position = match lb_kind {
LogoBackdropKind::Logo => BadgePosition::TopRight,
LogoBackdropKind::Backdrop => settings.backdrop_position,
};
let badge_direction = match lb_kind {
LogoBackdropKind::Logo => BadgeDirection::Vertical,
LogoBackdropKind::Backdrop => settings.backdrop_badge_direction.resolve(position),
};
let badge_style = match lb_kind {
LogoBackdropKind::Logo => settings.logo_badge_style,
LogoBackdropKind::Backdrop => settings.backdrop_badge_style,
}
.resolve(badge_direction);
let label_style = match lb_kind {
LogoBackdropKind::Logo => settings.logo_label_style,
LogoBackdropKind::Backdrop => settings.backdrop_label_style,
};
let badge_size = match lb_kind {
LogoBackdropKind::Logo => settings.logo_badge_size,
LogoBackdropKind::Backdrop => settings.backdrop_badge_size,
};
let appearance = match lb_kind {
LogoBackdropKind::Logo => settings.logo_appearance(),
LogoBackdropKind::Backdrop => settings.backdrop_appearance(),
};
// Edge insets are backdrop-only; logos render at a fixed anchor.
let (edge_inset_x, edge_inset_y) = match lb_kind {
LogoBackdropKind::Logo => (0, 0),
LogoBackdropKind::Backdrop => (
crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_x),
crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_y),
),
};
Self { position, badge_direction, badge_style, label_style, badge_size, appearance, edge_inset_x, edge_inset_y }
}
}
/// Returns a cache key suffix for badge position.
pub fn position_cache_suffix(position: &str) -> String {
format!(".p{position}")