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