PNRxA commited on
Commit
70cc48f
·
1 Parent(s): 098aae4

feat: add distance-from-edge inset setting for backdrop ratings

Browse files

Closes #7.

Some media players (e.g. Nuvio on Mac) reposition/crop backdrops and clip
the rating badges. Add two independent per-key/global settings that inset
the backdrop ratings inward from the anchored edge:

- backdrop_edge_inset_x — horizontal inset (% of width), applies only to
left/right positions
- backdrop_edge_inset_y — vertical inset (% of height), applies only to
top/bottom positions

Centered axes ignore their inset, so a top-right position exposes "space
from top" and "space from right", while "right" exposes only "space from
right". Surfaced in the settings UI as conditional, edge-aware inputs and
as edge_inset_x / edge_inset_y query params on the backdrop image and
preview endpoints.

The cache token (.eh{x}/.ev{y}) is emitted only when the position-gated
value is non-zero, mirroring the existing optional .x1 (split) and .blur
tokens, so default backdrop cache keys are unchanged and no cache-key
migration is needed (only the additive DB columns, default 0).

README.md CHANGED
@@ -137,9 +137,10 @@ GET /{api_key}/isValid
137
  - `?blur={true|false}`: apply Gaussian blur for spoiler protection (episode only). Badges remain sharp over the blurred still image
138
  - `?split={true|false}`: split the badges evenly across two opposite sides of the poster (poster only). The axis follows the badge layout — a vertical layout splits left/right, horizontal rows split top/bottom. With an odd number of badges the configured side gets the extra one (e.g. 4 badges → 2 + 2, 3 badges → 2 + 1)
139
  - `?fit={native|cover|pad|blur}`: how a poster is fit to the standard 2:3 output frame (poster only). `native` (default) keeps the source aspect ratio; `cover` scales to fill 2:3 and center-crops the overflow; `pad` fits the whole poster inside 2:3 with solid black bars; `blur` fits the whole poster inside 2:3 and fills the bars with a blurred, zoomed copy of the poster. The non-native modes guarantee a uniform 2:3 image so downstream apps that place posters in fixed 2:3 containers don't crop the art
 
140
  - RPDB-compatible — use `http://localhost:3000` as the base URL (drop-in replacement for `https://api.ratingposterdb.com`). Old parameter names `?poster_source=` and `?fanart_textless=` are accepted as aliases
141
 
142
- `textless`, `split`, and `fit` are poster-only. `blur` is episode-only. `badge_direction` and `position` are silently ignored on logo endpoints. For shared parameters (`ratings_limit`, `badge_style`, `label_style`, `badge_size`, `badge_shape`, `badge_background`, `image_source`), the override is applied to the correct image-type-specific setting (e.g. `?badge_style=h` on the poster endpoint sets `poster_badge_style`, on the logo endpoint sets `logo_badge_style`).
143
 
144
  Management endpoints (auth, keys, settings) are under `/api/` and return JSON.
145
 
 
137
  - `?blur={true|false}`: apply Gaussian blur for spoiler protection (episode only). Badges remain sharp over the blurred still image
138
  - `?split={true|false}`: split the badges evenly across two opposite sides of the poster (poster only). The axis follows the badge layout — a vertical layout splits left/right, horizontal rows split top/bottom. With an odd number of badges the configured side gets the extra one (e.g. 4 badges → 2 + 2, 3 badges → 2 + 1)
139
  - `?fit={native|cover|pad|blur}`: how a poster is fit to the standard 2:3 output frame (poster only). `native` (default) keeps the source aspect ratio; `cover` scales to fill 2:3 and center-crops the overflow; `pad` fits the whole poster inside 2:3 with solid black bars; `blur` fits the whole poster inside 2:3 and fills the bars with a blurred, zoomed copy of the poster. The non-native modes guarantee a uniform 2:3 image so downstream apps that place posters in fixed 2:3 containers don't crop the art
140
+ - `?edge_inset_x={0-50}` / `?edge_inset_y={0-50}`: inset the backdrop ratings further from the anchored edge, as a percentage of the backdrop's width/height (backdrop only). Useful when a media player crops the backdrop and clips the ratings. `edge_inset_x` only applies to left/right positions and `edge_inset_y` only to top/bottom positions; the inset for a centered axis is ignored. Example: `?position=tr&edge_inset_y=10` nudges top-right ratings down by 10% of the height
141
  - RPDB-compatible — use `http://localhost:3000` as the base URL (drop-in replacement for `https://api.ratingposterdb.com`). Old parameter names `?poster_source=` and `?fanart_textless=` are accepted as aliases
142
 
143
+ `textless`, `split`, and `fit` are poster-only. `blur` is episode-only. `edge_inset_x`/`edge_inset_y` are backdrop-only. `badge_direction` and `position` are silently ignored on logo endpoints. For shared parameters (`ratings_limit`, `badge_style`, `label_style`, `badge_size`, `badge_shape`, `badge_background`, `image_source`), the override is applied to the correct image-type-specific setting (e.g. `?badge_style=h` on the poster endpoint sets `poster_badge_style`, on the logo endpoint sets `logo_badge_style`).
144
 
145
  Management endpoints (auth, keys, settings) are under `/api/` and return JSON.
146
 
api/src/entity/api_key_settings.rs CHANGED
@@ -43,6 +43,12 @@ pub struct Model {
43
  pub logo_badge_background: String,
44
  pub backdrop_badge_background: String,
45
  pub episode_badge_background: String,
 
 
 
 
 
 
46
  }
47
 
48
  #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
 
43
  pub logo_badge_background: String,
44
  pub backdrop_badge_background: String,
45
  pub episode_badge_background: String,
46
+ /// Distance (percent of width, 0–50) to inset backdrop ratings from the
47
+ /// anchored horizontal edge (left or right). Ignored for centered positions.
48
+ pub backdrop_edge_inset_x: i32,
49
+ /// Distance (percent of height, 0–50) to inset backdrop ratings from the
50
+ /// anchored vertical edge (top or bottom). Ignored for centered positions.
51
+ pub backdrop_edge_inset_y: i32,
52
  }
53
 
54
  #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
