PNRxA commited on
Commit
41b3b51
·
1 Parent(s): eeec19f

additional configuration options for backdrops

Browse files
README.md CHANGED
@@ -68,7 +68,7 @@ GET /{api_key}/{id_type}/logo-default/{id_value}.png
68
  GET /{api_key}/{id_type}/backdrop-default/{id_value}.jpg
69
  ```
70
 
71
- - Returns JPEG with rating badges stacked vertically in the top-right corner
72
  - Uses TMDB (default) or Fanart.tv as the image source
73
 
74
  ### Episode
@@ -106,13 +106,13 @@ GET /{api_key}/isValid
106
  - `?label_style={t|i|o}`: label rendering — `t` (text), `i` (icon), `o` (official provider logos)
107
  - `?badge_size={xs|s|m|l|xl}`: badge scale — extra-small, small, medium, large, extra-large
108
  - `?image_source={t|f}`: image source — `t` (TMDB, default), `f` (Fanart.tv). Applies to all image types. The non-selected source is used as fallback
109
- - `?badge_direction={d|h|v}`: badge stacking direction (poster only) — `d` (default), `h` (horizontal), `v` (vertical)
110
- - `?position={bc|tc|l|r|tl|tr|bl|br}`: badge anchor position (poster only)
111
  - `?textless={true|false}`: prefer textless images when available (poster only). Works with both TMDB and Fanart.tv sources
112
  - `?blur={true|false}`: apply Gaussian blur for spoiler protection (episode only). Badges remain sharp over the blurred still image
113
  - 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
114
 
115
- Poster/episode parameters (`badge_direction`, `position`) are silently ignored on logo and backdrop endpoints. `textless` is poster-only. For shared parameters (`ratings_limit`, `badge_style`, `label_style`, `badge_size`, `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`).
116
 
117
  Management endpoints (auth, keys, settings) are under `/api/` and return JSON.
118
 
@@ -278,11 +278,16 @@ Cache keys uniquely identify a rendered image. They are used as keys in the in-m
278
  {id_type}/{id_value}{variant}{ratings_suffix}{pos_suffix}{style_suffix}{label_suffix}{direction_suffix}{badge_size_suffix}{size_suffix}
279
  ```
280
 
281
- **Logo / Backdrop:**
282
  ```
283
  {id_type}/{id_value}{kind_prefix}{variant}{ratings_suffix}{style_suffix}{label_suffix}{badge_size_suffix}{size_suffix}
284
  ```
285
 
 
 
 
 
 
286
  ### Suffix Reference
287
 
288
  | Suffix | Format | Example | Description |
@@ -364,8 +369,8 @@ imdb/tt0111161_l_t_en@mil.sh.lt.bm.zm
364
  # Logo from Fanart.tv with English language
365
  imdb/tt0111161_l_f_en@mil.sh.lt.bm.zm
366
 
367
- # Backdrop from TMDB with vertical badges, official labels, extra-large badge size, large image
368
- imdb/tt0111161_b_t@mil.sv.lo.bxl.zl
369
 
370
  # Episode with 1 rating, top-right position, vertical direction, vertical badges, official labels, medium badge size, blur enabled
371
  imdb/tt0959621_e@i.ptr.sv.lo.dv.bm.blur.zm
 
68
  GET /{api_key}/{id_type}/backdrop-default/{id_value}.jpg
69
  ```
70
 
71
+ - Returns JPEG with rating badges overlaid on the backdrop (configurable position and direction)
72
  - Uses TMDB (default) or Fanart.tv as the image source
73
 
74
  ### Episode
 
106
  - `?label_style={t|i|o}`: label rendering — `t` (text), `i` (icon), `o` (official provider logos)
107
  - `?badge_size={xs|s|m|l|xl}`: badge scale — extra-small, small, medium, large, extra-large
108
  - `?image_source={t|f}`: image source — `t` (TMDB, default), `f` (Fanart.tv). Applies to all image types. The non-selected source is used as fallback
109
+ - `?badge_direction={d|h|v}`: badge stacking direction — `d` (default), `h` (horizontal), `v` (vertical). Applies to poster, backdrop, and episode endpoints
110
+ - `?position={bc|tc|l|r|tl|tr|bl|br}`: badge anchor position. Applies to poster, backdrop, and episode endpoints
111
  - `?textless={true|false}`: prefer textless images when available (poster only). Works with both TMDB and Fanart.tv sources
112
  - `?blur={true|false}`: apply Gaussian blur for spoiler protection (episode only). Badges remain sharp over the blurred still image
113
  - 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
114
 
115
+ `textless` is 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`, `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`).
116
 
117
  Management endpoints (auth, keys, settings) are under `/api/` and return JSON.
118
 
 
278
  {id_type}/{id_value}{variant}{ratings_suffix}{pos_suffix}{style_suffix}{label_suffix}{direction_suffix}{badge_size_suffix}{size_suffix}
279
  ```
280
 
281
+ **Logo:**
282
  ```
283
  {id_type}/{id_value}{kind_prefix}{variant}{ratings_suffix}{style_suffix}{label_suffix}{badge_size_suffix}{size_suffix}
