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