api/src/handlers/admin.rs CHANGED
@@ -136,6 +136,8 @@ pub struct GlobalSettingsResponse {
136
  pub backdrop_badge_size: BadgeSize,
137
  pub backdrop_position: BadgePosition,
138
  pub backdrop_badge_direction: BadgeDirection,
 
 
139
  pub episode_ratings_limit: i32,
140
  pub episode_badge_style: BadgeStyle,
141
  pub episode_label_style: LabelStyle,
@@ -194,6 +196,8 @@ pub async fn get_settings(
194
  backdrop_badge_size: settings.backdrop_badge_size,
195
  backdrop_position: settings.backdrop_position,
196
  backdrop_badge_direction: settings.backdrop_badge_direction,
 
 
197
  episode_ratings_limit: settings.episode_ratings_limit,
198
  episode_badge_style: settings.episode_badge_style,
199
  episode_label_style: settings.episode_label_style,
@@ -261,6 +265,10 @@ pub struct UpdateGlobalSettingsRequest {
261
  pub backdrop_position: BadgePosition,
262
  #[serde(default = "db::default_backdrop_badge_direction")]
263
  pub backdrop_badge_direction: BadgeDirection,
 
 
 
 
264
  #[serde(default = "db::default_episode_ratings_limit")]
265
  pub episode_ratings_limit: i32,
266
  #[serde(default = "db::default_episode_badge_style")]
@@ -305,6 +313,8 @@ pub async fn update_settings(
305
  let episode_limit_str = req.episode_ratings_limit.to_string();
306
  let episode_blur_str = if req.episode_blur { "true" } else { "false" };
307
  let poster_badge_split_str = if req.poster_badge_split { "true" } else { "false" };
 
 
308
  let mut batch: Vec<(&str, &str)> = vec![
309
  ("image_source", req.image_source.as_str()),
310
  ("lang", &req.lang),
@@ -329,6 +339,8 @@ pub async fn update_settings(
329
  ("backdrop_badge_size", req.backdrop_badge_size.as_str()),
330
  ("backdrop_position", req.backdrop_position.as_str()),
331
  ("backdrop_badge_direction", req.backdrop_badge_direction.as_str()),
 
 
332
  ("episode_ratings_limit", &episode_limit_str),
333
  ("episode_badge_style", req.episode_badge_style.as_str()),
334
  ("episode_label_style", req.episode_label_style.as_str()),
 
136
  pub backdrop_badge_size: BadgeSize,
137
  pub backdrop_position: BadgePosition,
138
  pub backdrop_badge_direction: BadgeDirection,
139
+ pub backdrop_edge_inset_x: i32,
140
+ pub backdrop_edge_inset_y: i32,
141
  pub episode_ratings_limit: i32,
142
  pub episode_badge_style: BadgeStyle,
143
  pub episode_label_style: LabelStyle,
 
196
  backdrop_badge_size: settings.backdrop_badge_size,
197
  backdrop_position: settings.backdrop_position,
198
  backdrop_badge_direction: settings.backdrop_badge_direction,
199
+ backdrop_edge_inset_x: settings.backdrop_edge_inset_x,
200
+ backdrop_edge_inset_y: settings.backdrop_edge_inset_y,
201
  episode_ratings_limit: settings.episode_ratings_limit,
202
  episode_badge_style: settings.episode_badge_style,
203
  episode_label_style: settings.episode_label_style,
 
265
  pub backdrop_position: BadgePosition,
266
  #[serde(default = "db::default_backdrop_badge_direction")]
267
  pub backdrop_badge_direction: BadgeDirection,
268
+ #[serde(default = "db::default_backdrop_edge_inset")]
269
+ pub backdrop_edge_inset_x: i32,
270
+ #[serde(default = "db::default_backdrop_edge_inset")]
271
+ pub backdrop_edge_inset_y: i32,
272
  #[serde(default = "db::default_episode_ratings_limit")]
273
  pub episode_ratings_limit: i32,
274
  #[serde(default = "db::default_episode_badge_style")]
 
313
  let episode_limit_str = req.episode_ratings_limit.to_string();
314
  let episode_blur_str = if req.episode_blur { "true" } else { "false" };
315
  let poster_badge_split_str = if req.poster_badge_split { "true" } else { "false" };
316
+ let backdrop_edge_inset_x_str = db::clamp_edge_inset(req.backdrop_edge_inset_x).to_string();
317
+ let backdrop_edge_inset_y_str = db::clamp_edge_inset(req.backdrop_edge_inset_y).to_string();
318
  let mut batch: Vec<(&str, &str)> = vec![
319
  ("image_source", req.image_source.as_str()),
320
  ("lang", &req.lang),
 
339
  ("backdrop_badge_size", req.backdrop_badge_size.as_str()),
340
  ("backdrop_position", req.backdrop_position.as_str()),
341
  ("backdrop_badge_direction", req.backdrop_badge_direction.as_str()),
342
+ ("backdrop_edge_inset_x", &backdrop_edge_inset_x_str),
343
+ ("backdrop_edge_inset_y", &backdrop_edge_inset_y_str),
344
  ("episode_ratings_limit", &episode_limit_str),
345
  ("episode_badge_style", req.episode_badge_style.as_str()),
346
  ("episode_label_style", req.episode_label_style.as_str()),
api/src/handlers/api_keys.rs CHANGED
@@ -118,6 +118,8 @@ pub struct RenderSettingsResponse {
118
  pub backdrop_badge_size: BadgeSize,
119
  pub backdrop_position: BadgePosition,
120
  pub backdrop_badge_direction: BadgeDirection,
 
 
121
  pub episode_ratings_limit: i32,
122
  pub episode_badge_style: BadgeStyle,
123
  pub episode_label_style: LabelStyle,
@@ -173,6 +175,8 @@ fn settings_to_response(settings: &db::RenderSettings, fanart_available: bool) -
173
  backdrop_badge_size: settings.backdrop_badge_size,
174
  backdrop_position: settings.backdrop_position,
175
  backdrop_badge_direction: settings.backdrop_badge_direction,
 
 
176
  episode_ratings_limit: settings.episode_ratings_limit,
177
  episode_badge_style: settings.episode_badge_style,
178
  episode_label_style: settings.episode_label_style,
@@ -239,6 +243,10 @@ pub struct UpdateSettingsRequest {
239
  pub backdrop_position: BadgePosition,
240
  #[serde(default = "db::default_backdrop_badge_direction")]
241
  pub backdrop_badge_direction: BadgeDirection,
 
 
 
 
242
  #[serde(default = "db::default_episode_ratings_limit")]
243
  pub episode_ratings_limit: i32,
244
  #[serde(default = "db::default_episode_badge_style")]
@@ -312,6 +320,8 @@ fn build_upsert(id: i32, req: &UpdateSettingsRequest) -> db::UpsertApiKeySetting
312
  logo_badge_background: req.logo_badge_background.as_str(),
313
  backdrop_badge_background: req.backdrop_badge_background.as_str(),
314
  episode_badge_background: req.episode_badge_background.as_str(),
 
 
315
  }
316
  }
317
 
 
118
  pub backdrop_badge_size: BadgeSize,
119
  pub backdrop_position: BadgePosition,
120
  pub backdrop_badge_direction: BadgeDirection,
121
+ pub backdrop_edge_inset_x: i32,
122
+ pub backdrop_edge_inset_y: i32,
123
  pub episode_ratings_limit: i32,
124
  pub episode_badge_style: BadgeStyle,
125
  pub episode_label_style: LabelStyle,
 
175
  backdrop_badge_size: settings.backdrop_badge_size,
176
  backdrop_position: settings.backdrop_position,
177
  backdrop_badge_direction: settings.backdrop_badge_direction,
178
+ backdrop_edge_inset_x: settings.backdrop_edge_inset_x,
179
+ backdrop_edge_inset_y: settings.backdrop_edge_inset_y,
180
  episode_ratings_limit: settings.episode_ratings_limit,
181
  episode_badge_style: settings.episode_badge_style,
182
  episode_label_style: settings.episode_label_style,
 
243
  pub backdrop_position: BadgePosition,
244
  #[serde(default = "db::default_backdrop_badge_direction")]
245
  pub backdrop_badge_direction: BadgeDirection,
246
+ #[serde(default = "db::default_backdrop_edge_inset")]
247
+ pub backdrop_edge_inset_x: i32,
248
+ #[serde(default = "db::default_backdrop_edge_inset")]
249
+ pub backdrop_edge_inset_y: i32,
250
  #[serde(default = "db::default_episode_ratings_limit")]
251
  pub episode_ratings_limit: i32,
252
  #[serde(default = "db::default_episode_badge_style")]
 
320
  logo_badge_background: req.logo_badge_background.as_str(),
321
  backdrop_badge_background: req.backdrop_badge_background.as_str(),
322
  episode_badge_background: req.episode_badge_background.as_str(),
323
+ backdrop_edge_inset_x: db::clamp_edge_inset(req.backdrop_edge_inset_x),
324
+ backdrop_edge_inset_y: db::clamp_edge_inset(req.backdrop_edge_inset_y),
325
  }
326
  }
327
 
api/src/handlers/image.rs CHANGED
@@ -118,6 +118,16 @@ pub struct ImageQuery {
118
  #[serde(default)]
119
  #[param(value_type = Option<String>)]
120
  pub fit: Option<PosterFit>,
 
 
 
 
 
 
 
 
 
 
121
  }
122
 
123
  impl ImageQuery {
@@ -138,6 +148,8 @@ impl ImageQuery {
138
  || self.blur.is_some()
139
  || self.split.is_some()
140
  || self.fit.is_some()
 
 
141
  }
142
  }
143
 
@@ -193,6 +205,8 @@ pub struct FreeKeySettingsResponse {
193
  pub backdrop_badge_size: BadgeSize,
194
  pub backdrop_position: BadgePosition,
195
  pub backdrop_badge_direction: BadgeDirection,
 
 
196
  pub episode_ratings_limit: i32,
197
  pub episode_badge_style: BadgeStyle,
198
  pub episode_label_style: LabelStyle,
@@ -236,6 +250,8 @@ impl From<&RenderSettings> for FreeKeySettingsResponse {
236
  backdrop_badge_size: s.backdrop_badge_size,
237
  backdrop_position: s.backdrop_position,
238
  backdrop_badge_direction: s.backdrop_badge_direction,
 
 
239
  episode_ratings_limit: s.episode_ratings_limit,
240
  episode_badge_style: s.episode_badge_style,
241
  episode_label_style: s.episode_label_style,
@@ -451,6 +467,12 @@ fn apply_query_overrides(
451
  if let Some(pos) = query.position {
452
  s.backdrop_position = pos;
453
  }
 
 
 
 
 
 
454
  }
455
  if kind == cache::ImageType::Episode {
456
  if let Some(dir) = query.badge_direction {
@@ -876,6 +898,8 @@ mod tests {
876
  blur: None,
877
  split: None,
878
  fit: None,
 
 
879
  }
880
  }
881
 
@@ -980,6 +1004,50 @@ mod tests {
980
  assert_eq!(result.poster_badge_direction, original_poster_direction);
981
  }
982
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
983
  #[test]
984
  fn apply_query_overrides_rejects_invalid_ratings_limit() {
985
  let settings = Arc::new(db::RenderSettings::default());
 
118
  #[serde(default)]
119
  #[param(value_type = Option<String>)]
120
  pub fit: Option<PosterFit>,
121
+ /// Distance (0–50, percent of width) to inset backdrop ratings from the
122
+ /// anchored horizontal edge (backdrop only). Ignored for centered positions.
123
+ #[serde(default)]
124
+ #[param(value_type = Option<i32>)]
125
+ pub edge_inset_x: Option<i32>,
126
+ /// Distance (0–50, percent of height) to inset backdrop ratings from the
127
+ /// anchored vertical edge (backdrop only). Ignored for centered positions.
128
+ #[serde(default)]
129
+ #[param(value_type = Option<i32>)]
130
+ pub edge_inset_y: Option<i32>,
131
  }
132
 
133
  impl ImageQuery {
 
148
  || self.blur.is_some()
149
  || self.split.is_some()
150
  || self.fit.is_some()
151
+ || self.edge_inset_x.is_some()
152
+ || self.edge_inset_y.is_some()
153
  }
154
  }
155
 
 
205
  pub backdrop_badge_size: BadgeSize,
206
  pub backdrop_position: BadgePosition,
207
  pub backdrop_badge_direction: BadgeDirection,
208
+ pub backdrop_edge_inset_x: i32,
209
+ pub backdrop_edge_inset_y: i32,
210
  pub episode_ratings_limit: i32,
211
  pub episode_badge_style: BadgeStyle,
212
  pub episode_label_style: LabelStyle,
 
250
  backdrop_badge_size: s.backdrop_badge_size,
251
  backdrop_position: s.backdrop_position,
252
  backdrop_badge_direction: s.backdrop_badge_direction,
253
+ backdrop_edge_inset_x: s.backdrop_edge_inset_x,
254
+ backdrop_edge_inset_y: s.backdrop_edge_inset_y,
255
  episode_ratings_limit: s.episode_ratings_limit,
256
  episode_badge_style: s.episode_badge_style,
257
  episode_label_style: s.episode_label_style,
 
467
  if let Some(pos) = query.position {
468
  s.backdrop_position = pos;
469
  }
470
+ if let Some(inset) = query.edge_inset_x {
471
+ s.backdrop_edge_inset_x = db::clamp_edge_inset(inset);
472
+ }
473
+ if let Some(inset) = query.edge_inset_y {
474
+ s.backdrop_edge_inset_y = db::clamp_edge_inset(inset);
475
+ }
476
  }
477
  if kind == cache::ImageType::Episode {
478
  if let Some(dir) = query.badge_direction {
 
898
  blur: None,
899
  split: None,
900
  fit: None,
901
+ edge_inset_x: None,
902
+ edge_inset_y: None,
903
  }
904
  }
905
 
 
1004
  assert_eq!(result.poster_badge_direction, original_poster_direction);
1005
  }
1006
 
1007
+ #[test]
1008
+ fn apply_query_overrides_backdrop_edge_inset() {
1009
+ let settings = Arc::new(db::RenderSettings::default());
1010
+ let query = ImageQuery {
1011
+ edge_inset_x: Some(12),
1012
+ edge_inset_y: Some(7),
1013
+ ..empty_query()
1014
+ };
1015
+ let result =
1016
+ apply_query_overrides(settings, &query, cache::ImageType::Backdrop).unwrap();
1017
+ assert_eq!(result.backdrop_edge_inset_x, 12);
1018
+ assert_eq!(result.backdrop_edge_inset_y, 7);
1019
+ }
1020
+
1021
+ #[test]
1022
+ fn apply_query_overrides_clamps_out_of_range_edge_inset() {
1023
+ let settings = Arc::new(db::RenderSettings::default());
1024
+ let query = ImageQuery {
1025
+ edge_inset_x: Some(999),
1026
+ edge_inset_y: Some(-5),
1027
+ ..empty_query()
1028
+ };
1029
+ let result =
1030
+ apply_query_overrides(settings, &query, cache::ImageType::Backdrop).unwrap();
1031
+ assert_eq!(result.backdrop_edge_inset_x, db::MAX_EDGE_INSET);
1032
+ assert_eq!(result.backdrop_edge_inset_y, 0);
1033
+ }
1034
+
1035
+ #[test]
1036
+ fn apply_query_overrides_edge_inset_ignored_for_poster() {
1037
+ let settings = Arc::new(db::RenderSettings::default());
1038
+ let query = ImageQuery {
1039
+ edge_inset_x: Some(20),
1040
+ edge_inset_y: Some(20),
1041
+ ..empty_query()
1042
+ };
1043
+ // edge_inset counts as an override (forces a fresh Arc) but only the
1044
+ // backdrop path consumes it — posters are unaffected.
1045
+ let result =
1046
+ apply_query_overrides(settings, &query, cache::ImageType::Poster).unwrap();
1047
+ assert_eq!(result.backdrop_edge_inset_x, 0);
1048
+ assert_eq!(result.backdrop_edge_inset_y, 0);
1049
+ }
1050
+
1051
  #[test]
1052
  fn apply_query_overrides_rejects_invalid_ratings_limit() {
1053
  let settings = Arc::new(db::RenderSettings::default());
api/src/handlers/preview.rs CHANGED
@@ -318,8 +318,12 @@ pub async fn preview_backdrop(
318
  let badge_style = query.badge_style.unwrap_or(BadgeStyle::Vertical).resolve(badge_direction);
319
  let label_style = query.label_style.unwrap_or(db::default_label_style());
320
  let badge_appearance = preview_badge_appearance(&query);
 
 
321
  let ratings_suffix = ratings::ratings_cache_suffix(ratings_order, ratings_exclude, ratings_limit);
322
- let preview_settings = preview_render_settings(cache::ImageType::Backdrop, badge_style, label_style, badge_size, position, badge_direction, badge_appearance, ratings_limit, ratings_order, ratings_exclude);
 
 
323
  let suffix = serve::settings_cache_suffix_with_ratings(&preview_settings, cache::ImageType::Backdrop, image_size, &ratings_suffix);
324
  let cache_key = format!("preview-backdrop:{suffix}");
325
  let cache_path = cache::preview_path(&state.config.cache_dir, cache::ImageType::Backdrop, &suffix, "jpg")?;
@@ -342,7 +346,7 @@ pub async fn preview_backdrop(
342
  let quality = state.config.image_quality;
343
 
344
  let buf = tokio::task::spawn_blocking(move || {
345
- generate::render_backdrop_sync(backdrop_png, &badges, &font, quality, position, badge_style, label_style, badge_appearance, badge_direction, target_width, badge_scale, badge_size)
346
  })
347
  .await
348
  .map_err(|e| AppError::Other(e.to_string()))??;
 
318
  let badge_style = query.badge_style.unwrap_or(BadgeStyle::Vertical).resolve(badge_direction);
319
  let label_style = query.label_style.unwrap_or(db::default_label_style());
320
  let badge_appearance = preview_badge_appearance(&query);
321
+ let edge_inset_x = db::clamp_edge_inset(query.edge_inset_x.unwrap_or(0));
322
+ let edge_inset_y = db::clamp_edge_inset(query.edge_inset_y.unwrap_or(0));
323
  let ratings_suffix = ratings::ratings_cache_suffix(ratings_order, ratings_exclude, ratings_limit);
324
+ let mut preview_settings = preview_render_settings(cache::ImageType::Backdrop, badge_style, label_style, badge_size, position, badge_direction, badge_appearance, ratings_limit, ratings_order, ratings_exclude);
325
+ preview_settings.backdrop_edge_inset_x = edge_inset_x;
326
+ preview_settings.backdrop_edge_inset_y = edge_inset_y;
327
  let suffix = serve::settings_cache_suffix_with_ratings(&preview_settings, cache::ImageType::Backdrop, image_size, &ratings_suffix);
328
  let cache_key = format!("preview-backdrop:{suffix}");
329
  let cache_path = cache::preview_path(&state.config.cache_dir, cache::ImageType::Backdrop, &suffix, "jpg")?;
 
346
  let quality = state.config.image_quality;
347
 
348
  let buf = tokio::task::spawn_blocking(move || {
349
+ generate::render_backdrop_sync(backdrop_png, &badges, &font, quality, position, badge_style, label_style, badge_appearance, badge_direction, target_width, badge_scale, badge_size, edge_inset_x, edge_inset_y)
350
  })
351
  .await
352
  .map_err(|e| AppError::Other(e.to_string()))??;
api/src/image/generate.rs CHANGED
@@ -150,7 +150,13 @@ pub async fn generate_poster(params: ImageParams<'_>) -> Result<Vec<u8>, AppErro
150
  }
151
 
152
  /// Overlay badges in a vertical column, positioned according to `position`.
153
- fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, badge_scale: f32, side_margin_base: u32) {
 
 
 
 
 
 
154
  let vert_spacing = (BADGE_VERT_SPACING as f32 * badge_scale).round() as u32;
155
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
156
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
@@ -162,9 +168,9 @@ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], po
162
 
163
  // Vertical anchor
164
  let start_y = if position.is_top() {
165
- top_margin
166
  } else if position.is_bottom() {
167
- canvas.height().saturating_sub(total_badge_height + bottom_margin)
168
  } else {
169
  // "l", "r" — vertically centered
170
  (canvas.height().saturating_sub(total_badge_height)) / 2
@@ -175,9 +181,9 @@ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], po
175
  let is_right = position.is_right();
176
 
177
  let base_x = if is_left {
178
- side_margin
179
  } else if is_right {
180
- canvas.width().saturating_sub(max_badge_width + side_margin)
181
  } else {
182
  // center
183
  (canvas.width().saturating_sub(max_badge_width)) / 2
@@ -198,7 +204,11 @@ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], po
198
  }
199
 
200
  /// Overlay badges in horizontal rows, positioned according to `position`.
201
- fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, max_per_row: usize, badge_scale: f32, side_margin_base: u32) {
 
 
 
 
202
  let spacing = (BADGE_SPACING as f32 * badge_scale).round() as u32;
203
  let row_spacing = (BADGE_ROW_SPACING as f32 * badge_scale).round() as u32;
204
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
@@ -212,9 +222,9 @@ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], p
212
 
213
  // Vertical anchor
214
  let base_y = if position.is_top() {
215
- top_margin
216
  } else if position.is_bottom() {
217
- canvas.height().saturating_sub(total_height + bottom_margin)
218
  } else {
219
  // "l", "r" — vertically centered
220
  (canvas.height().saturating_sub(total_height)) / 2
@@ -230,9 +240,9 @@ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], p
230
  let y = base_y + row_idx as u32 * (badge_height + row_spacing);
231
 
232
  let start_x = if is_left {
233
- side_margin
234
  } else if is_right {
235
- canvas.width().saturating_sub(row_width + side_margin)
236
  } else {
237
  (canvas.width().saturating_sub(row_width)) / 2
238
  };
@@ -405,9 +415,9 @@ fn overlay_poster_group(
405
  badge_scale: f32,
406
  ) {
407
  if badge_direction.is_vertical() {
408
- overlay_vertical_stack(canvas, badge_images, position, badge_scale, BADGE_SIDE_MARGIN);
409
  } else {
410
- overlay_horizontal_rows(canvas, badge_images, position, max_per_row, badge_scale, BADGE_SIDE_MARGIN);
411
  }
412
  }
413
 
@@ -611,6 +621,8 @@ pub fn render_backdrop_sync(
611
  target_width: u32,
612
  badge_scale: f32,
613
  _badge_size: BadgeSize,
 
 
614
  ) -> Result<Vec<u8>, AppError> {
615
  // A pill is a horizontal lozenge (icon/label left, value right) — never a
616
  // vertical stacked badge, even when the configured style is vertical.
@@ -634,11 +646,19 @@ pub fn render_backdrop_sync(
634
  badge::render_badges_uniform(badges, font, label_style, badge_appearance, badge_scale)
635
  };
636
 
 
 
 
 
 
 
 
 
637
  if badge_direction.is_vertical() {
638
- overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BACKDROP_SIDE_MARGIN);
639
  } else {
640
  // Backdrops are wide (16:9) so all badges fit in a single row
641
- overlay_horizontal_rows(&mut canvas, &badge_images, position, badge_images.len(), badge_scale, BACKDROP_SIDE_MARGIN);
642
  }
643
  }
644
 
@@ -665,6 +685,8 @@ pub async fn generate_backdrop(
665
  target_width: u32,
666
  badge_scale: f32,
667
  badge_size: BadgeSize,
 
 
668
  ) -> Result<Vec<u8>, AppError> {
669
  if render_semaphore.available_permits() == 0 {
670
  tracing::debug!("render queue full, waiting for permit");
@@ -674,7 +696,7 @@ pub async fn generate_backdrop(
674
  tracing::debug!("backdrop render started");
675
  let start = std::time::Instant::now();
676
 
677
- let buf = tokio::task::spawn_blocking(move || render_backdrop_sync(&backdrop_bytes, &badges, &font, quality, position, badge_style, label_style, badge_appearance, badge_direction, target_width, badge_scale, badge_size))
678
  .await
679
  .map_err(|e| AppError::Other(e.to_string()))??;
680
 
@@ -733,7 +755,7 @@ pub fn render_episode_sync(
733
  };
734
 
735
  if badge_direction.is_vertical() {
736
- overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BADGE_SIDE_MARGIN);
737
  } else {
738
  let max_per_row = match badge_size {
739
  BadgeSize::Large | BadgeSize::ExtraLarge => {
@@ -743,7 +765,7 @@ pub fn render_episode_sync(
743
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
744
  }
745
  };
746
- overlay_horizontal_rows(&mut canvas, &badge_images, position, max_per_row, badge_scale, BADGE_SIDE_MARGIN);
747
  }
748
  }
749
 
@@ -920,7 +942,7 @@ mod tests {
920
  fn render_backdrop_no_badges() {
921
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
922
  let png = test_png(640, 360);
923
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
924
  assert!(!result.is_empty());
925
  // Backdrop outputs JPEG
926
  assert_eq!(result[0], 0xFF);
@@ -937,7 +959,7 @@ mod tests {
937
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
938
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
939
  ];
940
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
941
  assert!(!result.is_empty());
942
  assert_eq!(result[0], 0xFF);
943
  assert_eq!(result[1], 0xD8);
@@ -948,7 +970,7 @@ mod tests {
948
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
949
  // Create a backdrop wider than TARGET_WIDTH (1280)
950
  let png = test_png(2560, 1440);
951
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
952
  assert!(!result.is_empty());
953
  assert_eq!(result[0], 0xFF);
954
  assert_eq!(result[1], 0xD8);
@@ -957,7 +979,7 @@ mod tests {
957
  #[test]
958
  fn render_backdrop_invalid_bytes() {
959
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
960
- let result = render_backdrop_sync(b"not an image", &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium);
961
  assert!(result.is_err());
962
  }
963
 
@@ -971,7 +993,7 @@ mod tests {
971
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
972
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
973
  ];
974
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Medium).unwrap();
975
  assert!(!result.is_empty());
976
  assert_eq!(result[0], 0xFF);
977
  assert_eq!(result[1], 0xD8);
@@ -989,7 +1011,7 @@ mod tests {
989
  RatingBadge { source: RatingSource::RtAudience, value: "80%".to_string() },
990
  ];
991
  // Large + Horizontal style — all badges in a single row on wide backdrop
992
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Large).unwrap();
993
  assert!(!result.is_empty());
994
  assert_eq!(result[0], 0xFF);
995
  assert_eq!(result[1], 0xD8);
@@ -1004,12 +1026,42 @@ mod tests {
1004
  let badges = vec![
1005
  RatingBadge { source: RatingSource::Imdb, value: "8.5".to_string() },
1006
  ];
1007
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomLeft, BadgeStyle::Vertical, LabelStyle::Icon, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
1008
  assert!(!result.is_empty());
1009
  assert_eq!(result[0], 0xFF);
1010
  assert_eq!(result[1], 0xD8);
1011
  }
1012
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1013
  // --- Episode rendering tests ---
1014
 
1015
  #[test]
@@ -1189,7 +1241,7 @@ mod tests {
1189
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
1190
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1191
  ];
1192
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Icon, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
1193
  assert!(!result.is_empty());
1194
  assert_eq!(result[0], 0xFF);
1195
  assert_eq!(result[1], 0xD8);
@@ -1237,7 +1289,7 @@ mod tests {
1237
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1238
  RatingBadge { source: RatingSource::RtAudience, value: "40%".to_string() },
1239
  ];
1240
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Official, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
1241
  assert!(!result.is_empty());
1242
  assert_eq!(result[0], 0xFF);
1243
  assert_eq!(result[1], 0xD8);
@@ -1350,7 +1402,7 @@ mod tests {
1350
  let png = test_png(640, 360);
1351
  let sem = Arc::new(Semaphore::new(1));
1352
 
1353
- let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, sem.clone(), 1280, 1.0, BadgeSize::Medium).await;
1354
  assert!(result.is_ok());
1355
  assert_eq!(sem.available_permits(), 1);
1356
  }
@@ -1362,7 +1414,7 @@ mod tests {
1362
  let sem = Arc::new(Semaphore::new(1));
1363
  sem.close();
1364
 
1365
- let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, sem, 1280, 1.0, BadgeSize::Medium).await;
1366
  assert!(result.is_err());
1367
  }
1368
 
@@ -1692,7 +1744,7 @@ mod tests {
1692
  fn render_backdrop_at_large_target_width() {
1693
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
1694
  let png = test_png(640, 360);
1695
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 3840, 2.0, BadgeSize::Medium).unwrap();
1696
  assert!(!result.is_empty());
1697
  assert_eq!(result[0], 0xFF);
1698
  assert_eq!(result[1], 0xD8);
 
150
  }
151
 
152
  /// Overlay badges in a vertical column, positioned according to `position`.
153
+ ///
154
+ /// `extra_x` / `extra_y` are additional edge insets in pixels (already resolved
155
+ /// for this canvas) that push the badges further from the anchored edge. They
156
+ /// only take effect on the edge the position anchors to: `extra_y` on top/bottom
157
+ /// anchors, `extra_x` on left/right anchors. Centered axes ignore their inset.
158
+ /// Callers that don't want insets pass `0, 0`.
159
+ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, badge_scale: f32, side_margin_base: u32, extra_x: u32, extra_y: u32) {
160
  let vert_spacing = (BADGE_VERT_SPACING as f32 * badge_scale).round() as u32;
161
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
162
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
 
168
 
169
  // Vertical anchor
170
  let start_y = if position.is_top() {
171
+ top_margin + extra_y
172
  } else if position.is_bottom() {
173
+ canvas.height().saturating_sub(total_badge_height + bottom_margin + extra_y)
174
  } else {
175
  // "l", "r" — vertically centered
176
  (canvas.height().saturating_sub(total_badge_height)) / 2
 
181
  let is_right = position.is_right();
182
 
183
  let base_x = if is_left {
184
+ side_margin + extra_x
185
  } else if is_right {
186
+ canvas.width().saturating_sub(max_badge_width + side_margin + extra_x)
187
  } else {
188
  // center
189
  (canvas.width().saturating_sub(max_badge_width)) / 2
 
204
  }
205
 
206
  /// Overlay badges in horizontal rows, positioned according to `position`.
207
+ ///
208
+ /// `extra_x` / `extra_y` behave as in [`overlay_vertical_stack`]: edge insets in
209
+ /// resolved pixels that only apply to the anchored edge (centered axes ignore
210
+ /// them). Callers that don't want insets pass `0, 0`.
211
+ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, max_per_row: usize, badge_scale: f32, side_margin_base: u32, extra_x: u32, extra_y: u32) {
212
  let spacing = (BADGE_SPACING as f32 * badge_scale).round() as u32;
213
  let row_spacing = (BADGE_ROW_SPACING as f32 * badge_scale).round() as u32;
214
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
 
222
 
223
  // Vertical anchor
224
  let base_y = if position.is_top() {
225
+ top_margin + extra_y
226
  } else if position.is_bottom() {
227
+ canvas.height().saturating_sub(total_height + bottom_margin + extra_y)
228
  } else {
229
  // "l", "r" — vertically centered
230
  (canvas.height().saturating_sub(total_height)) / 2
 
240
  let y = base_y + row_idx as u32 * (badge_height + row_spacing);
241
 
242
  let start_x = if is_left {
243
+ side_margin + extra_x
244
  } else if is_right {
245
+ canvas.width().saturating_sub(row_width + side_margin + extra_x)
246
  } else {
247
  (canvas.width().saturating_sub(row_width)) / 2
248
  };
 
415
  badge_scale: f32,
416
  ) {
417
  if badge_direction.is_vertical() {
418
+ overlay_vertical_stack(canvas, badge_images, position, badge_scale, BADGE_SIDE_MARGIN, 0, 0);
419
  } else {
420
+ overlay_horizontal_rows(canvas, badge_images, position, max_per_row, badge_scale, BADGE_SIDE_MARGIN, 0, 0);
421
  }
422
  }
423
 
 
621
  target_width: u32,
622
  badge_scale: f32,
623
  _badge_size: BadgeSize,
624
+ edge_inset_x: i32,
625
+ edge_inset_y: i32,
626
  ) -> Result<Vec<u8>, AppError> {
627
  // A pill is a horizontal lozenge (icon/label left, value right) — never a
628
  // vertical stacked badge, even when the configured style is vertical.
 
646
  badge::render_badges_uniform(badges, font, label_style, badge_appearance, badge_scale)
647
  };
648
 
649
+ // Edge insets are a percentage of the canvas dimension, so they stay
650
+ // proportional across image sizes (small/medium/large). The overlay
651
+ // functions only apply each axis on the edge the position anchors to.
652
+ let inset_pct_x = edge_inset_x.clamp(0, 100) as f32 / 100.0;
653
+ let inset_pct_y = edge_inset_y.clamp(0, 100) as f32 / 100.0;
654
+ let extra_x = (canvas.width() as f32 * inset_pct_x).round() as u32;
655
+ let extra_y = (canvas.height() as f32 * inset_pct_y).round() as u32;
656
+
657
  if badge_direction.is_vertical() {
658
+ overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BACKDROP_SIDE_MARGIN, extra_x, extra_y);
659
  } else {
660
  // Backdrops are wide (16:9) so all badges fit in a single row
661
+ overlay_horizontal_rows(&mut canvas, &badge_images, position, badge_images.len(), badge_scale, BACKDROP_SIDE_MARGIN, extra_x, extra_y);
662
  }
663
  }
664
 
 
685
  target_width: u32,
686
  badge_scale: f32,
687
  badge_size: BadgeSize,
688
+ edge_inset_x: i32,
689
+ edge_inset_y: i32,
690
  ) -> Result<Vec<u8>, AppError> {
691
  if render_semaphore.available_permits() == 0 {
692
  tracing::debug!("render queue full, waiting for permit");
 
696
  tracing::debug!("backdrop render started");
697
  let start = std::time::Instant::now();
698
 
699
+ let buf = tokio::task::spawn_blocking(move || render_backdrop_sync(&backdrop_bytes, &badges, &font, quality, position, badge_style, label_style, badge_appearance, badge_direction, target_width, badge_scale, badge_size, edge_inset_x, edge_inset_y))
700
  .await
701
  .map_err(|e| AppError::Other(e.to_string()))??;
702
 
 
755
  };
756
 
757
  if badge_direction.is_vertical() {
758
+ overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BADGE_SIDE_MARGIN, 0, 0);
759
  } else {
760
  let max_per_row = match badge_size {
761
  BadgeSize::Large | BadgeSize::ExtraLarge => {
 
765
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
766
  }
767
  };
768
+ overlay_horizontal_rows(&mut canvas, &badge_images, position, max_per_row, badge_scale, BADGE_SIDE_MARGIN, 0, 0);
769
  }
770
  }
771
 
 
942
  fn render_backdrop_no_badges() {
943
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
944
  let png = test_png(640, 360);
945
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
946
  assert!(!result.is_empty());
947
  // Backdrop outputs JPEG
948
  assert_eq!(result[0], 0xFF);
 
959
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
960
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
961
  ];
962
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
963
  assert!(!result.is_empty());
964
  assert_eq!(result[0], 0xFF);
965
  assert_eq!(result[1], 0xD8);
 
970
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
971
  // Create a backdrop wider than TARGET_WIDTH (1280)
972
  let png = test_png(2560, 1440);
973
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
974
  assert!(!result.is_empty());
975
  assert_eq!(result[0], 0xFF);
976
  assert_eq!(result[1], 0xD8);
 
979
  #[test]
980
  fn render_backdrop_invalid_bytes() {
981
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
982
+ let result = render_backdrop_sync(b"not an image", &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0);
983
  assert!(result.is_err());
984
  }
985
 
 
993
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
994
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
995
  ];
996
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
997
  assert!(!result.is_empty());
998
  assert_eq!(result[0], 0xFF);
999
  assert_eq!(result[1], 0xD8);
 
1011
  RatingBadge { source: RatingSource::RtAudience, value: "80%".to_string() },
1012
  ];