284
  ```
285
 
286
+ **Backdrop:**
287
+ ```
288
+ {id_type}/{id_value}{kind_prefix}{variant}{ratings_suffix}{pos_suffix}{style_suffix}{label_suffix}{direction_suffix}{badge_size_suffix}{size_suffix}
289
+ ```
290
+
291
  ### Suffix Reference
292
 
293
  | Suffix | Format | Example | Description |
 
369
  # Logo from Fanart.tv with English language
370
  imdb/tt0111161_l_f_en@mil.sh.lt.bm.zm
371
 
372
+ # Backdrop from TMDB with top-right position, vertical direction, vertical badges, official labels, extra-large badge size, large image
373
+ imdb/tt0111161_b_t@mil.ptr.sv.lo.dv.bxl.zl
374
 
375
  # Episode with 1 rating, top-right position, vertical direction, vertical badges, official labels, medium badge size, blur enabled
376
  imdb/tt0959621_e@i.ptr.sv.lo.dv.bm.blur.zm
api/src/entity/api_key_settings.rs CHANGED
@@ -23,6 +23,8 @@ pub struct Model {
23
  pub poster_badge_size: String,
24
  pub logo_badge_size: String,
25
  pub backdrop_badge_size: String,
 
 
26
  pub episode_ratings_limit: i32,
27
  pub episode_badge_style: String,
28
  pub episode_label_style: String,
 
23
  pub poster_badge_size: String,
24
  pub logo_badge_size: String,
25
  pub backdrop_badge_size: String,
26
+ pub backdrop_position: String,
27
+ pub backdrop_badge_direction: String,
28
  pub episode_ratings_limit: i32,
29
  pub episode_badge_style: String,
30
  pub episode_label_style: String,
api/src/handlers/admin.rs CHANGED
@@ -131,6 +131,8 @@ pub struct GlobalSettingsResponse {
131
  pub poster_badge_size: BadgeSize,
132
  pub logo_badge_size: BadgeSize,
133
  pub backdrop_badge_size: BadgeSize,
 
 
134
  pub episode_ratings_limit: i32,
135
  pub episode_badge_style: BadgeStyle,
136
  pub episode_label_style: LabelStyle,
@@ -176,6 +178,8 @@ pub async fn get_settings(
176
  poster_badge_size: settings.poster_badge_size,
177
  logo_badge_size: settings.logo_badge_size,
178
  backdrop_badge_size: settings.backdrop_badge_size,
 
 
179
  episode_ratings_limit: settings.episode_ratings_limit,
180
  episode_badge_style: settings.episode_badge_style,
181
  episode_label_style: settings.episode_label_style,
@@ -225,6 +229,10 @@ pub struct UpdateGlobalSettingsRequest {
225
  pub logo_badge_size: BadgeSize,
226
  #[serde(default = "db::default_badge_size")]
227
  pub backdrop_badge_size: BadgeSize,
 
 
 
 
228
  #[serde(default = "db::default_episode_ratings_limit")]
229
  pub episode_ratings_limit: i32,
230
  #[serde(default = "db::default_episode_badge_style")]
@@ -271,6 +279,8 @@ pub async fn update_settings(
271
  ("poster_badge_size", req.poster_badge_size.as_str()),
272
  ("logo_badge_size", req.logo_badge_size.as_str()),
273
  ("backdrop_badge_size", req.backdrop_badge_size.as_str()),
 
 
274
  ("episode_ratings_limit", &episode_limit_str),
275
  ("episode_badge_style", req.episode_badge_style.as_str()),
276
  ("episode_label_style", req.episode_label_style.as_str()),
 
131
  pub poster_badge_size: BadgeSize,
132
  pub logo_badge_size: BadgeSize,
133
  pub backdrop_badge_size: BadgeSize,
134
+ pub backdrop_position: BadgePosition,
135
+ pub backdrop_badge_direction: BadgeDirection,
136
  pub episode_ratings_limit: i32,
137
  pub episode_badge_style: BadgeStyle,
138
  pub episode_label_style: LabelStyle,
 
178
  poster_badge_size: settings.poster_badge_size,
179
  logo_badge_size: settings.logo_badge_size,
180
  backdrop_badge_size: settings.backdrop_badge_size,
181
+ backdrop_position: settings.backdrop_position,
182
+ backdrop_badge_direction: settings.backdrop_badge_direction,
183
  episode_ratings_limit: settings.episode_ratings_limit,
184
  episode_badge_style: settings.episode_badge_style,
185
  episode_label_style: settings.episode_label_style,
 
229
  pub logo_badge_size: BadgeSize,
230
  #[serde(default = "db::default_badge_size")]
231
  pub backdrop_badge_size: BadgeSize,
232
+ #[serde(default = "db::default_backdrop_position")]
233
+ pub backdrop_position: BadgePosition,
234
+ #[serde(default = "db::default_backdrop_badge_direction")]
235
+ pub backdrop_badge_direction: BadgeDirection,
236
  #[serde(default = "db::default_episode_ratings_limit")]
237
  pub episode_ratings_limit: i32,
238
  #[serde(default = "db::default_episode_badge_style")]
 
279
  ("poster_badge_size", req.poster_badge_size.as_str()),
280
  ("logo_badge_size", req.logo_badge_size.as_str()),
281
  ("backdrop_badge_size", req.backdrop_badge_size.as_str()),
282
+ ("backdrop_position", req.backdrop_position.as_str()),
283
+ ("backdrop_badge_direction", req.backdrop_badge_direction.as_str()),
284
  ("episode_ratings_limit", &episode_limit_str),
285
  ("episode_badge_style", req.episode_badge_style.as_str()),
286
  ("episode_label_style", req.episode_label_style.as_str()),
api/src/handlers/api_keys.rs CHANGED
@@ -113,6 +113,8 @@ pub struct RenderSettingsResponse {
113
  pub poster_badge_size: BadgeSize,
114
  pub logo_badge_size: BadgeSize,
115
  pub backdrop_badge_size: BadgeSize,
 
 
116
  pub episode_ratings_limit: i32,
117
  pub episode_badge_style: BadgeStyle,
118
  pub episode_label_style: LabelStyle,
@@ -155,6 +157,8 @@ fn settings_to_response(settings: &db::RenderSettings, fanart_available: bool) -
155
  poster_badge_size: settings.poster_badge_size,
156
  logo_badge_size: settings.logo_badge_size,
157
  backdrop_badge_size: settings.backdrop_badge_size,
 
 
158
  episode_ratings_limit: settings.episode_ratings_limit,
159
  episode_badge_style: settings.episode_badge_style,
160
  episode_label_style: settings.episode_label_style,
@@ -203,6 +207,10 @@ pub struct UpdateSettingsRequest {
203
  pub logo_badge_size: BadgeSize,
204
  #[serde(default = "db::default_badge_size")]
205
  pub backdrop_badge_size: BadgeSize,
 
 
 
 
206
  #[serde(default = "db::default_episode_ratings_limit")]
207
  pub episode_ratings_limit: i32,
208
  #[serde(default = "db::default_episode_badge_style")]
@@ -240,6 +248,8 @@ fn build_upsert(id: i32, req: &UpdateSettingsRequest) -> db::UpsertApiKeySetting
240
  poster_badge_size: req.poster_badge_size.as_str(),
241
  logo_badge_size: req.logo_badge_size.as_str(),
242
  backdrop_badge_size: req.backdrop_badge_size.as_str(),
 
 
243
  episode_ratings_limit: req.episode_ratings_limit,
244
  episode_badge_style: req.episode_badge_style.as_str(),
245
  episode_label_style: req.episode_label_style.as_str(),
 
113
  pub poster_badge_size: BadgeSize,
114
  pub logo_badge_size: BadgeSize,
115
  pub backdrop_badge_size: BadgeSize,
116
+ pub backdrop_position: BadgePosition,
117
+ pub backdrop_badge_direction: BadgeDirection,
118
  pub episode_ratings_limit: i32,
119
  pub episode_badge_style: BadgeStyle,
120
  pub episode_label_style: LabelStyle,
 
157
  poster_badge_size: settings.poster_badge_size,
158
  logo_badge_size: settings.logo_badge_size,
159
  backdrop_badge_size: settings.backdrop_badge_size,
160
+ backdrop_position: settings.backdrop_position,
161
+ backdrop_badge_direction: settings.backdrop_badge_direction,
162
  episode_ratings_limit: settings.episode_ratings_limit,
163
  episode_badge_style: settings.episode_badge_style,
164
  episode_label_style: settings.episode_label_style,
 
207
  pub logo_badge_size: BadgeSize,
208
  #[serde(default = "db::default_badge_size")]
209
  pub backdrop_badge_size: BadgeSize,
210
+ #[serde(default = "db::default_backdrop_position")]
211
+ pub backdrop_position: BadgePosition,
212
+ #[serde(default = "db::default_backdrop_badge_direction")]
213
+ pub backdrop_badge_direction: BadgeDirection,
214
  #[serde(default = "db::default_episode_ratings_limit")]
215
  pub episode_ratings_limit: i32,
216
  #[serde(default = "db::default_episode_badge_style")]
 
248
  poster_badge_size: req.poster_badge_size.as_str(),
249
  logo_badge_size: req.logo_badge_size.as_str(),
250
  backdrop_badge_size: req.backdrop_badge_size.as_str(),
251
+ backdrop_position: req.backdrop_position.as_str(),
252
+ backdrop_badge_direction: req.backdrop_badge_direction.as_str(),
253
  episode_ratings_limit: req.episode_ratings_limit,
254
  episode_badge_style: req.episode_badge_style.as_str(),
255
  episode_label_style: req.episode_label_style.as_str(),
api/src/handlers/image.rs CHANGED
@@ -278,7 +278,7 @@ fn apply_query_overrides(
278
  }
279
  }
280
 
281
- // -- poster/episode position and direction overrides --
282
  if kind == cache::ImageType::Poster {
283
  if let Some(dir) = query.badge_direction {
284
  s.poster_badge_direction = dir;
@@ -287,6 +287,14 @@ fn apply_query_overrides(
287
  s.poster_position = pos;
288
  }
289
  }
 
 
 
 
 
 
 
 
290
  if kind == cache::ImageType::Episode {
291
  if let Some(dir) = query.badge_direction {
292
  s.episode_badge_direction = dir;
@@ -782,15 +790,26 @@ mod tests {
782
  #[test]
783
  fn apply_query_overrides_backdrop_maps_correctly() {
784
  let settings = Arc::new(db::RenderSettings::default());
 
 
 
785
  let query = ImageQuery {
786
  ratings_limit: Some(5),
787
  badge_style: Some(BadgeStyle::Default),
 
 
788
  ..empty_query()
789
  };
790
  let result =
791
  apply_query_overrides(settings, &query, cache::ImageType::Backdrop).unwrap();
792
  assert_eq!(result.backdrop_ratings_limit, 5);
793
  assert_eq!(result.backdrop_badge_style, BadgeStyle::Default);
 
 
 
 
 
 
794
  }
795
 
796
  #[test]
 
278
  }
279
  }
280
 
281
+ // -- position and direction overrides (poster, backdrop, episode) --
282
  if kind == cache::ImageType::Poster {
283
  if let Some(dir) = query.badge_direction {
284
  s.poster_badge_direction = dir;
 
287
  s.poster_position = pos;
288
  }
289
  }
290
+ if kind == cache::ImageType::Backdrop {
291
+ if let Some(dir) = query.badge_direction {
292
+ s.backdrop_badge_direction = dir;
293
+ }
294
+ if let Some(pos) = query.position {
295
+ s.backdrop_position = pos;
296
+ }
297
+ }
298
  if kind == cache::ImageType::Episode {
299
  if let Some(dir) = query.badge_direction {
300
  s.episode_badge_direction = dir;
 
790
  #[test]
791
  fn apply_query_overrides_backdrop_maps_correctly() {
792
  let settings = Arc::new(db::RenderSettings::default());
793
+ let original_poster_position = settings.poster_position;
794
+ let original_poster_direction = settings.poster_badge_direction;
795
+
796
  let query = ImageQuery {
797
  ratings_limit: Some(5),
798
  badge_style: Some(BadgeStyle::Default),
799
+ position: Some(BadgePosition::BottomCenter),
800
+ badge_direction: Some(BadgeDirection::Horizontal),
801
  ..empty_query()
802
  };
803
  let result =
804
  apply_query_overrides(settings, &query, cache::ImageType::Backdrop).unwrap();
805
  assert_eq!(result.backdrop_ratings_limit, 5);
806
  assert_eq!(result.backdrop_badge_style, BadgeStyle::Default);
807
+ assert_eq!(result.backdrop_position, BadgePosition::BottomCenter);
808
+ assert_eq!(result.backdrop_badge_direction, BadgeDirection::Horizontal);
809
+
810
+ // Poster fields unchanged
811
+ assert_eq!(result.poster_position, original_poster_position);
812
+ assert_eq!(result.poster_badge_direction, original_poster_direction);
813
  }
814
 
815
  #[test]
api/src/handlers/preview.rs CHANGED
@@ -127,6 +127,8 @@ fn preview_render_settings(
127
  s.backdrop_badge_style = badge_style;
128
  s.backdrop_label_style = label_style;
129
  s.backdrop_badge_size = badge_size;
 
 
130
  }
131
  cache::ImageType::Episode => {
132
  s.episode_ratings_limit = ratings_limit;
@@ -276,10 +278,12 @@ pub async fn preview_backdrop(
276
  let default_order = db::default_ratings_order();
277
  let ratings_order = query.ratings_order.as_deref().unwrap_or(&default_order);
278
  db::validate_ratings_order(ratings_order)?;
279
- let badge_style = query.badge_style.unwrap_or(BadgeStyle::Vertical).resolve(BadgeDirection::Vertical);
 
 
280
  let label_style = query.label_style.unwrap_or(db::default_label_style());
281
  let ratings_suffix = ratings::ratings_cache_suffix(ratings_order, ratings_limit);
282
- let preview_settings = preview_render_settings(cache::ImageType::Backdrop, badge_style, label_style, badge_size, BadgePosition::BottomCenter, BadgeDirection::Horizontal, ratings_limit, ratings_order);
283
  let suffix = serve::settings_cache_suffix_with_ratings(&preview_settings, cache::ImageType::Backdrop, image_size, &ratings_suffix);
284
  let cache_key = format!("preview-backdrop:{suffix}");
285
  let cache_path = cache::preview_path(&state.config.cache_dir, cache::ImageType::Backdrop, &suffix, "jpg")?;
@@ -302,7 +306,7 @@ pub async fn preview_backdrop(
302
  let quality = state.config.image_quality;
303
 
304
  let buf = tokio::task::spawn_blocking(move || {
305
- generate::render_backdrop_sync(backdrop_png, &badges, &font, quality, badge_style, label_style, target_width, badge_scale)
306
  })
307
  .await
308
  .map_err(|e| AppError::Other(e.to_string()))??;
 
127
  s.backdrop_badge_style = badge_style;
128
  s.backdrop_label_style = label_style;
129
  s.backdrop_badge_size = badge_size;
130
+ s.backdrop_position = position;
131
+ s.backdrop_badge_direction = badge_direction;
132
  }
133
  cache::ImageType::Episode => {
134
  s.episode_ratings_limit = ratings_limit;
 
278
  let default_order = db::default_ratings_order();
279
  let ratings_order = query.ratings_order.as_deref().unwrap_or(&default_order);
280
  db::validate_ratings_order(ratings_order)?;
281
+ let position = query.position.unwrap_or(db::default_backdrop_position());
282
+ let badge_direction = query.badge_direction.unwrap_or(db::default_backdrop_badge_direction()).resolve(position);
283
+ let badge_style = query.badge_style.unwrap_or(BadgeStyle::Vertical).resolve(badge_direction);
284
  let label_style = query.label_style.unwrap_or(db::default_label_style());
285
  let ratings_suffix = ratings::ratings_cache_suffix(ratings_order, ratings_limit);
286
+ let preview_settings = preview_render_settings(cache::ImageType::Backdrop, badge_style, label_style, badge_size, position, badge_direction, ratings_limit, ratings_order);
287
  let suffix = serve::settings_cache_suffix_with_ratings(&preview_settings, cache::ImageType::Backdrop, image_size, &ratings_suffix);
288
  let cache_key = format!("preview-backdrop:{suffix}");
289
  let cache_path = cache::preview_path(&state.config.cache_dir, cache::ImageType::Backdrop, &suffix, "jpg")?;
 
306
  let quality = state.config.image_quality;
307
 
308
  let buf = tokio::task::spawn_blocking(move || {
309
+ generate::render_backdrop_sync(backdrop_png, &badges, &font, quality, position, badge_style, label_style, badge_direction, target_width, badge_scale, badge_size)
310
  })
311
  .await
312
  .map_err(|e| AppError::Other(e.to_string()))??;
api/src/image/generate.rs CHANGED
@@ -21,6 +21,7 @@ const BADGE_TOP_MARGIN: u32 = 20;
21
  const BADGE_SIDE_MARGIN: u32 = 15;
22
  const BADGE_ROW_SPACING: u32 = 7;
23
  const BADGE_VERT_SPACING: u32 = 7;
 
24
  const MAX_BADGES_PER_ROW: usize = 3;
25
  const MAX_VERT_BADGES_PER_ROW: usize = 5;
26
 
@@ -140,21 +141,21 @@ pub async fn generate_poster(params: ImageParams<'_>) -> Result<Vec<u8>, AppErro
140
  Ok(buf)
141
  }
142
 
143
- /// Overlay badges in a vertical column, positioned according to `poster_position`.
144
- fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], poster_position: BadgePosition, badge_scale: f32) {
145
  let vert_spacing = (BADGE_VERT_SPACING as f32 * badge_scale).round() as u32;
146
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
147
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
148
- let side_margin = (BADGE_SIDE_MARGIN as f32 * badge_scale).round() as u32;
149
 
150
  let total_badge_height: u32 = badge_images.iter().map(|b| b.height()).sum::<u32>()
151
  + vert_spacing * (badge_images.len() as u32).saturating_sub(1);
152
  let max_badge_width: u32 = badge_images.iter().map(|b| b.width()).max().unwrap_or(0);
153
 
154
  // Vertical anchor
155
- let start_y = if poster_position.is_top() {
156
  top_margin
157
- } else if poster_position.is_bottom() {
158
  canvas.height().saturating_sub(total_badge_height + bottom_margin)
159
  } else {
160
  // "l", "r" — vertically centered
@@ -162,8 +163,8 @@ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], po
162
  };
163
 
164
  // Horizontal anchor
165
- let is_left = poster_position.is_left();
166
- let is_right = poster_position.is_right();
167
 
168
  let base_x = if is_left {
169
  side_margin
@@ -188,13 +189,13 @@ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], po
188
  }
189
  }
190
 
191
- /// Overlay badges in horizontal rows, positioned according to `poster_position`.
192
- fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], poster_position: BadgePosition, max_per_row: usize, badge_scale: f32) {
193
  let spacing = (BADGE_SPACING as f32 * badge_scale).round() as u32;
194
  let row_spacing = (BADGE_ROW_SPACING as f32 * badge_scale).round() as u32;
195
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
196
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
197
- let side_margin = (BADGE_SIDE_MARGIN as f32 * badge_scale).round() as u32;
198
 
199
  let rows: Vec<&[RgbaImage]> = badge_images.chunks(max_per_row).collect();
200
  let badge_height = badge_images.iter().map(|b| b.height()).max().unwrap_or(0);
@@ -202,9 +203,9 @@ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], p
202
  + row_spacing * (rows.len() as u32).saturating_sub(1);
203
 
204
  // Vertical anchor
205
- let base_y = if poster_position.is_top() {
206
  top_margin
207
- } else if poster_position.is_bottom() {
208
  canvas.height().saturating_sub(total_height + bottom_margin)
209
  } else {
210
  // "l", "r" — vertically centered
@@ -212,8 +213,8 @@ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], p
212
  };
213
 
214
  // Horizontal alignment
215
- let is_left = poster_position.is_left();
216
- let is_right = poster_position.is_right();
217
 
218
  for (row_idx, row) in rows.iter().enumerate() {
219
  let row_width: u32 = row.iter().map(|b| b.width()).sum::<u32>()
@@ -290,7 +291,7 @@ pub fn render_poster_sync(
290
  };
291
 
292
  if badge_direction.is_vertical() {
293
- overlay_vertical_stack(&mut canvas, &badge_images, poster_position, badge_scale);
294
  } else {
295
  let max_per_row = match badge_size {
296
  BadgeSize::Large | BadgeSize::ExtraLarge => {
@@ -300,7 +301,7 @@ pub fn render_poster_sync(
300
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
301
  }
302
  };
303
- overlay_horizontal_rows(&mut canvas, &badge_images, poster_position, max_per_row, badge_scale);
304
  }
305
  }
306
 
@@ -496,18 +497,18 @@ pub async fn generate_logo(
496
  Ok(buf)
497
  }
498
 
499
- const BACKDROP_MARGIN: u32 = 20;
500
- const BACKDROP_BADGE_SPACING: u32 = 7;
501
-
502
  pub fn render_backdrop_sync(
503
  backdrop_bytes: &[u8],
504
  badges: &[RatingBadge],
505
  font: &FontArc,
506
  quality: u8,
 
507
  badge_style: BadgeStyle,
508
  label_style: LabelStyle,
 
509
  target_width: u32,
510
  badge_scale: f32,
 
511
  ) -> Result<Vec<u8>, AppError> {
512
  let base = load_image_with_limits(backdrop_bytes)?;
513
 
@@ -528,16 +529,11 @@ pub fn render_backdrop_sync(
528
  badge::render_badges_uniform(badges, font, label_style, badge_scale)
529
  };
530
 
531
- let backdrop_margin = (BACKDROP_MARGIN as f32 * badge_scale).round() as u32;
532
- let backdrop_badge_spacing = (BACKDROP_BADGE_SPACING as f32 * badge_scale).round() as u32;
533
- let max_badge_width: u32 = badge_images.iter().map(|b| b.width()).max().unwrap_or(0);
534
- let x = canvas.width().saturating_sub(max_badge_width + backdrop_margin);
535
- let mut y = backdrop_margin;
536
-
537
- for badge_img in &badge_images {
538
- let bx = x + max_badge_width.saturating_sub(badge_img.width());
539
- imageops::overlay(&mut canvas, badge_img, bx as i64, y as i64);
540
- y += badge_img.height() + backdrop_badge_spacing;
541
  }
542
  }
543
 
@@ -555,11 +551,14 @@ pub async fn generate_backdrop(
555
  badges: Vec<RatingBadge>,
556
  font: FontArc,
557
  quality: u8,
 
558
  badge_style: BadgeStyle,
559
  label_style: LabelStyle,
 
560
  render_semaphore: Arc<Semaphore>,
561
  target_width: u32,
562
  badge_scale: f32,
 
563
  ) -> Result<Vec<u8>, AppError> {
564
  if render_semaphore.available_permits() == 0 {
565
  tracing::debug!("render queue full, waiting for permit");
@@ -569,7 +568,7 @@ pub async fn generate_backdrop(
569
  tracing::debug!("backdrop render started");
570
  let start = std::time::Instant::now();
571
 
572
- let buf = tokio::task::spawn_blocking(move || render_backdrop_sync(&backdrop_bytes, &badges, &font, quality, badge_style, label_style, target_width, badge_scale))
573
  .await
574
  .map_err(|e| AppError::Other(e.to_string()))??;
575
 
@@ -624,7 +623,7 @@ pub fn render_episode_sync(
624
  };
625
 
626
  if badge_direction.is_vertical() {
627
- overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale);
628
  } else {
629
  let max_per_row = match badge_size {
630
  BadgeSize::Large | BadgeSize::ExtraLarge => {
@@ -634,7 +633,7 @@ pub fn render_episode_sync(
634
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
635
  }
636
  };
637
- overlay_horizontal_rows(&mut canvas, &badge_images, position, max_per_row, badge_scale);
638
  }
639
  }
640
 
@@ -785,7 +784,7 @@ mod tests {
785
  fn render_backdrop_no_badges() {
786
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
787
  let png = test_png(640, 360);
788
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgeStyle::Vertical, LabelStyle::Text, 1280, 1.0).unwrap();
789
  assert!(!result.is_empty());
790
  // Backdrop outputs JPEG
791
  assert_eq!(result[0], 0xFF);
@@ -802,7 +801,7 @@ mod tests {
802
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
803
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
804
  ];
805
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgeStyle::Vertical, LabelStyle::Text, 1280, 1.0).unwrap();
806
  assert!(!result.is_empty());
807
  assert_eq!(result[0], 0xFF);
808
  assert_eq!(result[1], 0xD8);
@@ -813,7 +812,7 @@ mod tests {
813
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
814
  // Create a backdrop wider than TARGET_WIDTH (1280)
815
  let png = test_png(2560, 1440);
816
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgeStyle::Vertical, LabelStyle::Text, 1280, 1.0).unwrap();
817
  assert!(!result.is_empty());
818
  assert_eq!(result[0], 0xFF);
819
  assert_eq!(result[1], 0xD8);
@@ -822,10 +821,59 @@ mod tests {
822
  #[test]
823
  fn render_backdrop_invalid_bytes() {
824
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
825
- let result = render_backdrop_sync(b"not an image", &[], &font, 85, BadgeStyle::Vertical, LabelStyle::Text, 1280, 1.0);
826
  assert!(result.is_err());
827
  }
828
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
829
  // --- Episode rendering tests ---
830
 
831
  #[test]
@@ -1005,7 +1053,7 @@ mod tests {
1005
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
1006
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1007
  ];
1008
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgeStyle::Vertical, LabelStyle::Icon, 1280, 1.0).unwrap();
1009
  assert!(!result.is_empty());
1010
  assert_eq!(result[0], 0xFF);
1011
  assert_eq!(result[1], 0xD8);
@@ -1053,7 +1101,7 @@ mod tests {
1053
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1054
  RatingBadge { source: RatingSource::RtAudience, value: "40%".to_string() },
1055
  ];
1056
- let result = render_backdrop_sync(&png, &badges, &font, 85, BadgeStyle::Vertical, LabelStyle::Official, 1280, 1.0).unwrap();
1057
  assert!(!result.is_empty());
1058
  assert_eq!(result[0], 0xFF);
1059
  assert_eq!(result[1], 0xD8);
@@ -1160,7 +1208,7 @@ mod tests {
1160
  let png = test_png(640, 360);
1161
  let sem = Arc::new(Semaphore::new(1));
1162
 
1163
- let result = generate_backdrop(png, vec![], font, 85, BadgeStyle::Horizontal, LabelStyle::Text, sem.clone(), 1280, 1.0).await;
1164
  assert!(result.is_ok());
1165
  assert_eq!(sem.available_permits(), 1);
1166
  }
@@ -1172,7 +1220,7 @@ mod tests {
1172
  let sem = Arc::new(Semaphore::new(1));
1173
  sem.close();
1174
 
1175
- let result = generate_backdrop(png, vec![], font, 85, BadgeStyle::Horizontal, LabelStyle::Text, sem, 1280, 1.0).await;
1176
  assert!(result.is_err());
1177
  }
1178
 
@@ -1364,7 +1412,7 @@ mod tests {
1364
  fn render_backdrop_at_large_target_width() {
1365
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
1366
  let png = test_png(640, 360);
1367
- let result = render_backdrop_sync(&png, &[], &font, 85, BadgeStyle::Vertical, LabelStyle::Text, 3840, 2.0).unwrap();
1368
  assert!(!result.is_empty());
1369
  assert_eq!(result[0], 0xFF);
1370
  assert_eq!(result[1], 0xD8);
 
21
  const BADGE_SIDE_MARGIN: u32 = 15;
22
  const BADGE_ROW_SPACING: u32 = 7;
23
  const BADGE_VERT_SPACING: u32 = 7;
24
+ const BACKDROP_SIDE_MARGIN: u32 = 20;
25
  const MAX_BADGES_PER_ROW: usize = 3;
26
  const MAX_VERT_BADGES_PER_ROW: usize = 5;
27
 
 
141
  Ok(buf)
142
  }
143
 
144
+ /// Overlay badges in a vertical column, positioned according to `position`.
145
+ fn overlay_vertical_stack(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, badge_scale: f32, side_margin_base: u32) {
146
  let vert_spacing = (BADGE_VERT_SPACING as f32 * badge_scale).round() as u32;
147
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
148
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
149
+ let side_margin = (side_margin_base as f32 * badge_scale).round() as u32;
150
 
151
  let total_badge_height: u32 = badge_images.iter().map(|b| b.height()).sum::<u32>()
152
  + vert_spacing * (badge_images.len() as u32).saturating_sub(1);
153
  let max_badge_width: u32 = badge_images.iter().map(|b| b.width()).max().unwrap_or(0);
154
 
155
  // Vertical anchor
156
+ let start_y = if position.is_top() {
157
  top_margin
158
+ } else if position.is_bottom() {
159
  canvas.height().saturating_sub(total_badge_height + bottom_margin)
160
  } else {
161
  // "l", "r" — vertically centered
 
163
  };
164
 
165
  // Horizontal anchor
166
+ let is_left = position.is_left();
167
+ let is_right = position.is_right();
168
 
169
  let base_x = if is_left {
170
  side_margin
 
189
  }
190
  }
191
 
192
+ /// Overlay badges in horizontal rows, positioned according to `position`.
193
+ fn overlay_horizontal_rows(canvas: &mut RgbaImage, badge_images: &[RgbaImage], position: BadgePosition, max_per_row: usize, badge_scale: f32, side_margin_base: u32) {
194
  let spacing = (BADGE_SPACING as f32 * badge_scale).round() as u32;
195
  let row_spacing = (BADGE_ROW_SPACING as f32 * badge_scale).round() as u32;
196
  let top_margin = (BADGE_TOP_MARGIN as f32 * badge_scale).round() as u32;
197
  let bottom_margin = (BADGE_BOTTOM_MARGIN as f32 * badge_scale).round() as u32;
198
+ let side_margin = (side_margin_base as f32 * badge_scale).round() as u32;
199
 
200
  let rows: Vec<&[RgbaImage]> = badge_images.chunks(max_per_row).collect();
201
  let badge_height = badge_images.iter().map(|b| b.height()).max().unwrap_or(0);
 
203
  + row_spacing * (rows.len() as u32).saturating_sub(1);
204
 
205
  // Vertical anchor
206
+ let base_y = if position.is_top() {
207
  top_margin
208
+ } else if position.is_bottom() {
209
  canvas.height().saturating_sub(total_height + bottom_margin)
210
  } else {
211
  // "l", "r" — vertically centered
 
213
  };
214
 
215
  // Horizontal alignment
216
+ let is_left = position.is_left();
217
+ let is_right = position.is_right();
218
 
219
  for (row_idx, row) in rows.iter().enumerate() {
220
  let row_width: u32 = row.iter().map(|b| b.width()).sum::<u32>()
 
291
  };
292
 
293
  if badge_direction.is_vertical() {
294
+ overlay_vertical_stack(&mut canvas, &badge_images, poster_position, badge_scale, BADGE_SIDE_MARGIN);
295
  } else {
296
  let max_per_row = match badge_size {
297
  BadgeSize::Large | BadgeSize::ExtraLarge => {
 
301
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
302
  }
303
  };
304
+ overlay_horizontal_rows(&mut canvas, &badge_images, poster_position, max_per_row, badge_scale, BADGE_SIDE_MARGIN);
305
  }
306
  }
307
 
 
497
  Ok(buf)
498
  }
499
 
 
 
 
500
  pub fn render_backdrop_sync(
501
  backdrop_bytes: &[u8],
502
  badges: &[RatingBadge],
503
  font: &FontArc,
504
  quality: u8,
505
+ position: BadgePosition,
506
  badge_style: BadgeStyle,
507
  label_style: LabelStyle,
508
+ badge_direction: BadgeDirection,
509
  target_width: u32,
510
  badge_scale: f32,
511
+ _badge_size: BadgeSize,
512
  ) -> Result<Vec<u8>, AppError> {
513
  let base = load_image_with_limits(backdrop_bytes)?;
514
 
 
529
  badge::render_badges_uniform(badges, font, label_style, badge_scale)
530
  };
531
 
532
+ if badge_direction.is_vertical() {
533
+ overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BACKDROP_SIDE_MARGIN);
534
+ } else {
535
+ // Backdrops are wide (16:9) so all badges fit in a single row
536
+ overlay_horizontal_rows(&mut canvas, &badge_images, position, badge_images.len(), badge_scale, BACKDROP_SIDE_MARGIN);
 
 
 
 
 
537
  }
538
  }
539
 
 
551
  badges: Vec<RatingBadge>,
552
  font: FontArc,
553
  quality: u8,
554
+ position: BadgePosition,
555
  badge_style: BadgeStyle,
556
  label_style: LabelStyle,
557
+ badge_direction: BadgeDirection,
558
  render_semaphore: Arc<Semaphore>,
559
  target_width: u32,
560
  badge_scale: f32,
561
+ badge_size: BadgeSize,
562
  ) -> Result<Vec<u8>, AppError> {
563
  if render_semaphore.available_permits() == 0 {
564
  tracing::debug!("render queue full, waiting for permit");
 
568
  tracing::debug!("backdrop render started");
569
  let start = std::time::Instant::now();
570
 
571
+ let buf = tokio::task::spawn_blocking(move || render_backdrop_sync(&backdrop_bytes, &badges, &font, quality, position, badge_style, label_style, badge_direction, target_width, badge_scale, badge_size))
572
  .await
573
  .map_err(|e| AppError::Other(e.to_string()))??;
574
 
 
623
  };
624
 
625
  if badge_direction.is_vertical() {
626
+ overlay_vertical_stack(&mut canvas, &badge_images, position, badge_scale, BADGE_SIDE_MARGIN);
627
  } else {
628
  let max_per_row = match badge_size {
629
  BadgeSize::Large | BadgeSize::ExtraLarge => {
 
633
  if badge_style.is_vertical() { MAX_VERT_BADGES_PER_ROW } else { MAX_BADGES_PER_ROW }
634
  }
635
  };
636
+ overlay_horizontal_rows(&mut canvas, &badge_images, position, max_per_row, badge_scale, BADGE_SIDE_MARGIN);
637
  }
638
  }
639
 
 
784
  fn render_backdrop_no_badges() {
785
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
786
  let png = test_png(640, 360);
787
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
788
  assert!(!result.is_empty());
789
  // Backdrop outputs JPEG
790
  assert_eq!(result[0], 0xFF);
 
801
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
802
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
803
  ];
804
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
805
  assert!(!result.is_empty());
806
  assert_eq!(result[0], 0xFF);
807
  assert_eq!(result[1], 0xD8);
 
812
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
813
  // Create a backdrop wider than TARGET_WIDTH (1280)
814
  let png = test_png(2560, 1440);
815
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
816
  assert!(!result.is_empty());
817
  assert_eq!(result[0], 0xFF);
818
  assert_eq!(result[1], 0xD8);
 
821
  #[test]
822
  fn render_backdrop_invalid_bytes() {
823
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
824
+ let result = render_backdrop_sync(b"not an image", &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium);
825
  assert!(result.is_err());
826
  }
827
 
828
+ #[test]
829
+ fn render_backdrop_horizontal_bottom_center() {
830
+ use crate::services::ratings::{RatingBadge, RatingSource};
831
+
832
+ let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
833
+ let png = test_png(1280, 720);
834
+ let badges = vec![
835
+ RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
836
+ RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
837
+ ];
838
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Medium).unwrap();
839
+ assert!(!result.is_empty());
840
+ assert_eq!(result[0], 0xFF);
841
+ assert_eq!(result[1], 0xD8);
842
+ }
843
+
844
+ #[test]
845
+ fn render_backdrop_horizontal_large_badges() {
846
+ use crate::services::ratings::{RatingBadge, RatingSource};
847
+
848
+ let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
849
+ let png = test_png(1280, 720);
850
+ let badges = vec![
851
+ RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
852
+ RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
853
+ RatingBadge { source: RatingSource::RtAudience, value: "80%".to_string() },
854
+ ];
855
+ // Large + Horizontal style — all badges in a single row on wide backdrop
856
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomCenter, BadgeStyle::Horizontal, LabelStyle::Text, BadgeDirection::Horizontal, 1280, 1.0, BadgeSize::Large).unwrap();
857
+ assert!(!result.is_empty());
858
+ assert_eq!(result[0], 0xFF);
859
+ assert_eq!(result[1], 0xD8);
860
+ }
861
+
862
+ #[test]
863
+ fn render_backdrop_vertical_bottom_left() {
864
+ use crate::services::ratings::{RatingBadge, RatingSource};
865
+
866
+ let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
867
+ let png = test_png(1280, 720);
868
+ let badges = vec![
869
+ RatingBadge { source: RatingSource::Imdb, value: "8.5".to_string() },
870
+ ];
871
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::BottomLeft, BadgeStyle::Vertical, LabelStyle::Icon, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
872
+ assert!(!result.is_empty());
873
+ assert_eq!(result[0], 0xFF);
874
+ assert_eq!(result[1], 0xD8);
875
+ }
876
+
877
  // --- Episode rendering tests ---
878
 
879
  #[test]
 
1053
  RatingBadge { source: RatingSource::Imdb, value: "9.0".to_string() },
1054
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1055
  ];
1056
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Icon, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
1057
  assert!(!result.is_empty());
1058
  assert_eq!(result[0], 0xFF);
1059
  assert_eq!(result[1], 0xD8);
 
1101
  RatingBadge { source: RatingSource::Rt, value: "95%".to_string() },
1102
  RatingBadge { source: RatingSource::RtAudience, value: "40%".to_string() },
1103
  ];
1104
+ let result = render_backdrop_sync(&png, &badges, &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Official, BadgeDirection::Vertical, 1280, 1.0, BadgeSize::Medium).unwrap();
1105
  assert!(!result.is_empty());
1106
  assert_eq!(result[0], 0xFF);
1107
  assert_eq!(result[1], 0xD8);
 
1208
  let png = test_png(640, 360);
1209
  let sem = Arc::new(Semaphore::new(1));
1210
 
1211
+ let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeDirection::Vertical, sem.clone(), 1280, 1.0, BadgeSize::Medium).await;
1212
  assert!(result.is_ok());
1213
  assert_eq!(sem.available_permits(), 1);
1214
  }
 
1220
  let sem = Arc::new(Semaphore::new(1));
1221
  sem.close();
1222
 
1223
+ let result = generate_backdrop(png, vec![], font, 85, BadgePosition::TopRight, BadgeStyle::Horizontal, LabelStyle::Text, BadgeDirection::Vertical, sem, 1280, 1.0, BadgeSize::Medium).await;
1224
  assert!(result.is_err());
1225
  }
1226
 
 
1412
  fn render_backdrop_at_large_target_width() {
1413
  let font = FontArc::try_from_slice(crate::FONT_BYTES).unwrap();
1414
  let png = test_png(640, 360);
1415
+ let result = render_backdrop_sync(&png, &[], &font, 85, BadgePosition::TopRight, BadgeStyle::Vertical, LabelStyle::Text, BadgeDirection::Vertical, 3840, 2.0, BadgeSize::Medium).unwrap();
1416
  assert!(!result.is_empty());
1417
  assert_eq!(result[0], 0xFF);
1418
  assert_eq!(result[1], 0xD8);
api/src/image/serve.rs CHANGED
@@ -10,7 +10,7 @@ use crate::cache::{self, MemCacheEntry};
10
  use crate::error::AppError;
11
  use crate::id::{self, IdType, MediaType, format_tmdb_id_value};
12
  use crate::image::generate;
13
- use crate::services::db::{BadgeDirection, BadgeStyle, ImageSize, LabelStyle, RenderSettings};
14
  use crate::services::fanart::{FanartClient, FanartImages, FanartPoster, PosterMatch};
15
  use crate::services::ratings;
16
  use crate::AppState;
@@ -37,9 +37,49 @@ impl From<LogoBackdropKind> for cache::ImageType {
37
  }
38
  }
39
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
- /// Returns a cache key suffix for poster position.
42
- pub fn poster_position_cache_suffix(position: &str) -> String {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  format!(".p{position}")
44
  }
45
 
@@ -122,6 +162,8 @@ pub fn settings_cache_suffix_with_ratings(
122
  poster_badge_size: _,
123
  logo_badge_size: _,
124
  backdrop_badge_size: _,
 
 
125
  episode_ratings_limit: _,
126
  episode_badge_style: _,
127
  episode_label_style: _,
@@ -137,7 +179,7 @@ pub fn settings_cache_suffix_with_ratings(
137
 
138
  match kind {
139
  cache::ImageType::Poster => {
140
- let ps = poster_position_cache_suffix(settings.poster_position.as_str());
141
  let bs = badge_style_cache_suffix(settings.poster_badge_style.as_str());
142
  let ls = label_style_cache_suffix(settings.poster_label_style.as_str());
143
  let bd = badge_direction_cache_suffix(settings.poster_badge_direction.as_str());
@@ -151,13 +193,15 @@ pub fn settings_cache_suffix_with_ratings(
151
  format!("{rs}{bs}{ls}{bsz}{is_suffix}")
152
  }
153
  cache::ImageType::Backdrop => {
 
154
  let bs = badge_style_cache_suffix(settings.backdrop_badge_style.as_str());
155
  let ls = label_style_cache_suffix(settings.backdrop_label_style.as_str());
 
156
  let bsz = settings.backdrop_badge_size.cache_suffix();
157
- format!("{rs}{bs}{ls}{bsz}{is_suffix}")
158
  }
159
  cache::ImageType::Episode => {
160
- let ps = poster_position_cache_suffix(settings.episode_position.as_str());
161
  let bs = badge_style_cache_suffix(settings.episode_badge_style.as_str());
162
  let ls = label_style_cache_suffix(settings.episode_label_style.as_str());
163
  let bd = badge_direction_cache_suffix(settings.episode_badge_direction.as_str());
@@ -1028,20 +1072,9 @@ fn trigger_logo_backdrop_refresh(
1028
 
1029
  let image_type: cache::ImageType = lb_kind.into();
1030
 
1031
- let type_badge_style = match lb_kind {
1032
- LogoBackdropKind::Logo => settings.logo_badge_style,
1033
- LogoBackdropKind::Backdrop => settings.backdrop_badge_style,
1034
- };
1035
- let type_label_style = match lb_kind {
1036
- LogoBackdropKind::Logo => settings.logo_label_style,
1037
- LogoBackdropKind::Backdrop => settings.backdrop_label_style,
1038
- };
1039
-
1040
  let resolved_size = resolve_image_size(image_size);
1041
- let badge_size_factor = match lb_kind {
1042
- LogoBackdropKind::Logo => settings.logo_badge_size.scale_factor(),
1043
- LogoBackdropKind::Backdrop => settings.backdrop_badge_size.scale_factor(),
1044
- };
1045
  let (target_width, badge_scale) = match lb_kind {
1046
  LogoBackdropKind::Logo => (
1047
  resolved_size.logo_target_width(),
@@ -1053,8 +1086,8 @@ fn trigger_logo_backdrop_refresh(
1053
  ),
1054
  };
1055
  let bytes = match lb_kind {
1056
- LogoBackdropKind::Logo => generate::generate_logo(image_bytes, badges, state2.font.clone(), type_badge_style, type_label_style, state2.render_semaphore.clone(), target_width, badge_scale).await?,
1057
- LogoBackdropKind::Backdrop => generate::generate_backdrop(image_bytes, badges, state2.font.clone(), state2.config.image_quality, type_badge_style, type_label_style, state2.render_semaphore.clone(), target_width, badge_scale).await?,
1058
  };
1059
 
1060
  Ok((bytes, cross_ids.release_date.clone(), image_type, cross_ids))
@@ -1660,15 +1693,9 @@ pub async fn handle_logo_backdrop_inner(
1660
  LogoBackdropKind::Logo => settings.logo_ratings_limit,
1661
  LogoBackdropKind::Backdrop => settings.backdrop_ratings_limit,
1662
  };
1663
- let type_badge_style = match lb_kind {
1664
- LogoBackdropKind::Logo => settings.logo_badge_style,
1665
- LogoBackdropKind::Backdrop => settings.backdrop_badge_style,
1666
- }
1667
- .resolve(BadgeDirection::Vertical);
1668
- let type_label_style = match lb_kind {
1669
- LogoBackdropKind::Logo => settings.logo_label_style,
1670
- LogoBackdropKind::Backdrop => settings.backdrop_label_style,
1671
- };
1672
 
1673
  // Backdrops are language-agnostic (no text) — skip lang/textless entirely.
1674
  // Logos ARE the text — textless makes no sense, only lang matters.
@@ -1772,6 +1799,9 @@ pub async fn handle_logo_backdrop_inner(
1772
  neg_lang_key: String,
1773
  type_badge_style: BadgeStyle,
1774
  type_label_style: LabelStyle,
 
 
 
1775
  badge_size_factor: f32,
1776
  label: &'static str,
1777
  resolved: id::ResolvedId,
@@ -1790,10 +1820,10 @@ pub async fn handle_logo_backdrop_inner(
1790
  neg_lang_key,
1791
  type_badge_style,
1792
  type_label_style,
1793
- badge_size_factor: match lb_kind {
1794
- LogoBackdropKind::Logo => settings.logo_badge_size.scale_factor(),
1795
- LogoBackdropKind::Backdrop => settings.backdrop_badge_size.scale_factor(),
1796
- },
1797
  label,
1798
  resolved: resolved.clone(),
1799
  badges,
@@ -1876,7 +1906,7 @@ pub async fn handle_logo_backdrop_inner(
1876
  };
1877
  let bytes = match lb_kind {
1878
  LogoBackdropKind::Logo => generate::generate_logo(image_bytes, ctx.badges, ctx.state.font.clone(), ctx.type_badge_style, ctx.type_label_style, ctx.state.render_semaphore.clone(), target_width, badge_scale).await?,
1879
- LogoBackdropKind::Backdrop => generate::generate_backdrop(image_bytes, ctx.badges, ctx.state.font.clone(), ctx.state.config.image_quality, ctx.type_badge_style, ctx.type_label_style, ctx.state.render_semaphore.clone(), target_width, badge_scale).await?,
1880
  };
1881
 
1882
  let release_date = ctx.cross_ids.release_date.clone();
@@ -1899,7 +1929,7 @@ pub async fn handle_logo_backdrop_inner(
1899
  #[cfg(test)]
1900
  mod tests {
1901
  use super::*;
1902
- use crate::services::db::{BadgeStyle, ImageSource};
1903
 
1904
  #[test]
1905
  fn image_kind_prefix() {
@@ -1936,15 +1966,15 @@ mod tests {
1936
  }
1937
 
1938
  #[test]
1939
- fn poster_position_cache_suffix_all_positions() {
1940
- assert_eq!(poster_position_cache_suffix("bc"), ".pbc");
1941
- assert_eq!(poster_position_cache_suffix("tc"), ".ptc");
1942
- assert_eq!(poster_position_cache_suffix("l"), ".pl");
1943
- assert_eq!(poster_position_cache_suffix("r"), ".pr");
1944
- assert_eq!(poster_position_cache_suffix("tl"), ".ptl");
1945
- assert_eq!(poster_position_cache_suffix("tr"), ".ptr");
1946
- assert_eq!(poster_position_cache_suffix("bl"), ".pbl");
1947
- assert_eq!(poster_position_cache_suffix("br"), ".pbr");
1948
  }
1949
 
1950
  #[test]
@@ -2135,11 +2165,11 @@ mod tests {
2135
  }
2136
 
2137
  #[test]
2138
- fn settings_cache_suffix_backdrop_no_position_or_direction() {
2139
  let s = RenderSettings::default();
2140
  let suffix = settings_cache_suffix(&s, cache::ImageType::Backdrop, None);
2141
- assert!(!suffix.contains(".p"), "backdrop should not have position suffix");
2142
- assert!(!suffix.contains(".d"), "backdrop should not have direction suffix");
2143
  assert!(suffix.contains(".s"), "missing badge style suffix");
2144
  assert!(suffix.contains(".l"), "missing label style suffix");
2145
  assert!(suffix.contains(".z"), "missing image size suffix");
@@ -2217,6 +2247,76 @@ mod tests {
2217
  assert_eq!(suffix_imdb_rt, suffix_imdb_rt_2);
2218
  }
2219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2220
  #[test]
2221
  fn tmdb_poster_variant_all_combinations() {
2222
  // Default case: no variant (backward compatible)
 
10
  use crate::error::AppError;
11
  use crate::id::{self, IdType, MediaType, format_tmdb_id_value};
12
  use crate::image::generate;
13
+ use crate::services::db::{BadgeDirection, BadgePosition, BadgeSize, BadgeStyle, ImageSize, LabelStyle, RenderSettings};
14
  use crate::services::fanart::{FanartClient, FanartImages, FanartPoster, PosterMatch};
15
  use crate::services::ratings;
16
  use crate::AppState;
 
37
  }
38
  }
39
 
40
+ /// Resolved per-type render parameters for logo/backdrop generation.
41
+ ///
42
+ /// For logos, `position` and `badge_direction` are hardcoded (TopRight / Vertical)
43
+ /// and not passed to `generate_logo` — only `badge_style`, `label_style`, and
44
+ /// `badge_size` are used. They exist here so backdrop callers can read them
45
+ /// without a separate code path.
46
+ struct LbRenderParams {
47
+ position: BadgePosition,
48
+ badge_direction: BadgeDirection,
49
+ badge_style: BadgeStyle,
50
+ label_style: LabelStyle,
51
+ badge_size: BadgeSize,
52
+ }
53
 
54
+ impl LbRenderParams {
55
+ fn from_settings(lb_kind: LogoBackdropKind, settings: &RenderSettings) -> Self {
56
+ let position = match lb_kind {
57
+ LogoBackdropKind::Logo => BadgePosition::TopRight,
58
+ LogoBackdropKind::Backdrop => settings.backdrop_position,
59
+ };
60
+ let badge_direction = match lb_kind {
61
+ LogoBackdropKind::Logo => BadgeDirection::Vertical,
62
+ LogoBackdropKind::Backdrop => settings.backdrop_badge_direction.resolve(position),
63
+ };
64
+ let badge_style = match lb_kind {
65
+ LogoBackdropKind::Logo => settings.logo_badge_style,
66
+ LogoBackdropKind::Backdrop => settings.backdrop_badge_style,
67
+ }
68
+ .resolve(badge_direction);
69
+ let label_style = match lb_kind {
70
+ LogoBackdropKind::Logo => settings.logo_label_style,
71
+ LogoBackdropKind::Backdrop => settings.backdrop_label_style,
72
+ };
73
+ let badge_size = match lb_kind {
74
+ LogoBackdropKind::Logo => settings.logo_badge_size,
75
+ LogoBackdropKind::Backdrop => settings.backdrop_badge_size,
76
+ };
77
+ Self { position, badge_direction, badge_style, label_style, badge_size }
78
+ }
79
+ }
80
+
81
+ /// Returns a cache key suffix for badge position.
82
+ pub fn position_cache_suffix(position: &str) -> String {
83
  format!(".p{position}")
84
  }
85
 
 
162
  poster_badge_size: _,
163
  logo_badge_size: _,
164
  backdrop_badge_size: _,
165
+ backdrop_position: _,
166
+ backdrop_badge_direction: _,
167
  episode_ratings_limit: _,
168
  episode_badge_style: _,
169
  episode_label_style: _,
 
179
 
180
  match kind {
181
  cache::ImageType::Poster => {
182
+ let ps = position_cache_suffix(settings.poster_position.as_str());
183
  let bs = badge_style_cache_suffix(settings.poster_badge_style.as_str());
184
  let ls = label_style_cache_suffix(settings.poster_label_style.as_str());
185
  let bd = badge_direction_cache_suffix(settings.poster_badge_direction.as_str());
 
193
  format!("{rs}{bs}{ls}{bsz}{is_suffix}")
194
  }
195
  cache::ImageType::Backdrop => {
196
+ let ps = position_cache_suffix(settings.backdrop_position.as_str());
197
  let bs = badge_style_cache_suffix(settings.backdrop_badge_style.as_str());
198
  let ls = label_style_cache_suffix(settings.backdrop_label_style.as_str());
199
+ let bd = badge_direction_cache_suffix(settings.backdrop_badge_direction.as_str());
200
  let bsz = settings.backdrop_badge_size.cache_suffix();
201
+ format!("{rs}{ps}{bs}{ls}{bd}{bsz}{is_suffix}")
202
  }
203
  cache::ImageType::Episode => {
204
+ let ps = position_cache_suffix(settings.episode_position.as_str());
205
  let bs = badge_style_cache_suffix(settings.episode_badge_style.as_str());
206
  let ls = label_style_cache_suffix(settings.episode_label_style.as_str());
207
  let bd = badge_direction_cache_suffix(settings.episode_badge_direction.as_str());
 
1072
 
1073
  let image_type: cache::ImageType = lb_kind.into();
1074
 
1075
+ let params = LbRenderParams::from_settings(lb_kind, &settings);
 
 
 
 
 
 
 
 
1076
  let resolved_size = resolve_image_size(image_size);
1077
+ let badge_size_factor = params.badge_size.scale_factor();
 
 
 
1078
  let (target_width, badge_scale) = match lb_kind {
1079
  LogoBackdropKind::Logo => (
1080
  resolved_size.logo_target_width(),
 
1086
  ),
1087
  };
1088
  let bytes = match lb_kind {
1089
+ LogoBackdropKind::Logo => generate::generate_logo(image_bytes, badges, state2.font.clone(), params.badge_style, params.label_style, state2.render_semaphore.clone(), target_width, badge_scale).await?,
1090
+ LogoBackdropKind::Backdrop => generate::generate_backdrop(image_bytes, badges, state2.font.clone(), state2.config.image_quality, params.position, params.badge_style, params.label_style, params.badge_direction, state2.render_semaphore.clone(), target_width, badge_scale, params.badge_size).await?,
1091
  };
1092
 
1093
  Ok((bytes, cross_ids.release_date.clone(), image_type, cross_ids))
 
1693
  LogoBackdropKind::Logo => settings.logo_ratings_limit,
1694
  LogoBackdropKind::Backdrop => settings.backdrop_ratings_limit,
1695
  };
1696
+ let params = LbRenderParams::from_settings(lb_kind, settings);
1697
+ let type_badge_style = params.badge_style;
1698
+ let type_label_style = params.label_style;
 
 
 
 
 
 
1699
 
1700
  // Backdrops are language-agnostic (no text) — skip lang/textless entirely.
1701
  // Logos ARE the text — textless makes no sense, only lang matters.
 
1799
  neg_lang_key: String,
1800
  type_badge_style: BadgeStyle,
1801
  type_label_style: LabelStyle,
1802
+ type_position: BadgePosition,
1803
+ type_badge_direction: BadgeDirection,
1804
+ type_badge_size: BadgeSize,
1805
  badge_size_factor: f32,
1806
  label: &'static str,
1807
  resolved: id::ResolvedId,
 
1820
  neg_lang_key,
1821
  type_badge_style,
1822
  type_label_style,
1823
+ type_position: params.position,
1824
+ type_badge_direction: params.badge_direction,
1825
+ type_badge_size: params.badge_size,
1826
+ badge_size_factor: params.badge_size.scale_factor(),
1827
  label,
1828
  resolved: resolved.clone(),
1829
  badges,
 
1906
  };
1907
  let bytes = match lb_kind {
1908
  LogoBackdropKind::Logo => generate::generate_logo(image_bytes, ctx.badges, ctx.state.font.clone(), ctx.type_badge_style, ctx.type_label_style, ctx.state.render_semaphore.clone(), target_width, badge_scale).await?,
1909
+ 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_badge_direction, ctx.state.render_semaphore.clone(), target_width, badge_scale, ctx.type_badge_size).await?,
1910
  };
1911
 
1912
  let release_date = ctx.cross_ids.release_date.clone();
 
1929
  #[cfg(test)]
1930
  mod tests {
1931
  use super::*;
1932
+ use crate::services::db::{BadgeDirection, BadgePosition, BadgeSize, BadgeStyle, ImageSource, LabelStyle, RenderSettings};
1933
 
1934
  #[test]
1935
  fn image_kind_prefix() {
 
1966
  }
1967
 
1968
  #[test]
1969
+ fn position_cache_suffix_all_positions() {
1970
+ assert_eq!(position_cache_suffix("bc"), ".pbc");
1971
+ assert_eq!(position_cache_suffix("tc"), ".ptc");
1972
+ assert_eq!(position_cache_suffix("l"), ".pl");
1973
+ assert_eq!(position_cache_suffix("r"), ".pr");
1974
+ assert_eq!(position_cache_suffix("tl"), ".ptl");
1975
+ assert_eq!(position_cache_suffix("tr"), ".ptr");
1976
+ assert_eq!(position_cache_suffix("bl"), ".pbl");
1977
+ assert_eq!(position_cache_suffix("br"), ".pbr");
1978
  }
1979
 
1980
  #[test]
 
2165
  }
2166
 
2167
  #[test]
2168
+ fn settings_cache_suffix_backdrop_includes_position_and_direction() {
2169
  let s = RenderSettings::default();
2170
  let suffix = settings_cache_suffix(&s, cache::ImageType::Backdrop, None);
2171
+ assert!(suffix.contains(".p"), "backdrop should have position suffix");
2172
+ assert!(suffix.contains(".d"), "backdrop should have direction suffix");
2173
  assert!(suffix.contains(".s"), "missing badge style suffix");
2174
  assert!(suffix.contains(".l"), "missing label style suffix");
2175
  assert!(suffix.contains(".z"), "missing image size suffix");
 
2247
  assert_eq!(suffix_imdb_rt, suffix_imdb_rt_2);
2248
  }
2249
 
2250
+ // --- LbRenderParams tests ---
2251
+
2252
+ #[test]
2253
+ fn lb_render_params_logo_uses_hardcoded_defaults() {
2254
+ let settings = RenderSettings {
2255
+ logo_badge_style: BadgeStyle::Horizontal,
2256
+ logo_label_style: LabelStyle::Icon,
2257
+ logo_badge_size: BadgeSize::Large,
2258
+ // Backdrop fields should be ignored for logo
2259
+ backdrop_position: BadgePosition::BottomLeft,
2260
+ backdrop_badge_direction: BadgeDirection::Horizontal,
2261
+ backdrop_badge_style: BadgeStyle::Vertical,
2262
+ ..RenderSettings::default()
2263
+ };
2264
+ let p = LbRenderParams::from_settings(LogoBackdropKind::Logo, &settings);
2265
+ assert_eq!(p.position, BadgePosition::TopRight);
2266
+ assert_eq!(p.badge_direction, BadgeDirection::Vertical);
2267
+ assert_eq!(p.badge_style, BadgeStyle::Horizontal);
2268
+ assert_eq!(p.label_style, LabelStyle::Icon);
2269
+ assert_eq!(p.badge_size, BadgeSize::Large);
2270
+ }
2271
+
2272
+ #[test]
2273
+ fn lb_render_params_backdrop_reads_settings() {
2274
+ let settings = RenderSettings {
2275
+ backdrop_position: BadgePosition::BottomCenter,
2276
+ backdrop_badge_direction: BadgeDirection::Horizontal,
2277
+ backdrop_badge_style: BadgeStyle::Vertical,
2278
+ backdrop_label_style: LabelStyle::Official,
2279
+ backdrop_badge_size: BadgeSize::ExtraSmall,
2280
+ ..RenderSettings::default()
2281
+ };
2282
+ let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
2283
+ assert_eq!(p.position, BadgePosition::BottomCenter);
2284
+ assert_eq!(p.badge_direction, BadgeDirection::Horizontal);
2285
+ // badge_style Vertical stays Vertical (not Default), unaffected by direction
2286
+ assert_eq!(p.badge_style, BadgeStyle::Vertical);
2287
+ assert_eq!(p.label_style, LabelStyle::Official);
2288
+ assert_eq!(p.badge_size, BadgeSize::ExtraSmall);
2289
+ }
2290
+
2291
+ #[test]
2292
+ fn lb_render_params_backdrop_resolves_default_direction() {
2293
+ // BottomCenter is center-horizontal → Default direction resolves to Horizontal
2294
+ let settings = RenderSettings {
2295
+ backdrop_position: BadgePosition::BottomCenter,
2296
+ backdrop_badge_direction: BadgeDirection::Default,
2297
+ backdrop_badge_style: BadgeStyle::Default,
2298
+ ..RenderSettings::default()
2299
+ };
2300
+ let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
2301
+ assert_eq!(p.badge_direction, BadgeDirection::Horizontal);
2302
+ // Default style resolves to match direction (Horizontal)
2303
+ assert_eq!(p.badge_style, BadgeStyle::Horizontal);
2304
+ }
2305
+
2306
+ #[test]
2307
+ fn lb_render_params_backdrop_resolves_default_direction_vertical() {
2308
+ // TopRight is not center-horizontal → Default direction resolves to Vertical
2309
+ let settings = RenderSettings {
2310
+ backdrop_position: BadgePosition::TopRight,
2311
+ backdrop_badge_direction: BadgeDirection::Default,
2312
+ backdrop_badge_style: BadgeStyle::Default,
2313
+ ..RenderSettings::default()
2314
+ };
2315
+ let p = LbRenderParams::from_settings(LogoBackdropKind::Backdrop, &settings);
2316
+ assert_eq!(p.badge_direction, BadgeDirection::Vertical);
2317
+ assert_eq!(p.badge_style, BadgeStyle::Vertical);
2318
+ }
2319
+
2320
  #[test]
2321
  fn tmdb_poster_variant_all_combinations() {
2322
  // Default case: no variant (backward compatible)
api/src/lib.rs CHANGED
@@ -332,4 +332,12 @@ pub const MIGRATIONS: &[(&str, &str)] = &[
332
  "ALTER TABLE api_key_settings ADD COLUMN episode_blur INTEGER NOT NULL DEFAULT 0",
333
  "duplicate column",
334
  ),
 
 
 
 
 
 
 
 
335
  ];
 
332
  "ALTER TABLE api_key_settings ADD COLUMN episode_blur INTEGER NOT NULL DEFAULT 0",
333
  "duplicate column",
334
  ),
335
+ (
336
+ "ALTER TABLE api_key_settings ADD COLUMN backdrop_position TEXT NOT NULL DEFAULT 'tr'",
337
+ "duplicate column",
338
+ ),
339
+ (
340
+ "ALTER TABLE api_key_settings ADD COLUMN backdrop_badge_direction TEXT NOT NULL DEFAULT 'v'",
341
+ "duplicate column",
342
+ ),
343
  ];
api/src/services/db.rs CHANGED
@@ -481,6 +481,14 @@ pub fn default_episode_ratings_limit() -> i32 {
481
  1
482
  }
483
 
 
 
 
 
 
 
 
 
484
  // --- Badge size ---
485
 
486
  pub fn default_badge_size() -> BadgeSize {
@@ -1627,6 +1635,8 @@ pub struct UpsertApiKeySettings<'a> {
1627
  pub poster_badge_size: &'a str,
1628
  pub logo_badge_size: &'a str,
1629
  pub backdrop_badge_size: &'a str,
 
 
1630
  pub episode_ratings_limit: i32,
1631
  pub episode_badge_style: &'a str,
1632
  pub episode_label_style: &'a str,
@@ -1660,6 +1670,8 @@ pub async fn upsert_api_key_settings(
1660
  poster_badge_size: Set(params.poster_badge_size.to_string()),
1661
  logo_badge_size: Set(params.logo_badge_size.to_string()),
1662
  backdrop_badge_size: Set(params.backdrop_badge_size.to_string()),
 
 
1663
  episode_ratings_limit: Set(params.episode_ratings_limit),
1664
  episode_badge_style: Set(params.episode_badge_style.to_string()),
1665
  episode_label_style: Set(params.episode_label_style.to_string()),
@@ -1690,6 +1702,8 @@ pub async fn upsert_api_key_settings(
1690
  api_key_settings::Column::PosterBadgeSize,
1691
  api_key_settings::Column::LogoBadgeSize,
1692
  api_key_settings::Column::BackdropBadgeSize,
 
 
1693
  api_key_settings::Column::EpisodeRatingsLimit,
1694
  api_key_settings::Column::EpisodeBadgeStyle,
1695
  api_key_settings::Column::EpisodeLabelStyle,
@@ -1740,6 +1754,8 @@ pub struct RenderSettings {
1740
  pub poster_badge_size: BadgeSize,
1741
  pub logo_badge_size: BadgeSize,
1742
  pub backdrop_badge_size: BadgeSize,
 
 
1743
  pub episode_ratings_limit: i32,
1744
  pub episode_badge_style: BadgeStyle,
1745
  pub episode_label_style: LabelStyle,
@@ -1771,6 +1787,8 @@ impl Default for RenderSettings {
1771
  poster_badge_size: BadgeSize::Medium,
1772
  logo_badge_size: BadgeSize::Medium,
1773
  backdrop_badge_size: BadgeSize::Medium,
 
 
1774
  episode_ratings_limit: default_episode_ratings_limit(),
1775
  episode_badge_style: default_episode_badge_style(),
1776
  episode_label_style: default_label_style(),
@@ -1827,6 +1845,8 @@ pub fn parse_global_render_settings(globals: &HashMap<String, String>) -> Render
1827
  poster_badge_size: global_or(globals, "poster_badge_size", BadgeSize::parse, defaults.poster_badge_size),
1828
  logo_badge_size: global_or(globals, "logo_badge_size", BadgeSize::parse, defaults.logo_badge_size),
1829
  backdrop_badge_size: global_or(globals, "backdrop_badge_size", BadgeSize::parse, defaults.backdrop_badge_size),
 
 
1830
  episode_ratings_limit: globals
1831
  .get("episode_ratings_limit")
1832
  .and_then(|v| v.parse().ok())
@@ -1871,6 +1891,8 @@ pub async fn get_effective_render_settings(
1871
  poster_badge_size: parse_setting_or_default(&s.poster_badge_size, "poster_badge_size", BadgeSize::parse, BadgeSize::Medium),
1872
  logo_badge_size: parse_setting_or_default(&s.logo_badge_size, "logo_badge_size", BadgeSize::parse, BadgeSize::Medium),
1873
  backdrop_badge_size: parse_setting_or_default(&s.backdrop_badge_size, "backdrop_badge_size", BadgeSize::parse, BadgeSize::Medium),
 
 
1874
  episode_ratings_limit: s.episode_ratings_limit,
1875
  episode_badge_style: parse_setting_or_default(&s.episode_badge_style, "episode_badge_style", BadgeStyle::parse, default_episode_badge_style()),
1876
  episode_label_style: parse_setting_or_default(&s.episode_label_style, "episode_label_style", LabelStyle::parse, LabelStyle::Official),
 
481
  1
482
  }
483
 
484
+ pub fn default_backdrop_position() -> BadgePosition {
485
+ BadgePosition::TopRight
486
+ }
487
+
488
+ pub fn default_backdrop_badge_direction() -> BadgeDirection {
489
+ BadgeDirection::Vertical
490
+ }
491
+
492
  // --- Badge size ---
493
 
494
  pub fn default_badge_size() -> BadgeSize {
 
1635
  pub poster_badge_size: &'a str,
1636
  pub logo_badge_size: &'a str,
1637
  pub backdrop_badge_size: &'a str,
1638
+ pub backdrop_position: &'a str,
1639
+ pub backdrop_badge_direction: &'a str,
1640
  pub episode_ratings_limit: i32,
1641
  pub episode_badge_style: &'a str,
1642
  pub episode_label_style: &'a str,
 
1670
  poster_badge_size: Set(params.poster_badge_size.to_string()),
1671
  logo_badge_size: Set(params.logo_badge_size.to_string()),
1672
  backdrop_badge_size: Set(params.backdrop_badge_size.to_string()),
1673
+ backdrop_position: Set(params.backdrop_position.to_string()),
1674
+ backdrop_badge_direction: Set(params.backdrop_badge_direction.to_string()),
1675
  episode_ratings_limit: Set(params.episode_ratings_limit),
1676
  episode_badge_style: Set(params.episode_badge_style.to_string()),
1677
  episode_label_style: Set(params.episode_label_style.to_string()),
 
1702
  api_key_settings::Column::PosterBadgeSize,
1703
  api_key_settings::Column::LogoBadgeSize,
1704
  api_key_settings::Column::BackdropBadgeSize,
1705
+ api_key_settings::Column::BackdropPosition,
1706
+ api_key_settings::Column::BackdropBadgeDirection,
1707
  api_key_settings::Column::EpisodeRatingsLimit,
1708
  api_key_settings::Column::EpisodeBadgeStyle,
1709
  api_key_settings::Column::EpisodeLabelStyle,
 
1754
  pub poster_badge_size: BadgeSize,
1755
  pub logo_badge_size: BadgeSize,
1756
  pub backdrop_badge_size: BadgeSize,
1757
+ pub backdrop_position: BadgePosition,
1758
+ pub backdrop_badge_direction: BadgeDirection,
1759
  pub episode_ratings_limit: i32,
1760
  pub episode_badge_style: BadgeStyle,
1761
  pub episode_label_style: LabelStyle,
 
1787
  poster_badge_size: BadgeSize::Medium,
1788
  logo_badge_size: BadgeSize::Medium,
1789
  backdrop_badge_size: BadgeSize::Medium,
1790
+ backdrop_position: default_backdrop_position(),
1791
+ backdrop_badge_direction: default_backdrop_badge_direction(),
1792
  episode_ratings_limit: default_episode_ratings_limit(),
1793
  episode_badge_style: default_episode_badge_style(),
1794
  episode_label_style: default_label_style(),
 
1845
  poster_badge_size: global_or(globals, "poster_badge_size", BadgeSize::parse, defaults.poster_badge_size),
1846
  logo_badge_size: global_or(globals, "logo_badge_size", BadgeSize::parse, defaults.logo_badge_size),
1847
  backdrop_badge_size: global_or(globals, "backdrop_badge_size", BadgeSize::parse, defaults.backdrop_badge_size),
1848
+ backdrop_position: global_or(globals, "backdrop_position", BadgePosition::parse, defaults.backdrop_position),
1849
+ backdrop_badge_direction: global_or(globals, "backdrop_badge_direction", BadgeDirection::parse, defaults.backdrop_badge_direction),
1850
  episode_ratings_limit: globals
1851
  .get("episode_ratings_limit")
1852
  .and_then(|v| v.parse().ok())
 
1891
  poster_badge_size: parse_setting_or_default(&s.poster_badge_size, "poster_badge_size", BadgeSize::parse, BadgeSize::Medium),
1892
  logo_badge_size: parse_setting_or_default(&s.logo_badge_size, "logo_badge_size", BadgeSize::parse, BadgeSize::Medium),
1893
  backdrop_badge_size: parse_setting_or_default(&s.backdrop_badge_size, "backdrop_badge_size", BadgeSize::parse, BadgeSize::Medium),
1894
+ backdrop_position: parse_setting_or_default(&s.backdrop_position, "backdrop_position", BadgePosition::parse, default_backdrop_position()),
1895
+ backdrop_badge_direction: parse_setting_or_default(&s.backdrop_badge_direction, "backdrop_badge_direction", BadgeDirection::parse, default_backdrop_badge_direction()),
1896
  episode_ratings_limit: s.episode_ratings_limit,
1897
  episode_badge_style: parse_setting_or_default(&s.episode_badge_style, "episode_badge_style", BadgeStyle::parse, default_episode_badge_style()),
1898
  episode_label_style: parse_setting_or_default(&s.episode_label_style, "episode_label_style", LabelStyle::parse, LabelStyle::Official),
api/src/upgrade/mod.rs CHANGED
@@ -1,4 +1,5 @@
1
  mod v001_backdrop_cache_keys;
 
2
 
3
  use sea_orm::{ConnectionTrait, DatabaseConnection};
4
 
@@ -19,8 +20,10 @@ pub async fn run(
19
  })
20
  .await?;
21
 
22
- // Future upgrades:
23
- // run_once(db, "v002_...", || v002_...::run(db, cache_dir, external_cache_only)).await?;
 
 
24
 
25
  Ok(())
26
  }
 
1
  mod v001_backdrop_cache_keys;
2
+ mod v002_backdrop_position_direction_cache;
3
 
4
  use sea_orm::{ConnectionTrait, DatabaseConnection};
5
 
 
20
  })
21
  .await?;
22
 
23
+ run_once(db, "v002_backdrop_position_direction_cache", || {
24
+ v002_backdrop_position_direction_cache::run(db, cache_dir, external_cache_only)
25
+ })
26
+ .await?;
27
 
28
  Ok(())
29
  }
api/src/upgrade/v002_backdrop_position_direction_cache.rs ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ use sea_orm::{ConnectionTrait, DatabaseConnection};
2
+
3
+ use crate::error::AppError;
4
+
5
+ /// Migrate backdrop cache keys to include position and direction suffixes.
6
+ ///
7
+ /// Before this change, backdrop cache suffixes were:
8
+ /// `{ratings}.s{style}.l{label}.b{badge_size}.z{image_size}`
9
+ /// After this change they include position (`.ptr`) and direction (`.dv`):
10
+ /// `{ratings}.ptr.s{style}.l{label}.dv.b{badge_size}.z{image_size}`
11
+ ///
12
+ /// All existing backdrops were rendered with the hardcoded defaults
13
+ /// (TopRight position, Vertical direction), so we insert those defaults.
14
+ pub async fn run(
15
+ db: &DatabaseConnection,
16
+ cache_dir: &str,
17
+ external_cache_only: bool,
18
+ ) -> Result<(), AppError> {
19
+ run_db(db).await?;
20
+ run_fs(cache_dir, external_cache_only).await?;
21
+ Ok(())
22
+ }
23
+
24
+ /// Database step — insert `.ptr` before badge style and `.dv` after label style
25
+ /// in all backdrop cache keys.
26
+ pub async fn run_db(db: &impl ConnectionTrait) -> Result<(), AppError> {
27
+ let mut total = 0u64;
28
+
29
+ // Insert `.ptr` (default position = TopRight) before badge style (`.sv`, `.sh`, `.sd`)
30
+ // Use substr-based replacement to only replace the first occurrence of the pattern.
31
+ for style in ["sv", "sh", "sd"] {
32
+ let old = format!(".{style}.l");
33
+ let new = format!(".ptr.{style}.l");
34
+ let old_len = old.len() as i32;
35
+ let result = db
36
+ .execute(sea_orm::Statement::from_string(
37
+ sea_orm::DatabaseBackend::Sqlite,
38
+ format!(
39
+ "UPDATE image_meta SET cache_key = \
40
+ substr(cache_key, 1, instr(cache_key, '{old}') - 1) || '{new}' || \
41
+ substr(cache_key, instr(cache_key, '{old}') + {old_len}) \
42
+ WHERE image_type = 'b' AND instr(cache_key, '{old}') > 0 AND instr(cache_key, '.ptr.') = 0"
43
+ ),
44
+ ))
45
+ .await?;
46
+ total += result.rows_affected();
47
+ }
48
+
49
+ // Insert `.dv` (default direction = Vertical) after label style, before badge size
50
+ for label in ["lt", "li", "lo"] {
51
+ let old = format!(".{label}.b");
52
+ let new = format!(".{label}.dv.b");
53
+ let old_len = old.len() as i32;
54
+ let result = db
55
+ .execute(sea_orm::Statement::from_string(
56
+ sea_orm::DatabaseBackend::Sqlite,
57
+ format!(
58
+ "UPDATE image_meta SET cache_key = \
59
+ substr(cache_key, 1, instr(cache_key, '{old}') - 1) || '{new}' || \
60
+ substr(cache_key, instr(cache_key, '{old}') + {old_len}) \
61
+ WHERE image_type = 'b' AND instr(cache_key, '{old}') > 0 AND instr(cache_key, '.dv.') = 0"
62
+ ),
63
+ ))
64
+ .await?;
65
+ total += result.rows_affected();
66
+ }
67
+
68
+ tracing::info!(db_rows = total, "backdrop cache keys migrated (added position/direction suffixes)");
69
+ Ok(())
70
+ }
71
+
72
+ /// Filesystem step — rename backdrop cache files to include position/direction suffixes.
73
+ pub async fn run_fs(cache_dir: &str, external_cache_only: bool) -> Result<(), AppError> {
74
+ if external_cache_only {
75
+ tracing::info!("backdrop position/direction filesystem rename skipped (external_cache_only)");
76
+ return Ok(());
77
+ }
78
+
79
+ let backdrop_dir = std::path::Path::new(cache_dir).join("backdrops");
80
+ let renamed = tokio::task::spawn_blocking(move || rename_files(&backdrop_dir))
81
+ .await
82
+ .map_err(|e| AppError::Other(format!("rename task panicked: {e}")))?
83
+ ?;
84
+ tracing::info!(fs_renamed = renamed, "backdrop cache files renamed (added position/direction suffixes)");
85
+ Ok(())
86
+ }
87
+
88
+ /// Recursively rename backdrop cache files to include `.ptr` and `.dv` suffixes.
89
+ fn rename_files(dir: &std::path::Path) -> Result<u64, AppError> {
90
+ let entries = match std::fs::read_dir(dir) {
91
+ Ok(e) => e,
92
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
93
+ Err(e) => {
94
+ return Err(AppError::Other(format!(
95
+ "failed to read {}: {e}",
96
+ dir.display()
97
+ )))
98
+ }
99
+ };
100
+
101
+ let mut count = 0u64;
102
+ for entry in entries {
103
+ let entry = entry.map_err(|e| AppError::Other(e.to_string()))?;
104
+ let path = entry.path();
105
+ if path.is_dir() {
106
+ count += rename_files(&path)?;
107
+ } else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
108
+ if let Some(new_name) = migrate_name(name) {
109
+ let new_path = path.with_file_name(new_name);
110
+ std::fs::rename(&path, &new_path).map_err(|e| {
111
+ AppError::Other(format!(
112
+ "rename failed: {} → {}: {e}",
113
+ path.display(),
114
+ new_path.display()
115
+ ))
116
+ })?;
117
+ count += 1;
118
+ }
119
+ }
120
+ }
121
+
122
+ Ok(count)
123
+ }
124
+
125
+ /// Transform an old backdrop filename to include default position/direction suffixes.
126
+ ///
127
+ /// Returns `None` if already migrated or no matching pattern found.
128
+ fn migrate_name(name: &str) -> Option<String> {
129
+ // Already migrated — has both position and direction suffixes
130
+ if name.contains(".ptr.") && name.contains(".dv.") {
131
+ return None;
132
+ }
133
+
134
+ let mut result = name.to_string();
135
+ let mut modified = false;
136
+
137
+ // Insert `.ptr` before badge style (`.sv.l`, `.sh.l`, or `.sd.l`)
138
+ if !result.contains(".ptr.") {
139
+ for style in [".sv.l", ".sh.l", ".sd.l"] {
140
+ if let Some(pos) = result.find(style) {
141
+ result.insert_str(pos, ".ptr");
142
+ modified = true;
143
+ break;
144
+ }
145
+ }
146
+ }
147
+
148
+ // Insert `.dv` after label style, before badge size
149
+ if !result.contains(".dv.") {
150
+ for (old, new) in [(".lt.b", ".lt.dv.b"), (".li.b", ".li.dv.b"), (".lo.b", ".lo.dv.b")] {
151
+ if result.contains(old) {
152
+ result = result.replacen(old, new, 1);
153
+ modified = true;
154
+ break;
155
+ }
156
+ }
157
+ }
158
+
159
+ if modified { Some(result) } else { None }
160
+ }
161
+
162
+ #[cfg(test)]
163
+ mod tests {
164
+ use super::*;
165
+
166
+ #[test]
167
+ fn migrate_name_typical() {
168
+ assert_eq!(
169
+ migrate_name("tt1234567_b_f@mil.sv.lt.bm.zm.jpg"),
170
+ Some("tt1234567_b_f@mil.ptr.sv.lt.dv.bm.zm.jpg".to_string()),
171
+ );
172
+ }
173
+
174
+ #[test]
175
+ fn migrate_name_horizontal_style_icon_label() {
176
+ assert_eq!(
177
+ migrate_name("tt1234567_b_t@ir.sh.li.bl.zl.jpg"),
178
+ Some("tt1234567_b_t@ir.ptr.sh.li.dv.bl.zl.jpg".to_string()),
179
+ );
180
+ }
181
+
182
+ #[test]
183
+ fn migrate_name_default_style_official_label() {
184
+ assert_eq!(
185
+ migrate_name("tt1234567_b_f@m.sd.lo.bxs.zs.jpg"),
186
+ Some("tt1234567_b_f@m.ptr.sd.lo.dv.bxs.zs.jpg".to_string()),
187
+ );
188
+ }
189
+
190
+ #[test]
191
+ fn migrate_name_already_migrated() {
192
+ assert_eq!(
193
+ migrate_name("tt1234567_b_f@mil.ptr.sv.lt.dv.bm.zm.jpg"),
194
+ None,
195
+ );
196
+ }
197
+
198
+ #[test]
199
+ fn migrate_name_no_match() {
200
+ // Not a backdrop cache file
201
+ assert_eq!(migrate_name("tt1234567.jpg"), None);
202
+ }
203
+
204
+ #[test]
205
+ fn migrate_name_extra_small_badge_size() {
206
+ assert_eq!(
207
+ migrate_name("tt1234567_b_f@mil.sv.lt.bxs.zm.jpg"),
208
+ Some("tt1234567_b_f@mil.ptr.sv.lt.dv.bxs.zm.jpg".to_string()),
209
+ );
210
+ }
211
+
212
+ #[test]
213
+ fn migrate_name_extra_large_badge_size() {
214
+ assert_eq!(
215
+ migrate_name("tt1234567_b_f@mil.sv.lt.bxl.zvl.jpg"),
216
+ Some("tt1234567_b_f@mil.ptr.sv.lt.dv.bxl.zvl.jpg".to_string()),
217
+ );
218
+ }
219
+
220
+ #[test]
221
+ fn migrate_name_zero_ratings() {
222
+ assert_eq!(
223
+ migrate_name("tt1234567_b_f@.sv.lt.bm.zm.jpg"),
224
+ Some("tt1234567_b_f@.ptr.sv.lt.dv.bm.zm.jpg".to_string()),
225
+ );
226
+ }
227
+ }
api/tests/db_operations.rs CHANGED
@@ -25,6 +25,8 @@ fn test_upsert(key_id: i32) -> UpsertApiKeySettings<'static> {
25
  poster_badge_size: "m",
26
  logo_badge_size: "m",
27
  backdrop_badge_size: "m",
 
 
28
  episode_ratings_limit: 2,
29
  episode_badge_style: "v",
30
  episode_label_style: "o",
 
25
  poster_badge_size: "m",
26
  logo_badge_size: "m",
27
  backdrop_badge_size: "m",
28
+ backdrop_position: "tr",
29
+ backdrop_badge_direction: "v",
30
  episode_ratings_limit: 2,
31
  episode_badge_style: "v",
32
  episode_label_style: "o",
api/tests/upgrade.rs CHANGED
@@ -232,3 +232,153 @@ async fn v001_skips_filesystem_when_external_cache_only() {
232
  "renamed file should not exist in external_cache_only mode"
233
  );
234
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
232
  "renamed file should not exist in external_cache_only mode"
233
  );
234
  }
235
+
236
+ // --- v002 backdrop position/direction cache key migration: DB ---
237
+
238
+ #[tokio::test]
239
+ async fn v002_inserts_position_and_direction_in_db() {
240
+ let db = setup_db().await;
241
+ let dir = tempfile::tempdir().unwrap();
242
+
243
+ // Insert rows with old-style backdrop cache keys (no position/direction suffix)
244
+ db.execute_unprepared(
245
+ "INSERT INTO image_meta (cache_key, image_type, created_at, updated_at) VALUES
246
+ ('imdb/tt1234567_b_f@mil.sv.lt.bm.zm', 'b', 1000, 1000),
247
+ ('imdb/tt7654321_b_t@ir.sh.li.bl.zl', 'b', 1000, 1000),
248
+ ('imdb/tt0000001_p@mil.sv.lt.bm.zm', 'poster', 1000, 1000)"
249
+ )
250
+ .await
251
+ .unwrap();
252
+
253
+ openposterdb_api::upgrade::run(&db, dir.path().to_str().unwrap(), false)
254
+ .await
255
+ .expect("upgrade should succeed");
256
+
257
+ // Backdrop keys should have position and direction inserted
258
+ let row = db
259
+ .query_one(sea_orm::Statement::from_string(
260
+ sea_orm::DatabaseBackend::Sqlite,
261
+ "SELECT cache_key FROM image_meta WHERE cache_key = 'imdb/tt1234567_b_f@mil.ptr.sv.lt.dv.bm.zm'".to_string(),
262
+ ))
263
+ .await
264
+ .unwrap();
265
+ assert!(row.is_some(), "first backdrop key should have .ptr and .dv inserted");
266
+
267
+ let row = db
268
+ .query_one(sea_orm::Statement::from_string(
269
+ sea_orm::DatabaseBackend::Sqlite,
270
+ "SELECT cache_key FROM image_meta WHERE cache_key = 'imdb/tt7654321_b_t@ir.ptr.sh.li.dv.bl.zl'".to_string(),
271
+ ))
272
+ .await
273
+ .unwrap();
274
+ assert!(row.is_some(), "second backdrop key should have .ptr and .dv inserted");
275
+
276
+ // Poster key should be untouched
277
+ let row = db
278
+ .query_one(sea_orm::Statement::from_string(
279
+ sea_orm::DatabaseBackend::Sqlite,
280
+ "SELECT cache_key FROM image_meta WHERE cache_key = 'imdb/tt0000001_p@mil.sv.lt.bm.zm'".to_string(),
281
+ ))
282
+ .await
283
+ .unwrap();
284
+ assert!(row.is_some(), "poster key should be untouched");
285
+ }
286
+
287
+ #[tokio::test]
288
+ async fn v002_db_is_idempotent() {
289
+ let db = setup_db().await;
290
+ let dir = tempfile::tempdir().unwrap();
291
+
292
+ db.execute_unprepared(
293
+ "INSERT INTO image_meta (cache_key, image_type, created_at, updated_at) VALUES
294
+ ('imdb/tt1234567_b_f@mil.sv.lt.bm.zm', 'b', 1000, 1000)"
295
+ )
296
+ .await
297
+ .unwrap();
298
+
299
+ let cache_dir = dir.path().to_str().unwrap();
300
+ openposterdb_api::upgrade::run(&db, cache_dir, false)
301
+ .await
302
+ .expect("first run should succeed");
303
+ openposterdb_api::upgrade::run(&db, cache_dir, false)
304
+ .await
305
+ .expect("second run should succeed (idempotent)");
306
+
307
+ let row = db
308
+ .query_one(sea_orm::Statement::from_string(
309
+ sea_orm::DatabaseBackend::Sqlite,
310
+ "SELECT cache_key FROM image_meta WHERE cache_key = 'imdb/tt1234567_b_f@mil.ptr.sv.lt.dv.bm.zm'".to_string(),
311
+ ))
312
+ .await
313
+ .unwrap();
314
+ assert!(row.is_some(), "key should be migrated exactly once");
315
+ }
316
+
317
+ // --- v002 backdrop position/direction cache key migration: filesystem ---
318
+
319
+ #[tokio::test]
320
+ async fn v002_renames_backdrop_files() {
321
+ let db = setup_db().await;
322
+ let dir = tempfile::tempdir().unwrap();
323
+ let cache_dir = dir.path();
324
+
325
+ let backdrop_dir = cache_dir.join("backdrops").join("imdb");
326
+ std::fs::create_dir_all(&backdrop_dir).unwrap();
327
+
328
+ // Old-style filenames
329
+ std::fs::write(backdrop_dir.join("tt1234567_b_f@mil.sv.lt.bm.zm.jpg"), b"data1").unwrap();
330
+ std::fs::write(backdrop_dir.join("tt7654321_b_t@ir.sh.li.bl.zl.jpg"), b"data2").unwrap();
331
+ // Already migrated — should not be touched
332
+ std::fs::write(backdrop_dir.join("tt0000001_b_f@mil.ptr.sv.lt.dv.bm.zm.jpg"), b"data3").unwrap();
333
+
334
+ openposterdb_api::upgrade::run(&db, cache_dir.to_str().unwrap(), false)
335
+ .await
336
+ .expect("upgrade should succeed");
337
+
338
+ assert!(
339
+ backdrop_dir.join("tt1234567_b_f@mil.ptr.sv.lt.dv.bm.zm.jpg").exists(),
340
+ "first file should be renamed with .ptr and .dv"
341
+ );
342
+ assert!(
343
+ backdrop_dir.join("tt7654321_b_t@ir.ptr.sh.li.dv.bl.zl.jpg").exists(),
344
+ "second file should be renamed with .ptr and .dv"
345
+ );
346
+ assert!(
347
+ !backdrop_dir.join("tt1234567_b_f@mil.sv.lt.bm.zm.jpg").exists(),
348
+ "old first file should no longer exist"
349
+ );
350
+ assert!(
351
+ backdrop_dir.join("tt0000001_b_f@mil.ptr.sv.lt.dv.bm.zm.jpg").exists(),
352
+ "already-migrated file should be untouched"
353
+ );
354
+ }
355
+
356
+ #[tokio::test]
357
+ async fn v002_skips_filesystem_when_external_cache_only() {
358
+ let db = setup_db().await;
359
+ let dir = tempfile::tempdir().unwrap();
360
+ let cache_dir = dir.path();
361
+
362
+ let backdrop_dir = cache_dir.join("backdrops").join("imdb");
363
+ std::fs::create_dir_all(&backdrop_dir).unwrap();
364
+ std::fs::write(backdrop_dir.join("tt1234567_b_f@mil.sv.lt.bm.zm.jpg"), b"data").unwrap();
365
+
366
+ openposterdb_api::upgrade::run(&db, cache_dir.to_str().unwrap(), true)
367
+ .await
368
+ .expect("upgrade should succeed");
369
+
370
+ assert!(
371
+ backdrop_dir.join("tt1234567_b_f@mil.sv.lt.bm.zm.jpg").exists(),
372
+ "file should be untouched in external_cache_only mode"
373
+ );
374
+ }
375
+
376
+ #[tokio::test]
377
+ async fn v002_handles_missing_backdrop_directory() {
378
+ let db = setup_db().await;
379
+ let dir = tempfile::tempdir().unwrap();
380
+ // Don't create the backdrops subdirectory — should not error
381
+ openposterdb_api::upgrade::run(&db, dir.path().to_str().unwrap(), false)
382
+ .await
383
+ .expect("upgrade should succeed even without backdrops dir");
384
+ }
scripts/visual-report.sh CHANGED
@@ -262,6 +262,20 @@ for val in xs s m l xl; do
262
  done
263
  end_section
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  add_section "Backdrop - image_source"
266
  for val in t f; do
267
  path=$(fetch_image "backdrop_image_source=$val" "backdrop-default" "image_source=$val" "jpg")
@@ -283,6 +297,16 @@ for val in small medium large; do
283
  done
284
  end_section
285
 
 
 
 
 
 
 
 
 
 
 
286
  # Combined backdrop: badge_style × label_style
287
  add_section "Backdrop - badge_style × label_style"
288
  for bs in h v; do
 
262
  done
263
  end_section
264
 
265
+ add_section "Backdrop - badge_direction"
266
+ for val in h v d; do
267
+ path=$(fetch_image "backdrop_badge_direction=$val" "backdrop-default" "badge_direction=$val" "jpg")
268
+ add_image "badge_direction=$val" "$path"
269
+ done
270
+ end_section
271
+
272
+ add_section "Backdrop - position"
273
+ for val in bc tc l r tl tr bl br; do
274
+ path=$(fetch_image "backdrop_position=$val" "backdrop-default" "position=$val" "jpg")
275
+ add_image "position=$val" "$path"
276
+ done
277
+ end_section
278
+
279
  add_section "Backdrop - image_source"
280
  for val in t f; do
281
  path=$(fetch_image "backdrop_image_source=$val" "backdrop-default" "image_source=$val" "jpg")
 
297
  done
298
  end_section
299
 
300
+ # Combined backdrop: position × badge_direction (needs multiple ratings to see direction)
301
+ add_section "Backdrop - position × badge_direction"
302
+ for pos in bc tc l r tl tr bl br; do
303
+ for dir in h v; do
304
+ path=$(fetch_image "backdrop_pos=${pos}_dir=${dir}" "backdrop-default" "position=${pos}&badge_direction=${dir}" "jpg")
305
+ add_image "position=$pos badge_direction=$dir" "$path"
306
+ done
307
+ done
308
+ end_section
309
+
310
  # Combined backdrop: badge_style × label_style
311
  add_section "Backdrop - badge_style × label_style"
312
  for bs in h v; do
web/src/__tests__/components/RenderSettingsForm.spec.ts CHANGED
@@ -27,6 +27,8 @@ const defaultSettings: RenderSettings = {
27
  poster_badge_size: 'm',
28
  logo_badge_size: 'm',
29
  backdrop_badge_size: 'm',
 
 
30
  episode_ratings_limit: 1,
31
  episode_badge_style: 'v',
32
  episode_label_style: 'o',
 
27
  poster_badge_size: 'm',
28
  logo_badge_size: 'm',
29
  backdrop_badge_size: 'm',
30
+ backdrop_position: 'tr',
31
+ backdrop_badge_direction: 'v',
32
  episode_ratings_limit: 1,
33
  episode_badge_style: 'v',
34
  episode_label_style: 'o',
web/src/__tests__/views/SettingsView.spec.ts CHANGED
@@ -35,6 +35,8 @@ const defaultSettings = {
35
  logo_label_style: 't',
36
  backdrop_label_style: 't',
37
  poster_badge_direction: 'd',
 
 
38
  episode_ratings_limit: 1,
39
  episode_badge_style: 'v',
40
  episode_label_style: 'o',
 
35
  logo_label_style: 't',
36
  backdrop_label_style: 't',
37
  poster_badge_direction: 'd',
38
+ backdrop_position: 'tr',
39
+ backdrop_badge_direction: 'v',
40
  episode_ratings_limit: 1,
41
  episode_badge_style: 'v',
42
  episode_label_style: 'o',
web/src/components/RenderSettingsForm.vue CHANGED
@@ -37,6 +37,8 @@ export interface RenderSettings {
37
  poster_badge_size: string
38
  logo_badge_size: string
39
  backdrop_badge_size: string
 
 
40
  episode_ratings_limit: number
41
  episode_badge_style: string
42
  episode_label_style: string
@@ -54,7 +56,7 @@ const props = defineProps<{
54
  resetSettings?: () => Promise<boolean>
55
  fetchPreview: (ratingsLimit: number, ratingsOrder: string, posterPosition?: string, badgeStyle?: string, labelStyle?: string, badgeDirection?: string, badgeSize?: string) => Promise<Response>
56
  fetchLogoPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string) => Promise<Response>
57
- fetchBackdropPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string) => Promise<Response>
58
  fetchEpisodePreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean) => Promise<Response>
59
  }>()
60
 
@@ -77,6 +79,8 @@ const editPosterBadgeDirection = ref(props.settings.poster_badge_direction || 'd
77
  const editPosterBadgeSize = ref(props.settings.poster_badge_size || 'm')
78
  const editLogoBadgeSize = ref(props.settings.logo_badge_size || 'm')
79
  const editBackdropBadgeSize = ref(props.settings.backdrop_badge_size || 'm')
 
 
80
  const editEpisodeRatingsLimit = ref(props.settings.episode_ratings_limit ?? 1)
81
  const editEpisodeBadgeStyle = ref(props.settings.episode_badge_style || 'v')
82
  const editEpisodeLabelStyle = ref(props.settings.episode_label_style || 'o')
@@ -104,6 +108,8 @@ function applySettings(s: RenderSettings) {
104
  editPosterBadgeSize.value = s.poster_badge_size || 'm'
105
  editLogoBadgeSize.value = s.logo_badge_size || 'm'
106
  editBackdropBadgeSize.value = s.backdrop_badge_size || 'm'
 
 
107
  editEpisodeRatingsLimit.value = s.episode_ratings_limit ?? 1
108
  editEpisodeBadgeStyle.value = s.episode_badge_style || 'v'
109
  editEpisodeLabelStyle.value = s.episode_label_style || 'o'
@@ -165,6 +171,8 @@ async function autoSave() {
165
  poster_badge_size: editPosterBadgeSize.value,
166
  logo_badge_size: editLogoBadgeSize.value,
167
  backdrop_badge_size: editBackdropBadgeSize.value,
 
 
168
  episode_ratings_limit: editEpisodeRatingsLimit.value,
169
  episode_badge_style: editEpisodeBadgeStyle.value,
170
  episode_label_style: editEpisodeLabelStyle.value,
@@ -198,7 +206,7 @@ async function autoSave() {
198
 
199
  // Auto-save on any setting change
200
  watch(
201
- [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection, editPosterBadgeSize, editLogoBadgeSize, editBackdropBadgeSize, editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur],
202
  () => {
203
  if (syncing) return
204
  autoSave()
@@ -301,7 +309,7 @@ function updateLogoPreview() {
301
 
302
  function updateBackdropPreview() {
303
  if (props.fetchBackdropPreview) {
304
- fetchPreviewImage(backdropPreview.value, (_limit, order) => props.fetchBackdropPreview!(editBackdropRatingsLimit.value, order, editBackdropBadgeStyle.value, editBackdropLabelStyle.value, editBackdropBadgeSize.value))
305
  }
306
  }
307
 
@@ -346,7 +354,7 @@ watch([editLogoRatingsLimit, editLogoBadgeStyle, editLogoLabelStyle, editLogoBad
346
  })
347
 
348
  // Backdrop-only settings
349
- watch([editBackdropRatingsLimit, editBackdropBadgeStyle, editBackdropLabelStyle, editBackdropBadgeSize], () => {
350
  if (syncing) return
351
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
352
  backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
@@ -716,6 +724,43 @@ const inputId = (name: string) => props.uid ? `${name}-${props.uid}` : name
716
  </SelectContent>
717
  </Select>
718
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
719
  <div class="space-y-1">
720
  <div class="flex items-center gap-3">
721
  <Label :for="inputId('backdrop-ratings-limit')">Max ratings</Label>
 
37
  poster_badge_size: string
38
  logo_badge_size: string
39
  backdrop_badge_size: string
40
+ backdrop_position: string
41
+ backdrop_badge_direction: string
42
  episode_ratings_limit: number
43
  episode_badge_style: string
44
  episode_label_style: string
 
56
  resetSettings?: () => Promise<boolean>
57
  fetchPreview: (ratingsLimit: number, ratingsOrder: string, posterPosition?: string, badgeStyle?: string, labelStyle?: string, badgeDirection?: string, badgeSize?: string) => Promise<Response>
58
  fetchLogoPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string) => Promise<Response>
59
+ fetchBackdropPreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string) => Promise<Response>
60
  fetchEpisodePreview?: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean) => Promise<Response>
61
  }>()
62
 
 
79
  const editPosterBadgeSize = ref(props.settings.poster_badge_size || 'm')
80
  const editLogoBadgeSize = ref(props.settings.logo_badge_size || 'm')
81
  const editBackdropBadgeSize = ref(props.settings.backdrop_badge_size || 'm')
82
+ const editBackdropPosition = ref(props.settings.backdrop_position || 'tr')
83
+ const editBackdropBadgeDirection = ref(props.settings.backdrop_badge_direction || 'v')
84
  const editEpisodeRatingsLimit = ref(props.settings.episode_ratings_limit ?? 1)
85
  const editEpisodeBadgeStyle = ref(props.settings.episode_badge_style || 'v')
86
  const editEpisodeLabelStyle = ref(props.settings.episode_label_style || 'o')
 
108
  editPosterBadgeSize.value = s.poster_badge_size || 'm'
109
  editLogoBadgeSize.value = s.logo_badge_size || 'm'
110
  editBackdropBadgeSize.value = s.backdrop_badge_size || 'm'
111
+ editBackdropPosition.value = s.backdrop_position || 'tr'
112
+ editBackdropBadgeDirection.value = s.backdrop_badge_direction || 'v'
113
  editEpisodeRatingsLimit.value = s.episode_ratings_limit ?? 1
114
  editEpisodeBadgeStyle.value = s.episode_badge_style || 'v'
115
  editEpisodeLabelStyle.value = s.episode_label_style || 'o'
 
171
  poster_badge_size: editPosterBadgeSize.value,
172
  logo_badge_size: editLogoBadgeSize.value,
173
  backdrop_badge_size: editBackdropBadgeSize.value,
174
+ backdrop_position: editBackdropPosition.value,
175
+ backdrop_badge_direction: editBackdropBadgeDirection.value,
176
  episode_ratings_limit: editEpisodeRatingsLimit.value,
177
  episode_badge_style: editEpisodeBadgeStyle.value,
178
  episode_label_style: editEpisodeLabelStyle.value,
 
206
 
207
  // Auto-save on any setting change
208
  watch(
209
+ [editSource, editLang, editTextless, editRatingsLimit, editRatingsOrder, editPosterPosition, editLogoRatingsLimit, editBackdropRatingsLimit, editPosterBadgeStyle, editLogoBadgeStyle, editBackdropBadgeStyle, editPosterLabelStyle, editLogoLabelStyle, editBackdropLabelStyle, editPosterBadgeDirection, editPosterBadgeSize, editLogoBadgeSize, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection, editEpisodeRatingsLimit, editEpisodeBadgeStyle, editEpisodeLabelStyle, editEpisodeBadgeSize, editEpisodePosition, editEpisodeBadgeDirection, editEpisodeBlur],
210
  () => {
211
  if (syncing) return
212
  autoSave()
 
309
 
310
  function updateBackdropPreview() {
311
  if (props.fetchBackdropPreview) {
312
+ fetchPreviewImage(backdropPreview.value, (_limit, order) => props.fetchBackdropPreview!(editBackdropRatingsLimit.value, order, editBackdropBadgeStyle.value, editBackdropLabelStyle.value, editBackdropBadgeSize.value, editBackdropPosition.value, editBackdropBadgeDirection.value))
313
  }
314
  }
315
 
 
354
  })
355
 
356
  // Backdrop-only settings
357
+ watch([editBackdropRatingsLimit, editBackdropBadgeStyle, editBackdropLabelStyle, editBackdropBadgeSize, editBackdropPosition, editBackdropBadgeDirection], () => {
358
  if (syncing) return
359
  if (backdropPreviewTimer) clearTimeout(backdropPreviewTimer)
360
  backdropPreviewTimer = setTimeout(updateBackdropPreview, 500)
 
724
  </SelectContent>
725
  </Select>
726
  </div>
727
+ <div class="space-y-2">
728
+ <Label :for="inputId('backdrop-position')">Position</Label>
729
+ <Select
730
+ :model-value="editBackdropPosition"
731
+ @update:model-value="editBackdropPosition = $event as string"
732
+ >
733
+ <SelectTrigger :id="inputId('backdrop-position')" class="max-w-xs" data-testid="backdrop-position-select">
734
+ <SelectValue placeholder="Select position" />
735
+ </SelectTrigger>
736
+ <SelectContent>
737
+ <SelectItem value="tl">Top Left</SelectItem>
738
+ <SelectItem value="tc">Top Center</SelectItem>
739
+ <SelectItem value="tr">Top Right</SelectItem>
740
+ <SelectItem value="bl">Bottom Left</SelectItem>
741
+ <SelectItem value="bc">Bottom Center</SelectItem>
742
+ <SelectItem value="br">Bottom Right</SelectItem>
743
+ <SelectItem value="l">Left</SelectItem>
744
+ <SelectItem value="r">Right</SelectItem>
745
+ </SelectContent>
746
+ </Select>
747
+ </div>
748
+ <div class="space-y-2">
749
+ <Label :for="inputId('backdrop-badge-direction')">Badge direction</Label>
750
+ <Select
751
+ :model-value="editBackdropBadgeDirection"
752
+ @update:model-value="editBackdropBadgeDirection = $event as string"
753
+ >
754
+ <SelectTrigger :id="inputId('backdrop-badge-direction')" class="max-w-xs" data-testid="backdrop-badge-direction-select">
755
+ <SelectValue placeholder="Select direction" />
756
+ </SelectTrigger>
757
+ <SelectContent>
758
+ <SelectItem value="d">Default</SelectItem>
759
+ <SelectItem value="h">Horizontal</SelectItem>
760
+ <SelectItem value="v">Vertical</SelectItem>
761
+ </SelectContent>
762
+ </Select>
763
+ </div>
764
  <div class="space-y-1">
765
  <div class="flex items-center gap-3">
766
  <Label :for="inputId('backdrop-ratings-limit')">Max ratings</Label>
web/src/lib/api.ts CHANGED
@@ -83,6 +83,8 @@ export interface SaveSettingsPayload {
83
  poster_badge_size: string
84
  logo_badge_size: string
85
  backdrop_badge_size: string
 
 
86
  episode_ratings_limit: number
87
  episode_badge_style: string
88
  episode_label_style: string
@@ -138,8 +140,8 @@ export const adminApi = {
138
  get(buildUrl('/api/admin/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize })),
139
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
140
  get(buildUrl('/api/admin/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
141
- previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
142
- get(buildUrl('/api/admin/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
143
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean): Promise<Response> =>
144
  get(buildUrl('/api/admin/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined })),
145
  }
@@ -177,8 +179,8 @@ export const selfApi = {
177
  keyRequest(buildUrl('/api/key/me/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize })),
178
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
179
  keyRequest(buildUrl('/api/key/me/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
180
- previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
181
- keyRequest(buildUrl('/api/key/me/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
182
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean): Promise<Response> =>
183
  keyRequest(buildUrl('/api/key/me/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined })),
184
  }
 
83
  poster_badge_size: string
84
  logo_badge_size: string
85
  backdrop_badge_size: string
86
+ backdrop_position: string
87
+ backdrop_badge_direction: string
88
  episode_ratings_limit: number
89
  episode_badge_style: string
90
  episode_label_style: string
 
140
  get(buildUrl('/api/admin/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize })),
141
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
142
  get(buildUrl('/api/admin/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
143
+ previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string): Promise<Response> =>
144
+ get(buildUrl('/api/admin/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection })),
145
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean): Promise<Response> =>
146
  get(buildUrl('/api/admin/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined })),
147
  }
 
179
  keyRequest(buildUrl('/api/key/me/preview/poster', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, position: posterPosition, badge_style: badgeStyle, label_style: labelStyle, badge_direction: badgeDirection, badge_size: badgeSize })),
180
  previewLogo: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string): Promise<Response> =>
181
  keyRequest(buildUrl('/api/key/me/preview/logo', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize })),
182
+ previewBackdrop: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string): Promise<Response> =>
183
+ keyRequest(buildUrl('/api/key/me/preview/backdrop', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection })),
184
  previewEpisode: (ratingsLimit: number, ratingsOrder: string, badgeStyle?: string, labelStyle?: string, badgeSize?: string, position?: string, badgeDirection?: string, blur?: boolean): Promise<Response> =>
185
  keyRequest(buildUrl('/api/key/me/preview/episode', { ratings_limit: ratingsLimit, ratings_order: ratingsOrder, badge_style: badgeStyle, label_style: labelStyle, badge_size: badgeSize, position, badge_direction: badgeDirection, blur: blur ? 'true' : undefined })),
186
  }