1013
  // Large + Horizontal style — all badges in a single row on wide backdrop
1014
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Large, 0, 0).unwrap();
1015
  assert!(!result.is_empty());
1016
  assert_eq!(result[0], 0xFF);
1017
  assert_eq!(result[1], 0xD8);
 
1026
  let badges = vec![
1027
  RatingBadge { source: RatingSource::Imdb, value: "8.5".to_string() },
1028
  ];
1029
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomLeft, BadgeStyle::Vertical, LabelStyle::Icon, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
1030
  assert!(!result.is_empty());
1031
  assert_eq!(result[0], 0xFF);
1032
  assert_eq!(result[1], 0xD8);
1033
  }
1034
 
1035
+ #[test]
1036
+ fn render_backdrop_edge_inset_shifts_only_active_axis() {
1037
+ use crate::services::ratings::{RatingBadge, RatingSource};
1038
+
1039
+ let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
1040
+ let png = test_png(1280, 720);
1041
+ let badges = vec![
1042
+ RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
1043
+ RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1044
+ ];
1045
+ let render = |pos: BadgePosition, x: i32, y: i32| {
1046
+ render_backdrop_sync(&png, &badges, &font, 85, pos, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, x, y).unwrap()
1047
+ };
1048
+
1049
+ // TopRight anchors to both a horizontal and vertical edge, so both insets
1050
+ // move the badges (the JPEG encoder is deterministic, so bytes differ).
1051
+ assert_ne!(render(BadgePosition::TopRight, 0, 0), render(BadgePosition::TopRight, 10, 0));
1052
+ assert_ne!(render(BadgePosition::TopRight, 0, 0), render(BadgePosition::TopRight, 0, 10));
1053
+
1054
+ // TopCenter is horizontally centered → the horizontal inset is a no-op,
1055
+ // but the vertical inset still applies.
1056
+ assert_eq!(render(BadgePosition::TopCenter, 0, 0), render(BadgePosition::TopCenter, 10, 0));
1057
+ assert_ne!(render(BadgePosition::TopCenter, 0, 0), render(BadgePosition::TopCenter, 0, 10));
1058
+
1059
+ // Right is vertically centered → the vertical inset is a no-op, but the
1060
+ // horizontal inset still applies.
1061
+ assert_eq!(render(BadgePosition::Right, 0, 0), render(BadgePosition::Right, 0, 10));
1062
+ assert_ne!(render(BadgePosition::Right, 0, 0), render(BadgePosition::Right, 10, 0));
1063
+ }
1064
+
1065
  // --- Episode rendering tests ---
1066
 
1067
  #[test]
 
1241
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
1242
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1243
  ];
1244
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Icon, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
1245
  assert!(!result.is_empty());
1246
  assert_eq!(result[0], 0xFF);
1247
  assert_eq!(result[1], 0xD8);
 
1289
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1290
  RatingBadge { source: RatingSource::RtAudience, value: "40%".to_string() },
1291
  ];
1292
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Official, BadgeAppearance::default(), BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium, 0, 0).unwrap();
1293
  assert!(!result.is_empty());
1294
  assert_eq!(result[0], 0xFF);
1295
  assert_eq!(result[1], 0xD8);
 
1402
  let png = test_png(640, 360);
1403
  let sem = Arc::new(Semaphore::new(1));
1404
 
1405
+ let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, sem.clone(), 1280, 1.0, BadgeSize::Medium, 0, 0).await;
1406
  assert!(result.is_ok());
1407
  assert_eq!(sem.available_permits(), 1);
1408
  }
 
1414
  let sem = Arc::new(Semaphore::new(1));
1415
  sem.close();
1416
 
1417
+ let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, sem, 1280, 1.0, BadgeSize::Medium, 0, 0).await;
1418
  assert!(result.is_err());
1419
  }
1420
 
 
1744
  fn render_backdrop_at_large_target_width() {
1745
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
1746
  let png = test_png(640, 360);
1747
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeAppearance::default(), BadgeDirection::Vertical, 3840, 2.0, BadgeSize::Medium, 0, 0).unwrap();
1748
  assert!(!result.is_empty());
1749
  assert_eq!(result[0], 0xFF);
1750
  assert_eq!(result[1], 0xD8);
api/src/image/serve.rs CHANGED
@@ -51,6 +51,9 @@ struct LbRenderParams {
51
  label_style: LabelStyle,
52
  badge_size: BadgeSize,
53
  appearance: BadgeAppearance,
 
 
 
54
  }
55
 
56
  impl LbRenderParams {
@@ -80,7 +83,15 @@ impl LbRenderParams {
80
  LogoBackdropKind::Logo => settings.logo_appearance(),
81
  LogoBackdropKind::Backdrop => settings.backdrop_appearance(),
82
  };
83
- Self { position, badge_direction, badge_style, label_style, badge_size, appearance }
 
 
 
 
 
 
 
 
84
  }
85
  }
86
 
@@ -114,6 +125,24 @@ pub fn badge_background_cache_suffix(background: &str) -> String {
114
  format!(".bg{background}")
115
  }
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  /// Resolve an optional image size, defaulting to Medium.
118
  pub fn resolve_image_size(size: Option<ImageSize>) -> ImageSize {
119
  size.unwrap_or(ImageSize::Medium)
@@ -183,6 +212,8 @@ pub fn settings_cache_suffix_with_ratings(
183
  backdrop_badge_size: _,
184
  backdrop_position: _,
185
  backdrop_badge_direction: _,
 
 
186
  episode_ratings_limit: _,
187
  episode_badge_style: _,
188
  episode_label_style: _,
@@ -243,7 +274,10 @@ pub fn settings_cache_suffix_with_ratings(
243
  let bsz = settings.backdrop_badge_size.cache_suffix();
244
  let shp = badge_shape_cache_suffix(settings.backdrop_badge_shape.as_str());
245
  let bgd = badge_background_cache_suffix(settings.backdrop_badge_background.as_str());
246
- format!("{rs}{ps}{bs}{ls}{bd}{bsz}{shp}{bgd}{is_suffix}")
 
 
 
247
  }
248
  cache::ImageType::Episode => {
249
  let ps = position_cache_suffix(settings.episode_position.as_str());
@@ -1165,7 +1199,7 @@ fn trigger_logo_backdrop_refresh(
1165
  };
1166
  let bytes = match lb_kind {
1167
  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?,
1168
- 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).await?,
1169
  };
1170
 
1171
  Ok((bytes, cross_ids.release_date.clone(), image_type, cross_ids))
@@ -1901,6 +1935,8 @@ pub async fn handle_logo_backdrop_inner(
1901
  type_position: BadgePosition,
1902
  type_badge_direction: BadgeDirection,
1903
  type_badge_size: BadgeSize,
 
 
1904
  badge_size_factor: f32,
1905
  label: &'static str,
1906
  resolved: id::ResolvedId,
@@ -1923,6 +1959,8 @@ pub async fn handle_logo_backdrop_inner(
1923
  type_position: params.position,
1924
  type_badge_direction: params.badge_direction,
1925
  type_badge_size: params.badge_size,
 
 
1926
  badge_size_factor: params.badge_size.scale_factor(),
1927
  label,
1928
  resolved: resolved.clone(),
@@ -2006,7 +2044,7 @@ pub async fn handle_logo_backdrop_inner(
2006
  };
2007
  let bytes = match lb_kind {
2008
  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?,
2009
- 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).await?,
2010
  };
2011
 
2012
  let release_date = ctx.cross_ids.release_date.clone();
@@ -2523,6 +2561,51 @@ mod tests {
2523
  assert_eq!(p.badge_style, BadgeStyle::Vertical);
2524
  }
2525
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2526
  #[test]
2527
  fn tmdb_poster_variant_all_combinations() {
2528
  // Default case: no variant (backward compatible)
 
51
  label_style: LabelStyle,
52
  badge_size: BadgeSize,
53
  appearance: BadgeAppearance,
54
+ /// Backdrop edge insets (percent of dimension). Zero for logos.
55
+ edge_inset_x: i32,
56
+ edge_inset_y: i32,
57
  }
58
 
59
  impl LbRenderParams {
 
83
  LogoBackdropKind::Logo => settings.logo_appearance(),
84
  LogoBackdropKind::Backdrop => settings.backdrop_appearance(),
85
  };
86
+ // Edge insets are backdrop-only; logos render at a fixed anchor.
87
+ let (edge_inset_x, edge_inset_y) = match lb_kind {
88
+ LogoBackdropKind::Logo => (0, 0),
89
+ LogoBackdropKind::Backdrop => (
90
+ crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_x),
91
+ crate::services::db::clamp_edge_inset(settings.backdrop_edge_inset_y),
92
+ ),
93
+ };
94
+ Self { position, badge_direction, badge_style, label_style, badge_size, appearance, edge_inset_x, edge_inset_y }
95
  }
96
  }
97
 
 
125
  format!(".bg{background}")
126
  }
127
 
128
+ /// Returns a cache key suffix for the backdrop edge inset.
129
+ ///
130
+ /// Empty when the (position-gated) inset is zero, so the default configuration
131
+ /// keeps its existing cache keys — no migration needed. This mirrors the
132
+ /// optional `.x1` (poster split) and `.blur` (episode) tokens. Each axis is only
133
+ /// tokenized when it actually affects placement: horizontal for left/right
134
+ /// positions, vertical for top/bottom positions.
135
+ pub fn edge_inset_cache_suffix(position: BadgePosition, inset_x: i32, inset_y: i32) -> String {
136
+ let mut out = String::new();
137
+ if (position.is_left() || position.is_right()) && inset_x > 0 {
138
+ out.push_str(&format!(".eh{inset_x}"));
139
+ }
140
+ if (position.is_top() || position.is_bottom()) && inset_y > 0 {
141
+ out.push_str(&format!(".ev{inset_y}"));
142
+ }
143
+ out
144
+ }
145
+
146
  /// Resolve an optional image size, defaulting to Medium.
147
  pub fn resolve_image_size(size: Option<ImageSize>) -> ImageSize {
148
  size.unwrap_or(ImageSize::Medium)
 
212
  backdrop_badge_size: _,
213
  backdrop_position: _,
214
  backdrop_badge_direction: _,
215
+ backdrop_edge_inset_x: _,
216
+ backdrop_edge_inset_y: _,
217
  episode_ratings_limit: _,
218
  episode_badge_style: _,
219
  episode_label_style: _,
 
274
  let bsz = settings.backdrop_badge_size.cache_suffix();
275
  let shp = badge_shape_cache_suffix(settings.backdrop_badge_shape.as_str());
276
  let bgd = badge_background_cache_suffix(settings.backdrop_badge_background.as_str());
277
+ // Edge inset sits after background (before image size) and is only
278
+ // present when non-zero, so default backdrop keys are unchanged.
279
+ let ei = edge_inset_cache_suffix(settings.backdrop_position, settings.backdrop_edge_inset_x, settings.backdrop_edge_inset_y);
280
+ format!("{rs}{ps}{bs}{ls}{bd}{bsz}{shp}{bgd}{ei}{is_suffix}")
281
  }
282
  cache::ImageType::Episode => {
283
  let ps = position_cache_suffix(settings.episode_position.as_str());
 
1199
  };
1200
  let bytes = match lb_kind {
1201
  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?,
1202
+ 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?,
1203
  };
1204
 
1205
  Ok((bytes, cross_ids.release_date.clone(), image_type, cross_ids))
 
1935
  type_position: BadgePosition,
1936
  type_badge_direction: BadgeDirection,
1937
  type_badge_size: BadgeSize,
1938
+ type_edge_inset_x: i32,
1939
+ type_edge_inset_y: i32,
1940
  badge_size_factor: f32,
1941
  label: &'static str,
1942
  resolved: id::ResolvedId,
 
1959
  type_position: params.position,
1960
  type_badge_direction: params.badge_direction,
1961
  type_badge_size: params.badge_size,
1962
+ type_edge_inset_x: params.edge_inset_x,
1963
+ type_edge_inset_y: params.edge_inset_y,
1964
  badge_size_factor: params.badge_size.scale_factor(),
1965
  label,
1966
  resolved: resolved.clone(),
 
2044
  };
2045
  let bytes = match lb_kind {
2046
  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?,
2047
+ 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?,
2048
  };
2049
 
2050
  let release_date = ctx.cross_ids.release_date.clone();
 
2561
  assert_eq!(p.badge_style, BadgeStyle::Vertical);
2562
  }
2563
 
2564
+ #[test]
2565
+ fn lb_render_params_backdrop_reads_edge_insets() {
2566
+ let settings = RenderSettings {
2567
+ backdrop_position: BadgePosition::TopRight,
2568
+ backdrop_edge_inset_x: 8,
2569
+ backdrop_edge_inset_y: 3,
2570
+ ..RenderSettings::default()
2571
+ };
2572
+ let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
2573
+ assert_eq!(p.edge_inset_x, 8);
2574
+ assert_eq!(p.edge_inset_y, 3);
2575
+ // Logos never inset.
2576
+ let logo = LbRenderParams::from_settings(LogoBackdropKind::Logo, &settings);
2577
+ assert_eq!(logo.edge_inset_x, 0);
2578
+ assert_eq!(logo.edge_inset_y, 0);
2579
+ }
2580
+
2581
+ #[test]
2582
+ fn edge_inset_suffix_only_tokenizes_active_axes() {
2583
+ // Corner: both axes apply.
2584
+ assert_eq!(edge_inset_cache_suffix(BadgePosition::TopRight, 8, 3), ".eh8.ev3");
2585
+ assert_eq!(edge_inset_cache_suffix(BadgePosition::BottomLeft, 5, 10), ".eh5.ev10");
2586
+ // Right edge is vertically centered → vertical inset is dropped.
2587
+ assert_eq!(edge_inset_cache_suffix(BadgePosition::Right, 8, 3), ".eh8");
2588
+ // Top center is horizontally centered → horizontal inset is dropped.
2589
+ assert_eq!(edge_inset_cache_suffix(BadgePosition::TopCenter, 8, 3), ".ev3");
2590
+ // Zero (or default) insets add no token, so existing keys are unchanged.
2591
+ assert_eq!(edge_inset_cache_suffix(BadgePosition::TopRight, 0, 0), "");
2592
+ }
2593
+
2594
+ #[test]
2595
+ fn backdrop_cache_suffix_includes_edge_inset_only_when_set() {
2596
+ let base = RenderSettings { backdrop_position: BadgePosition::TopRight, ..RenderSettings::default() };
2597
+ let without = settings_cache_suffix(&base, cache::ImageType::Backdrop, None);
2598
+ let with = settings_cache_suffix(
2599
+ &RenderSettings { backdrop_edge_inset_x: 8, backdrop_edge_inset_y: 3, ..base.clone() },
2600
+ cache::ImageType::Backdrop,
2601
+ None,
2602
+ );
2603
+ // Default (0/0) must match the pre-feature key; a non-zero inset diverges.
2604
+ assert!(!without.contains(".eh") && !without.contains(".ev"));
2605
+ assert!(with.contains(".eh8.ev3"));
2606
+ assert_ne!(without, with);
2607
+ }
2608
+
2609
  #[test]
2610
  fn tmdb_poster_variant_all_combinations() {
2611
  // Default case: no variant (backward compatible)
api/src/lib.rs CHANGED
@@ -386,4 +386,12 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
386
  "ALTER TABLE api_key_settings ADD COLUMN poster_fit TEXT NOT NULL DEFAULT 'native'",
387
  "duplicate column",
388
  ),
 
 
 
 
 
 
 
 
389
  ];
 
386
  "ALTER TABLE api_key_settings ADD COLUMN poster_fit TEXT NOT NULL DEFAULT 'native'",
387
  "duplicate column",
388
  ),
389
+ (
390
+ "ALTER TABLE api_key_settings ADD COLUMN backdrop_edge_inset_x INTEGER NOT NULL DEFAULT 0",
391
+ "duplicate column",
392
+ ),
393
+ (
394
+ "ALTER TABLE api_key_settings ADD COLUMN backdrop_edge_inset_y INTEGER NOT NULL DEFAULT 0",
395
+ "duplicate column",
396
+ ),
397
  ];
api/src/services/db.rs CHANGED
@@ -659,6 +659,21 @@ pub fn default_backdrop_badge_direction() -> BadgeDirection {
659
  BadgeDirection::Default
660
  }
661
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
662
  // --- Badge size ---
663
 
664
  pub fn default_badge_size() -> BadgeSize {
@@ -2003,6 +2018,8 @@ pub struct UpsertApiKeySettings<'a> {
2003
  pub logo_badge_background: &'a str,
2004
  pub backdrop_badge_background: &'a str,
2005
  pub episode_badge_background: &'a str,
 
 
2006
  }
2007
 
2008
  pub async fn upsert_api_key_settings(
@@ -2049,6 +2066,8 @@ pub async fn upsert_api_key_settings(
2049
  logo_badge_background: Set(params.logo_badge_background.to_string()),
2050
  backdrop_badge_background: Set(params.backdrop_badge_background.to_string()),
2051
  episode_badge_background: Set(params.episode_badge_background.to_string()),
 
 
2052
  };
2053
  api_key_settings::Entity::insert(model)
2054
  .on_conflict(
@@ -2092,6 +2111,8 @@ pub async fn upsert_api_key_settings(
2092
  api_key_settings::Column::LogoBadgeBackground,
2093
  api_key_settings::Column::BackdropBadgeBackground,
2094
  api_key_settings::Column::EpisodeBadgeBackground,
 
 
2095
  ])
2096
  .to_owned(),
2097
  )
@@ -2143,6 +2164,12 @@ pub struct RenderSettings {
2143
  pub backdrop_badge_size: BadgeSize,
2144
  pub backdrop_position: BadgePosition,
2145
  pub backdrop_badge_direction: BadgeDirection,
 
 
 
 
 
 
2146
  pub episode_ratings_limit: i32,
2147
  pub episode_badge_style: BadgeStyle,
2148
  pub episode_label_style: LabelStyle,
@@ -2206,6 +2233,8 @@ impl Default for RenderSettings {
2206
  backdrop_badge_size: BadgeSize::Medium,
2207
  backdrop_position: default_backdrop_position(),
2208
  backdrop_badge_direction: default_backdrop_badge_direction(),
 
 
2209
  episode_ratings_limit: default_episode_ratings_limit(),
2210
  episode_badge_style: default_episode_badge_style(),
2211
  episode_label_style: default_label_style(),
@@ -2278,6 +2307,16 @@ pub fn parse_global_render_settings(globals: &HashMap<String, String>) -> Render
2278
  backdrop_badge_size: global_or(globals, "backdrop_badge_size", BadgeSize::parse, defaults.backdrop_badge_size),
2279
  backdrop_position: global_or(globals, "backdrop_position", BadgePosition::parse, defaults.backdrop_position),
2280
  backdrop_badge_direction: global_or(globals, "backdrop_badge_direction", BadgeDirection::parse, defaults.backdrop_badge_direction),
 
 
 
 
 
 
 
 
 
 
2281
  episode_ratings_limit: globals
2282
  .get("episode_ratings_limit")
2283
  .and_then(|v| v.parse().ok())
@@ -2335,6 +2374,8 @@ pub async fn get_effective_render_settings(
2335
  backdrop_badge_size: parse_setting_or_default(&s.backdrop_badge_size, "backdrop_badge_size", BadgeSize::parse, BadgeSize::Medium),
2336
  backdrop_position: parse_setting_or_default(&s.backdrop_position, "backdrop_position", BadgePosition::parse, default_backdrop_position()),
2337
  backdrop_badge_direction: parse_setting_or_default(&s.backdrop_badge_direction, "backdrop_badge_direction", BadgeDirection::parse, default_backdrop_badge_direction()),
 
 
2338
  episode_ratings_limit: s.episode_ratings_limit,
2339
  episode_badge_style: parse_setting_or_default(&s.episode_badge_style, "episode_badge_style", BadgeStyle::parse, default_episode_badge_style()),
2340
  episode_label_style: parse_setting_or_default(&s.episode_label_style, "episode_label_style", LabelStyle::parse, LabelStyle::Official),
 
659
  BadgeDirection::Default
660
  }
661
 
662
+ /// Maximum backdrop edge inset, expressed as a percentage of the image
663
+ /// dimension. Capped at half so the inset can never push past the centre.
664
+ pub const MAX_EDGE_INSET: i32 = 50;
665
+
666
+ pub fn default_backdrop_edge_inset() -> i32 {
667
+ 0
668
+ }
669
+
670
+ /// Clamp a stored/requested edge inset to the supported `0..=MAX_EDGE_INSET`
671
+ /// range so out-of-range values can never produce runaway cache keys or
672
+ /// nonsensical placement.
673
+ pub fn clamp_edge_inset(value: i32) -> i32 {
674
+ value.clamp(0, MAX_EDGE_INSET)
675
+ }
676
+
677
  // --- Badge size ---
678
 
679
  pub fn default_badge_size() -> BadgeSize {
 
2018
  pub logo_badge_background: &'a str,
2019
  pub backdrop_badge_background: &'a str,
2020
  pub episode_badge_background: &'a str,
2021
+ pub backdrop_edge_inset_x: i32,
2022
+ pub backdrop_edge_inset_y: i32,
2023
  }
2024
 
2025
  pub async fn upsert_api_key_settings(
 
2066
  logo_badge_background: Set(params.logo_badge_background.to_string()),
2067
  backdrop_badge_background: Set(params.backdrop_badge_background.to_string()),
2068
  episode_badge_background: Set(params.episode_badge_background.to_string()),
2069
+ backdrop_edge_inset_x: Set(params.backdrop_edge_inset_x),
2070
+ backdrop_edge_inset_y: Set(params.backdrop_edge_inset_y),
2071
  };
2072
  api_key_settings::Entity::insert(model)
2073
  .on_conflict(
 
2111
  api_key_settings::Column::LogoBadgeBackground,
2112
  api_key_settings::Column::BackdropBadgeBackground,
2113
  api_key_settings::Column::EpisodeBadgeBackground,
2114
+ api_key_settings::Column::BackdropEdgeInsetX,
2115
+ api_key_settings::Column::BackdropEdgeInsetY,
2116
  ])
2117
  .to_owned(),
2118
  )
 
2164
  pub backdrop_badge_size: BadgeSize,
2165
  pub backdrop_position: BadgePosition,
2166
  pub backdrop_badge_direction: BadgeDirection,
2167
+ /// Inset (percent of width) of backdrop ratings from the anchored
2168
+ /// horizontal edge. Only applies to left/right positions.
2169
+ pub backdrop_edge_inset_x: i32,
2170
+ /// Inset (percent of height) of backdrop ratings from the anchored
2171
+ /// vertical edge. Only applies to top/bottom positions.
2172
+ pub backdrop_edge_inset_y: i32,
2173
  pub episode_ratings_limit: i32,
2174
  pub episode_badge_style: BadgeStyle,
2175
  pub episode_label_style: LabelStyle,
 
2233
  backdrop_badge_size: BadgeSize::Medium,
2234
  backdrop_position: default_backdrop_position(),
2235
  backdrop_badge_direction: default_backdrop_badge_direction(),
2236
+ backdrop_edge_inset_x: default_backdrop_edge_inset(),
2237
+ backdrop_edge_inset_y: default_backdrop_edge_inset(),
2238
  episode_ratings_limit: default_episode_ratings_limit(),
2239
  episode_badge_style: default_episode_badge_style(),
2240
  episode_label_style: default_label_style(),
 
2307
  backdrop_badge_size: global_or(globals, "backdrop_badge_size", BadgeSize::parse, defaults.backdrop_badge_size),
2308
  backdrop_position: global_or(globals, "backdrop_position", BadgePosition::parse, defaults.backdrop_position),
2309
  backdrop_badge_direction: global_or(globals, "backdrop_badge_direction", BadgeDirection::parse, defaults.backdrop_badge_direction),
2310
+ backdrop_edge_inset_x: globals
2311
+ .get("backdrop_edge_inset_x")
2312
+ .and_then(|v| v.parse().ok())
2313
+ .map(clamp_edge_inset)
2314
+ .unwrap_or(defaults.backdrop_edge_inset_x),
2315
+ backdrop_edge_inset_y: globals
2316
+ .get("backdrop_edge_inset_y")
2317
+ .and_then(|v| v.parse().ok())
2318
+ .map(clamp_edge_inset)
2319
+ .unwrap_or(defaults.backdrop_edge_inset_y),
2320
  episode_ratings_limit: globals
2321
  .get("episode_ratings_limit")
2322
  .and_then(|v| v.parse().ok())
 
2374
  backdrop_badge_size: parse_setting_or_default(&s.backdrop_badge_size, "backdrop_badge_size", BadgeSize::parse, BadgeSize::Medium),
2375
  backdrop_position: parse_setting_or_default(&s.backdrop_position, "backdrop_position", BadgePosition::parse, default_backdrop_position()),
2376
  backdrop_badge_direction: parse_setting_or_default(&s.backdrop_badge_direction, "backdrop_badge_direction", BadgeDirection::parse, default_backdrop_badge_direction()),
2377
+ backdrop_edge_inset_x: clamp_edge_inset(s.backdrop_edge_inset_x),
2378
+ backdrop_edge_inset_y: clamp_edge_inset(s.backdrop_edge_inset_y),
2379
  episode_ratings_limit: s.episode_ratings_limit,
2380
  episode_badge_style: parse_setting_or_default(&s.episode_badge_style, "episode_badge_style", BadgeStyle::parse, default_episode_badge_style()),
2381
  episode_label_style: parse_setting_or_default(&s.episode_label_style, "episode_label_style", LabelStyle::parse, LabelStyle::Official),
api/tests/db_operations.rs CHANGED
@@ -45,6 +45,8 @@ fn test_upsert(key_id: i32) -> UpsertApiKeySettings<'static> {
45
  logo_badge_background: "d",
46
  backdrop_badge_background: "d",
47
  episode_badge_background: "d",
 
 
48
  }
49
  }
50
 
 
45
  logo_badge_background: "d",
46
  backdrop_badge_background: "d",
47
  episode_badge_background: "d",
48
+ backdrop_edge_inset_x: 0,
49
+ backdrop_edge_inset_y: 0,
50
  }
51
  }
52
 
api/tests/preview.rs CHANGED
@@ -169,6 +169,34 @@ async fn preview_cache_differs_for_different_params() {
169
  assert_ne!(body1, body2);
170
  }
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  #[tokio::test]
173
  async fn preview_cache_populates_entry_count() {
174
  let (app, state) = common::setup_test_app().await;
 
169
  assert_ne!(body1, body2);
170
  }
171
 
172
+ #[tokio::test]
173
+ async fn preview_backdrop_edge_inset_shifts_only_active_axis() {
174
+ let (app, _state) = common::setup_test_app().await;
175
+ let token = common::setup_admin(&app).await;
176
+
177
+ let fetch = |q: &str| {
178
+ let app = app.clone();
179
+ let token = token.clone();
180
+ let uri = format!("/api/admin/preview/backdrop?{q}");
181
+ async move {
182
+ let res = app.oneshot(authed_get(&uri, &token)).await.unwrap();
183
+ assert_eq!(res.status(), StatusCode::OK);
184
+ res.into_body().collect().await.unwrap().to_bytes()
185
+ }
186
+ };
187
+
188
+ // Top-right anchors to both edges → both insets change the rendered image.
189
+ let base = fetch("position=tr").await;
190
+ assert_ne!(base, fetch("position=tr&edge_inset_x=12").await);
191
+ assert_ne!(base, fetch("position=tr&edge_inset_y=8").await);
192
+
193
+ // Top-center is horizontally centered → the horizontal inset is a no-op and
194
+ // must reuse the same cache key / image, while the vertical inset applies.
195
+ let tc = fetch("position=tc").await;
196
+ assert_eq!(tc, fetch("position=tc&edge_inset_x=12").await);
197
+ assert_ne!(tc, fetch("position=tc&edge_inset_y=8").await);
198
+ }
199
+
200
  #[tokio::test]
201
  async fn preview_cache_populates_entry_count() {
202
  let (app, state) = common::setup_test_app().await;
web/src/__tests__/components/RenderSettingsForm.spec.ts CHANGED
@@ -32,6 +32,8 @@ const defaultSettings: RenderSettings = {
32
  backdrop_badge_size: 'm',
33
  backdrop_position: 'tr',
34
  backdrop_badge_direction: 'v',
 
 
35
  episode_ratings_limit: 1,
36
  episode_badge_style: 'v',
37
  episode_label_style: 'o',
@@ -369,4 +371,82 @@ describe('RenderSettingsForm', () => {
369
  expect(wrapper.find('[data-testid="episode-badge-direction-select"]').exists()).toBe(true)
370
  expect(wrapper.find('[data-testid="episode-blur-checkbox"]').exists()).toBe(true)
371
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
372
  })
 
32
  backdrop_badge_size: 'm',
33
  backdrop_position: 'tr',
34
  backdrop_badge_direction: 'v',
35
+ backdrop_edge_inset_x: 0,
36
+ backdrop_edge_inset_y: 0,
37
  episode_ratings_limit: 1,
38
  episode_badge_style: 'v',
39
  episode_label_style: 'o',
 
371
  expect(wrapper.find('[data-testid="episode-badge-direction-select"]').exists()).toBe(true)
372
  expect(wrapper.find('[data-testid="episode-blur-checkbox"]').exists()).toBe(true)
373
  })
374
+
375
+ // --- Backdrop edge inset ---
376
+
377
+ function mountWithBackdrop(
378
+ overrides: Partial<RenderSettings> = {},
379
+ fetchBackdropPreview = makeFetchPreview(),
380
+ saveSettings = vi.fn().mockResolvedValue(null),
381
+ ) {
382
+ const settings = { ...defaultSettings, ...overrides }
383
+ return mount(RenderSettingsForm, {
384
+ props: {
385
+ settings,
386
+ loadSettings: vi.fn().mockResolvedValue(settings),
387
+ saveSettings,
388
+ fetchPreview: makeFetchPreview(),
389
+ fetchBackdropPreview,
390
+ },
391
+ global: {
392
+ plugins: [createPinia()],
393
+ stubs: shadcnStubs,
394
+ },
395
+ })
396
+ }
397
+
398
+ it('shows both edge-inset inputs for a corner backdrop position', () => {
399
+ const wrapper = mountWithBackdrop({ backdrop_position: 'tr' })
400
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-x"]').exists()).toBe(true)
401
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-y"]').exists()).toBe(true)
402
+ expect(wrapper.text()).toContain('Space from top')
403
+ expect(wrapper.text()).toContain('Space from right')
404
+ })
405
+
406
+ it('shows only the vertical edge-inset input for a top-center position', () => {
407
+ const wrapper = mountWithBackdrop({ backdrop_position: 'tc' })
408
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-x"]').exists()).toBe(false)
409
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-y"]').exists()).toBe(true)
410
+ expect(wrapper.text()).toContain('Space from top')
411
+ })
412
+
413
+ it('shows only the horizontal edge-inset input for a right position', () => {
414
+ const wrapper = mountWithBackdrop({ backdrop_position: 'r' })
415
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-x"]').exists()).toBe(true)
416
+ expect(wrapper.find('[data-testid="backdrop-edge-inset-y"]').exists()).toBe(false)
417
+ expect(wrapper.text()).toContain('Space from right')
418
+ })
419
+
420
+ it('passes edge insets to fetchBackdropPreview', async () => {
421
+ const fetchBackdropPreview = makeFetchPreview()
422
+ mountWithBackdrop({ backdrop_position: 'tr', backdrop_edge_inset_x: 12, backdrop_edge_inset_y: 7 }, fetchBackdropPreview)
423
+ await flushPromises()
424
+
425
+ expect(fetchBackdropPreview).toHaveBeenCalledWith(
426
+ 3, // backdrop_ratings_limit
427
+ expect.any(String), // ratings_order
428
+ 'v', // backdrop_badge_style
429
+ 'i', // backdrop_label_style
430
+ 'm', // backdrop_badge_size
431
+ 'tr', // backdrop_position
432
+ 'v', // backdrop_badge_direction
433
+ '', // ratings_exclude
434
+ 'r', // backdrop_badge_shape
435
+ 'd', // backdrop_badge_background
436
+ 12, // backdrop_edge_inset_x
437
+ 7, // backdrop_edge_inset_y
438
+ )
439
+ })
440
+
441
+ it('editing a backdrop edge inset auto-saves the new value', async () => {
442
+ const saveSettings = vi.fn().mockResolvedValue(null)
443
+ const wrapper = mountWithBackdrop({ backdrop_position: 'tr' }, makeFetchPreview(), saveSettings)
444
+
445
+ await wrapper.find('[data-testid="backdrop-edge-inset-y"]').setValue(15)
446
+ await flushPromises()
447
+
448
+ expect(saveSettings).toHaveBeenCalledWith(
449
+ expect.objectContaining({ backdrop_edge_inset_y: 15 }),
450
+ )
451
+ })
452
  })
web/src/components/RenderSettingsForm.vue CHANGED
@@ -42,6 +42,8 @@ export interface RenderSettings {
42
  backdrop_badge_size: string
43
  backdrop_position: string
44
  backdrop_badge_direction: string
 
 
45
  episode_ratings_limit: number
46
  episode_badge_style: string
47
  episode_label_style: string
@@ -67,7 +69,7 @@ const props = defineProps<{
67
  resetSettings?: () => Promise<boolean>
68
  fetchPreview: (ratingsLimit: number, ratingsOrder: string, posterPosition?: string, badgeStyle?: string, labelStyle?: string, badgeDirection?: string, badgeSize?: string, ratingsExclude?: string, posterSplit?: boolean, badgeShape?: string, badgeBackground?: string, posterFit?: string) => Promise<Response>
69
  fetchLogoPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string) => Promise<Response>
70
- fetchBackdropPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string) => Promise<Response>
71
  fetchEpisodePreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string) => Promise<Response>
72
  }>()
73
 
@@ -97,6 +99,8 @@ const editLogoBadgeSize = ref(props.settings.logo_badge_size || 'm')
97
  const editBackdropBadgeSize = ref(props.settings.backdrop_badge_size || 'm')
98
  const editBackdropPosition = ref(props.settings.backdrop_position || 'tr')
99
  const editBackdropBadgeDirection = ref(props.settings.backdrop_badge_direction || 'd')
 
 
100
  const editEpisodeRatingsLimit = ref(props.settings.episode_ratings_limit ?? 1)
101
  const editEpisodeBadgeStyle = ref(props.settings.episode_badge_style || 'v')
102
  const editEpisodeLabelStyle = ref(props.settings.episode_label_style || 'o')
@@ -113,6 +117,18 @@ const editLogoBadgeBackground = ref(props.settings.logo_badge_background || 'd')
113
  const editBackdropBadgeBackground = ref(props.settings.backdrop_badge_background || 'd')
114
  const editEpisodeBadgeBackground = ref(props.settings.episode_badge_background || 'd')
115
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  function applySettings(s: RenderSettings) {
117
  editFanart.value = s.image_source === 'f'
118
  editLang.value = s.lang || 'en'
@@ -137,6 +153,8 @@ function applySettings(s: RenderSettings) {
137
  editBackdropBadgeSize.value = s.backdrop_badge_size || 'm'
138
  editBackdropPosition.value = s.backdrop_position || 'tr'
139
  editBackdropBadgeDirection.value = s.backdrop_badge_direction || 'd'
 
 
140
  editEpisodeRatingsLimit.value = s.episode_ratings_limit ?? 1
141
  editEpisodeBadgeStyle.value = s.episode_badge_style || 'v'
142
  editEpisodeLabelStyle.value = s.episode_label_style || 'o'
@@ -211,6 +229,8 @@ async function autoSave() {
211
  backdrop_badge_size: editBackdropBadgeSize.value,
212
  backdrop_position: editBackdropPosition.value,
213
  backdrop_badge_direction: editBackdropBadgeDirection.value,
 
 
214
  episode_ratings_limit: editEpisodeRatingsLimit.value,
215
  episode_badge_style: editEpisodeBadgeStyle.value,
216
  episode_label_style: editEpisodeLabelStyle.value,
@@ -252,7 +272,7 @@ async function autoSave() {
252
 
253
  // Auto-save on any setting change
254
  watch(
255
- [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editRatingsExclude, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection, editPosterBadgeSplit, editPosterFit, editPosterBadgeSize, editLogoBadgeSize, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur, editPosterBadgeShape, editLogoBadgeShape, editBackdropBadgeShape, editEpisodeBadgeShape, editPosterBadgeBackground, editLogoBadgeBackground, editBackdropBadgeBackground, editEpisodeBadgeBackground],
256
  () => {
257
  if (syncing) return
258
  autoSave()
@@ -354,7 +374,7 @@ function updateLogoPreview() {
354
 
355
  function updateBackdropPreview() {
356
  if (props.fetchBackdropPreview) {
357
- fetchPreviewImage(backdropPreview.value, (_limit, order) => props.fetchBackdropPreview!(editBackdropRatingsLimit.value, order, editBackdropBadgeStyle.value, editBackdropLabelStyle.value, editBackdropBadgeSize.value, editBackdropPosition.value, editBackdropBadgeDirection.value, editRatingsExclude.value.join(','), editBackdropBadgeShape.value, editBackdropBadgeBackground.value))
358
  }
359
  }
360
 
@@ -399,7 +419,7 @@ watch([editLogoRatingsLimit, editLogoBadgeStyle, editLogoLabelStyle, editLogoBad
399
  })
400
 
401
  // Backdrop-only settings
402
- watch([editBackdropRatingsLimit, editBackdropBadgeStyle, editBackdropLabelStyle, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editBackdropBadgeShape, editBackdropBadgeBackground], () => {
403
  if (syncing) return
404
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
405
  backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
@@ -962,6 +982,42 @@ function toggleExclude(key: string, checked: boolean) {
962
  </SelectContent>
963
  </Select>
964
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
965
  <div class="space-y-2">
966
  <Label :for="inputId('backdrop-badge-direction')">Badge direction</Label>
967
  <Select
 
42
  backdrop_badge_size: string
43
  backdrop_position: string
44
  backdrop_badge_direction: string
45
+ backdrop_edge_inset_x: number
46
+ backdrop_edge_inset_y: number
47
  episode_ratings_limit: number
48
  episode_badge_style: string
49
  episode_label_style: string
 
69
  resetSettings?: () => Promise<boolean>
70
  fetchPreview: (ratingsLimit: number, ratingsOrder: string, posterPosition?: string, badgeStyle?: string, labelStyle?: string, badgeDirection?: string, badgeSize?: string, ratingsExclude?: string, posterSplit?: boolean, badgeShape?: string, badgeBackground?: string, posterFit?: string) => Promise<Response>
71
  fetchLogoPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string) => Promise<Response>
72
+ fetchBackdropPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, edgeInsetX?: number, edgeInsetY?: number) => Promise<Response>
73
  fetchEpisodePreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string) => Promise<Response>
74
  }>()
75
 
 
99
  const editBackdropBadgeSize = ref(props.settings.backdrop_badge_size || 'm')
100
  const editBackdropPosition = ref(props.settings.backdrop_position || 'tr')
101
  const editBackdropBadgeDirection = ref(props.settings.backdrop_badge_direction || 'd')
102
+ const editBackdropEdgeInsetX = ref(props.settings.backdrop_edge_inset_x ?? 0)
103
+ const editBackdropEdgeInsetY = ref(props.settings.backdrop_edge_inset_y ?? 0)
104
  const editEpisodeRatingsLimit = ref(props.settings.episode_ratings_limit ?? 1)
105
  const editEpisodeBadgeStyle = ref(props.settings.episode_badge_style || 'v')
106
  const editEpisodeLabelStyle = ref(props.settings.episode_label_style || 'o')
 
117
  const editBackdropBadgeBackground = ref(props.settings.backdrop_badge_background || 'd')
118
  const editEpisodeBadgeBackground = ref(props.settings.episode_badge_background || 'd')
119
 
120
+ // Which edges the current backdrop position anchors to. The horizontal inset
121
+ // only applies to left/right positions and the vertical inset only to top/bottom
122
+ // positions; centered axes hide their control (and the server ignores them).
123
+ const backdropIsTop = computed(() => ['tl', 'tc', 'tr'].includes(editBackdropPosition.value))
124
+ const backdropIsBottom = computed(() => ['bl', 'bc', 'br'].includes(editBackdropPosition.value))
125
+ const backdropIsLeft = computed(() => ['tl', 'bl', 'l'].includes(editBackdropPosition.value))
126
+ const backdropIsRight = computed(() => ['tr', 'br', 'r'].includes(editBackdropPosition.value))
127
+ const backdropShowVerticalInset = computed(() => backdropIsTop.value || backdropIsBottom.value)
128
+ const backdropShowHorizontalInset = computed(() => backdropIsLeft.value || backdropIsRight.value)
129
+ const backdropVerticalInsetLabel = computed(() => (backdropIsTop.value ? 'Space from top' : 'Space from bottom'))
130
+ const backdropHorizontalInsetLabel = computed(() => (backdropIsLeft.value ? 'Space from left' : 'Space from right'))
131
+
132
  function applySettings(s: RenderSettings) {
133
  editFanart.value = s.image_source === 'f'
134
  editLang.value = s.lang || 'en'
 
153
  editBackdropBadgeSize.value = s.backdrop_badge_size || 'm'
154
  editBackdropPosition.value = s.backdrop_position || 'tr'
155
  editBackdropBadgeDirection.value = s.backdrop_badge_direction || 'd'
156
+ editBackdropEdgeInsetX.value = s.backdrop_edge_inset_x ?? 0
157
+ editBackdropEdgeInsetY.value = s.backdrop_edge_inset_y ?? 0
158
  editEpisodeRatingsLimit.value = s.episode_ratings_limit ?? 1
159
  editEpisodeBadgeStyle.value = s.episode_badge_style || 'v'
160
  editEpisodeLabelStyle.value = s.episode_label_style || 'o'
 
229
  backdrop_badge_size: editBackdropBadgeSize.value,
230
  backdrop_position: editBackdropPosition.value,
231
  backdrop_badge_direction: editBackdropBadgeDirection.value,
232
+ backdrop_edge_inset_x: editBackdropEdgeInsetX.value,
233
+ backdrop_edge_inset_y: editBackdropEdgeInsetY.value,
234
  episode_ratings_limit: editEpisodeRatingsLimit.value,
235
  episode_badge_style: editEpisodeBadgeStyle.value,
236
  episode_label_style: editEpisodeLabelStyle.value,
 
272
 
273
  // Auto-save on any setting change
274
  watch(
275
+ [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editRatingsExclude, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection, editPosterBadgeSplit, editPosterFit, editPosterBadgeSize, editLogoBadgeSize, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editBackdropEdgeInsetX, editBackdropEdgeInsetY, editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur, editPosterBadgeShape, editLogoBadgeShape, editBackdropBadgeShape, editEpisodeBadgeShape, editPosterBadgeBackground, editLogoBadgeBackground, editBackdropBadgeBackground, editEpisodeBadgeBackground],
276
  () => {
277
  if (syncing) return
278
  autoSave()
 
374
 
375
  function updateBackdropPreview() {
376
  if (props.fetchBackdropPreview) {
377
+ fetchPreviewImage(backdropPreview.value, (_limit, order) => props.fetchBackdropPreview!(editBackdropRatingsLimit.value, order, editBackdropBadgeStyle.value, editBackdropLabelStyle.value, editBackdropBadgeSize.value, editBackdropPosition.value, editBackdropBadgeDirection.value, editRatingsExclude.value.join(','), editBackdropBadgeShape.value, editBackdropBadgeBackground.value, editBackdropEdgeInsetX.value, editBackdropEdgeInsetY.value))
378
  }
379
  }
380
 
 
419
  })
420
 
421
  // Backdrop-only settings
422
+ watch([editBackdropRatingsLimit, editBackdropBadgeStyle, editBackdropLabelStyle, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editBackdropEdgeInsetX, editBackdropEdgeInsetY, editBackdropBadgeShape, editBackdropBadgeBackground], () => {
423
  if (syncing) return
424
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
425
  backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
 
982
  </SelectContent>
983
  </Select>
984
  </div>
985
+ <div v-if="backdropShowVerticalInset || backdropShowHorizontalInset" class="space-y-1">
986
+ <Label>Distance from edge</Label>
987
+ <div class="flex flex-wrap gap-4">
988
+ <div v-if="backdropShowVerticalInset" class="space-y-1">
989
+ <Label :for="inputId('backdrop-edge-inset-y')" class="text-xs font-normal text-muted-foreground">{{ backdropVerticalInsetLabel }}</Label>
990
+ <div class="flex items-center gap-1">
991
+ <Input
992
+ :id="inputId('backdrop-edge-inset-y')"
993
+ v-model.number="editBackdropEdgeInsetY"
994
+ type="number"
995
+ :min="0"
996
+ :max="50"
997
+ class="w-[80px]"
998
+ data-testid="backdrop-edge-inset-y"
999
+ />
1000
+ <span class="text-xs text-muted-foreground">%</span>
1001
+ </div>
1002
+ </div>
1003
+ <div v-if="backdropShowHorizontalInset" class="space-y-1">
1004
+ <Label :for="inputId('backdrop-edge-inset-x')" class="text-xs font-normal text-muted-foreground">{{ backdropHorizontalInsetLabel }}</Label>
1005
+ <div class="flex items-center gap-1">
1006
+ <Input
1007
+ :id="inputId('backdrop-edge-inset-x')"
1008
+ v-model.number="editBackdropEdgeInsetX"
1009
+ type="number"
1010
+ :min="0"
1011
+ :max="50"
1012
+ class="w-[80px]"
1013
+ data-testid="backdrop-edge-inset-x"
1014
+ />
1015
+ <span class="text-xs text-muted-foreground">%</span>
1016
+ </div>
1017
+ </div>
1018
+ </div>
1019
+ <p class="text-xs text-muted-foreground">Inset ratings from the image edge (% of size) — useful when a player crops the backdrop.</p>
1020
+ </div>
1021
  <div class="space-y-2">
1022
  <Label :for="inputId('backdrop-badge-direction')">Badge direction</Label>
1023
  <Select
web/src/lib/api.ts CHANGED
@@ -88,6 +88,8 @@ export interface SaveSettingsPayload {
88
  backdrop_badge_size: string
89
  backdrop_position: string
90
  backdrop_badge_direction: string
 
 
91
  episode_ratings_limit: number
92
  episode_badge_style: string
93
  episode_label_style: string
@@ -151,8 +153,8 @@ export const adminApi = {
151
  get(buildUrl('/api/admin/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize, split: posterSplit ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground, fit: posterFit })),
152
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
153
  get(buildUrl('/api/admin/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, badge_shape: badgeShape, badge_background: badgeBackground })),
154
- previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
155
- get(buildUrl('/api/admin/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, badge_shape: badgeShape, badge_background: badgeBackground })),
156
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
157
  get(buildUrl('/api/admin/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground })),
158
  }
@@ -190,8 +192,8 @@ export const selfApi = {
190
  keyRequest(buildUrl('/api/key/me/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize, split: posterSplit ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground, fit: posterFit })),
191
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
192
  keyRequest(buildUrl('/api/key/me/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, badge_shape: badgeShape, badge_background: badgeBackground })),
193
- previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
194
- keyRequest(buildUrl('/api/key/me/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, badge_shape: badgeShape, badge_background: badgeBackground })),
195
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
196
  keyRequest(buildUrl('/api/key/me/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground })),
197
  }
 
88
  backdrop_badge_size: string
89
  backdrop_position: string
90
  backdrop_badge_direction: string
91
+ backdrop_edge_inset_x: number
92
+ backdrop_edge_inset_y: number
93
  episode_ratings_limit: number
94
  episode_badge_style: string
95
  episode_label_style: string
 
153
  get(buildUrl('/api/admin/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize, split: posterSplit ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground, fit: posterFit })),
154
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
155
  get(buildUrl('/api/admin/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, badge_shape: badgeShape, badge_background: badgeBackground })),
156
+ previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, edgeInsetX?: number, edgeInsetY?: number): Promise<Response> =>
157
+ get(buildUrl('/api/admin/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, badge_shape: badgeShape, badge_background: badgeBackground, edge_inset_x: edgeInsetX, edge_inset_y: edgeInsetY })),
158
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
159
  get(buildUrl('/api/admin/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground })),
160
  }
 
192
  keyRequest(buildUrl('/api/key/me/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize, split: posterSplit ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground, fit: posterFit })),
193
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
194
  keyRequest(buildUrl('/api/key/me/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, badge_shape: badgeShape, badge_background: badgeBackground })),
195
+ previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string, edgeInsetX?: number, edgeInsetY?: number): Promise<Response> =>
196
+ keyRequest(buildUrl('/api/key/me/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, badge_shape: badgeShape, badge_background: badgeBackground, edge_inset_x: edgeInsetX, edge_inset_y: edgeInsetY })),
197
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean, ratingsExclude?: string, badgeShape?: string, badgeBackground?: string): Promise<Response> =>
198
  keyRequest(buildUrl('/api/key/me/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, ratings_exclude: ratingsExclude, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined, badge_shape: badgeShape, badge_background: badgeBackground })),
199
  }
web/src/lib/auth-api.ts CHANGED
@@ -29,6 +29,8 @@ export interface FreeKeyDefaults {
29
  backdrop_badge_size: string
30
  backdrop_position: string
31
  backdrop_badge_direction: string
 
 
32
  episode_ratings_limit: number
33
  episode_badge_style: string
34
  episode_label_style: string
 
29
  backdrop_badge_size: string
30
  backdrop_position: string
31
  backdrop_badge_direction: string
32
+ backdrop_edge_inset_x: number
33
+ backdrop_edge_inset_y: number
34
  episode_ratings_limit: number
35
  episode_badge_style: string
36
  episode_label_style: string