Skip to main content

ferrex_model/image/
sizes.rs

1use std::fmt::Formatter;
2
3use std::fmt::Display;
4
5/// Image size variants
6#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
7#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
8#[cfg_attr(
9    feature = "rkyv",
10    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
11)]
12#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq, Hash)))]
13pub enum ImageSize {
14    Poster(PosterSize),     // Standard poster size
15    Backdrop(BackdropSize), // Wide backdrop/banner
16    Thumbnail(EpisodeSize), // Small size for grids
17    Profile(ProfileSize),   // Person profile image (2:3 aspect ratio)
18}
19
20#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
21#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
22#[cfg_attr(
23    feature = "sqlx",
24    sqlx(type_name = "image_variant", rename_all = "lowercase")
25)]
26pub enum ImageVariant {
27    Poster,
28    Backdrop,
29    Thumbnail,
30    Profile,
31}
32
33impl Display for ImageVariant {
34    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
35        match self {
36            ImageVariant::Poster => write!(f, "poster"),
37            ImageVariant::Backdrop => write!(f, "backdrop"),
38            ImageVariant::Thumbnail => write!(f, "thumbnail"),
39            ImageVariant::Profile => write!(f, "profile"),
40        }
41    }
42}
43
44impl ImageVariant {
45    #[inline]
46    fn as_str(&self) -> &'static str {
47        match self {
48            ImageVariant::Poster => "poster",
49            ImageVariant::Backdrop => "backdrop",
50            ImageVariant::Thumbnail => "thumbnail",
51            ImageVariant::Profile => "profile",
52        }
53    }
54
55    #[inline]
56    fn as_str_path(&self) -> &'static str {
57        match self {
58            ImageVariant::Poster => "/poster/",
59            ImageVariant::Backdrop => "/backdrop/",
60            ImageVariant::Thumbnail => "/thumbnail/",
61            ImageVariant::Profile => "/profile/",
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
67#[cfg_attr(feature = "sqlx", derive(sqlx::Type))]
68#[cfg_attr(
69    feature = "sqlx",
70    sqlx(type_name = "size_variant", rename_all = "lowercase")
71)]
72pub enum SqlxImageSizeVariant {
73    Original,
74    Resized,
75    Tmdb,
76}
77
78impl Display for ImageSize {
79    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
80        match self {
81            ImageSize::Thumbnail(s) => {
82                write!(f, "Thumbnail (size: {:#?})", s)
83            }
84            ImageSize::Poster(s) => write!(f, "Poster (size: {:#?})", s),
85            ImageSize::Backdrop(s) => {
86                write!(f, "Backdrop (size: {:#?})", s)
87            }
88            ImageSize::Profile(s) => write!(f, "Profile (size: {:#?})", s),
89        }
90    }
91}
92
93impl ImageSize {
94    // Default size constructors for convenience
95    /// Default thumbnail size (512px episode still)
96    pub const fn thumbnail() -> Self {
97        Self::Thumbnail(EpisodeSize::W512)
98    }
99
100    /// Default poster size (342px)
101    pub const fn poster() -> Self {
102        Self::Poster(PosterSize::W342)
103    }
104
105    /// Large poster size (780px)
106    pub const fn poster_large() -> Self {
107        Self::Poster(PosterSize::W780)
108    }
109
110    /// Default backdrop size (original)
111    pub const fn backdrop() -> Self {
112        Self::Backdrop(BackdropSize::Original(None))
113    }
114
115    /// Default profile size (180px)
116    pub const fn profile() -> Self {
117        Self::Profile(ProfileSize::W185)
118    }
119
120    /// Rounds up to the nearest tmdb api valid size variant, always returns with a specific width
121    pub fn to_nearest_tmdb_size(self, original_width: ImageSize) -> Self {
122        match self {
123            ImageSize::Poster(poster_size) => match poster_size {
124                PosterSize::CustomResized(w) => {
125                    for size in PosterSize::ALL.iter() {
126                        if let PosterSize::Original(None) = size {
127                            return original_width;
128                        }
129                        if w <= size.width_unchecked() {
130                            return ImageSize::Poster(*size);
131                        }
132                    }
133                    ImageSize::Poster(PosterSize::default())
134                }
135                PosterSize::Original(Some(_)) => self,
136                PosterSize::Original(None) => {
137                    ImageSize::Poster(PosterSize::default())
138                }
139                _ => self,
140            },
141            ImageSize::Backdrop(backdrop_size) => match backdrop_size {
142                BackdropSize::CustomResized(w) => {
143                    for size in BackdropSize::ALL.iter() {
144                        if let BackdropSize::Original(None) = size {
145                            return original_width;
146                        }
147                        if w <= size.width_unchecked() {
148                            return ImageSize::Backdrop(*size);
149                        }
150                    }
151                    ImageSize::Backdrop(BackdropSize::default())
152                }
153                BackdropSize::Original(Some(_)) => self,
154                BackdropSize::Original(None) => {
155                    ImageSize::Backdrop(BackdropSize::default())
156                }
157                _ => self,
158            },
159            ImageSize::Thumbnail(episode_size) => match episode_size {
160                EpisodeSize::CustomResized(w) => {
161                    for size in EpisodeSize::ALL.iter() {
162                        if let EpisodeSize::Original(None) = size {
163                            return original_width;
164                        }
165                        if w <= size.width_unchecked() {
166                            return ImageSize::Thumbnail(*size);
167                        }
168                    }
169                    ImageSize::Thumbnail(EpisodeSize::default())
170                }
171                EpisodeSize::Original(Some(_)) => self,
172                EpisodeSize::Original(None) => {
173                    ImageSize::Thumbnail(EpisodeSize::default())
174                }
175                _ => self,
176            },
177            ImageSize::Profile(profile_size) => match profile_size {
178                ProfileSize::CustomResized(w) => {
179                    for size in ProfileSize::ALL.iter() {
180                        if let ProfileSize::Original(None) = size {
181                            return original_width;
182                        }
183                        if w <= size.width_unchecked() {
184                            return ImageSize::Profile(*size);
185                        }
186                    }
187                    ImageSize::Profile(ProfileSize::default())
188                }
189                ProfileSize::Original(Some(_)) => self,
190                ProfileSize::Original(None) => {
191                    ImageSize::Profile(ProfileSize::default())
192                }
193                _ => self,
194            },
195        }
196    }
197
198    pub const fn original(width: u32, image_variant: ImageVariant) -> Self {
199        match image_variant {
200            ImageVariant::Poster => {
201                ImageSize::Poster(PosterSize::Original(Some(width)))
202            }
203            ImageVariant::Backdrop => {
204                ImageSize::Backdrop(BackdropSize::Original(Some(width)))
205            }
206            ImageVariant::Thumbnail => {
207                ImageSize::Thumbnail(EpisodeSize::Original(Some(width)))
208            }
209            ImageVariant::Profile => {
210                ImageSize::Profile(ProfileSize::Original(Some(width)))
211            }
212        }
213    }
214
215    pub const fn original_unknown(image_variant: ImageVariant) -> Self {
216        match image_variant {
217            ImageVariant::Poster => {
218                ImageSize::Poster(PosterSize::Original(None))
219            }
220            ImageVariant::Backdrop => {
221                ImageSize::Backdrop(BackdropSize::Original(None))
222            }
223            ImageVariant::Thumbnail => {
224                ImageSize::Thumbnail(EpisodeSize::Original(None))
225            }
226            ImageVariant::Profile => {
227                ImageSize::Profile(ProfileSize::Original(None))
228            }
229        }
230    }
231
232    pub const fn is_original(self) -> bool {
233        match self {
234            ImageSize::Poster(poster_size) => {
235                matches!(poster_size, PosterSize::Original(_))
236            }
237            ImageSize::Backdrop(backdrop_size) => {
238                matches!(backdrop_size, BackdropSize::Original(_))
239            }
240            ImageSize::Thumbnail(episode_size) => {
241                matches!(episode_size, EpisodeSize::Original(_))
242            }
243            ImageSize::Profile(profile_size) => {
244                matches!(profile_size, ProfileSize::Original(_))
245            }
246        }
247    }
248
249    pub const fn custom(width: u32, image_variant: ImageVariant) -> Self {
250        match image_variant {
251            ImageVariant::Poster => {
252                ImageSize::Poster(PosterSize::CustomResized(width))
253            }
254            ImageVariant::Backdrop => {
255                ImageSize::Backdrop(BackdropSize::CustomResized(width))
256            }
257            ImageVariant::Thumbnail => {
258                ImageSize::Thumbnail(EpisodeSize::CustomResized(width))
259            }
260            ImageVariant::Profile => {
261                ImageSize::Profile(ProfileSize::CustomResized(width))
262            }
263        }
264    }
265
266    pub const fn is_resized(self) -> bool {
267        match self {
268            ImageSize::Poster(poster_size) => {
269                matches!(poster_size, PosterSize::CustomResized(_))
270            }
271            ImageSize::Backdrop(backdrop_size) => {
272                matches!(backdrop_size, BackdropSize::CustomResized(_))
273            }
274            ImageSize::Thumbnail(episode_size) => {
275                matches!(episode_size, EpisodeSize::CustomResized(_))
276            }
277            ImageSize::Profile(profile_size) => {
278                matches!(profile_size, ProfileSize::CustomResized(_))
279            }
280        }
281    }
282
283    pub fn from_size_and_variant(width: u32, variant: ImageVariant) -> Self {
284        match variant {
285            ImageVariant::Poster => {
286                ImageSize::Poster(PosterSize::from_width(width))
287            }
288            ImageVariant::Backdrop => {
289                ImageSize::Backdrop(BackdropSize::from_width(width))
290            }
291            ImageVariant::Thumbnail => {
292                ImageSize::Thumbnail(EpisodeSize::from_width(width))
293            }
294            ImageVariant::Profile => {
295                ImageSize::Profile(ProfileSize::from_width(width))
296            }
297        }
298    }
299
300    pub fn sqlx_image_size_variant(self) -> SqlxImageSizeVariant {
301        match self {
302            ImageSize::Poster(s) => s.sqlx_image_size_variant(),
303            ImageSize::Backdrop(s) => s.sqlx_image_size_variant(),
304            ImageSize::Thumbnail(s) => s.sqlx_image_size_variant(),
305            ImageSize::Profile(s) => s.sqlx_image_size_variant(),
306        }
307    }
308
309    pub fn dimensions(&self) -> Option<(u32, u32)> {
310        match self {
311            ImageSize::Poster(s) => s.dimensions(),
312            ImageSize::Backdrop(s) => s.dimensions(),
313            ImageSize::Thumbnail(s) => s.dimensions(),
314            ImageSize::Profile(s) => s.dimensions(),
315        }
316    }
317
318    /// Panics if given an Original variant with no width included
319    pub fn dimensions_unchecked(&self) -> (u32, u32) {
320        self.dimensions().unwrap_or_else(|| {
321            panic!(
322                "dimensions_unchecked called for ImageSize with no dimensions available: {self:?}"
323            )
324        })
325    }
326
327    pub fn has_width(&self) -> bool {
328        !match self {
329            ImageSize::Poster(s) => {
330                matches!(s, PosterSize::Original(None))
331            }
332            ImageSize::Backdrop(s) => {
333                matches!(s, BackdropSize::Original(None))
334            }
335            ImageSize::Thumbnail(s) => {
336                matches!(s, EpisodeSize::Original(None))
337            }
338            ImageSize::Profile(s) => {
339                matches!(s, ProfileSize::Original(None))
340            }
341        }
342    }
343
344    pub fn image_variant(&self) -> ImageVariant {
345        match self {
346            ImageSize::Poster(_) => ImageVariant::Poster,
347            ImageSize::Backdrop(_) => ImageVariant::Backdrop,
348            ImageSize::Thumbnail(_) => ImageVariant::Thumbnail,
349            ImageSize::Profile(_) => ImageVariant::Profile,
350        }
351    }
352
353    #[inline]
354    pub fn image_variant_str(&self) -> &'static str {
355        match self {
356            ImageSize::Poster(_) => ImageVariant::Poster.as_str(),
357            ImageSize::Backdrop(_) => ImageVariant::Backdrop.as_str(),
358            ImageSize::Thumbnail(_) => ImageVariant::Thumbnail.as_str(),
359            ImageSize::Profile(_) => ImageVariant::Profile.as_str(),
360        }
361    }
362
363    #[inline]
364    pub fn image_variant_str_path(&self) -> &'static str {
365        match self {
366            ImageSize::Poster(_) => ImageVariant::Poster.as_str_path(),
367            ImageSize::Backdrop(_) => ImageVariant::Backdrop.as_str_path(),
368            ImageSize::Thumbnail(_) => ImageVariant::Thumbnail.as_str_path(),
369            ImageSize::Profile(_) => ImageVariant::Profile.as_str_path(),
370        }
371    }
372
373    /// Convert to URL string size
374    #[inline]
375    pub fn to_tmdb_param(&self) -> &'static str {
376        match self {
377            ImageSize::Thumbnail(s) => s.to_tmdb_param(),
378            ImageSize::Poster(s) => s.to_tmdb_param(),
379            ImageSize::Backdrop(s) => s.to_tmdb_param(),
380            ImageSize::Profile(s) => s.to_tmdb_param(),
381        }
382    }
383
384    /// Get the width hint for this size
385    pub fn width(&self) -> Option<u32> {
386        match self {
387            ImageSize::Thumbnail(s) => s.width(),
388            ImageSize::Poster(s) => s.width(),
389            ImageSize::Backdrop(s) => s.width(),
390            ImageSize::Profile(s) => s.width(),
391        }
392    }
393
394    /// Get the width for this size
395    /// Panics if given an Original variant with no width included
396    pub const fn width_unchecked(&self) -> u32 {
397        match self {
398            ImageSize::Thumbnail(s) => s.width_unchecked(),
399            ImageSize::Poster(s) => s.width_unchecked(),
400            ImageSize::Backdrop(s) => s.width_unchecked(),
401            ImageSize::Profile(s) => s.width_unchecked(),
402        }
403    }
404
405    pub fn width_name(&self) -> String {
406        match self {
407            ImageSize::Thumbnail(s) => s.width_name(),
408            ImageSize::Poster(s) => s.width_name(),
409            ImageSize::Backdrop(s) => s.width_name(),
410            ImageSize::Profile(s) => s.width_name(),
411        }
412    }
413
414    #[inline]
415    pub fn width_name_str(&self) -> &'static str {
416        match self {
417            ImageSize::Thumbnail(s) => s.width_name_str(),
418            ImageSize::Poster(s) => s.width_name_str(),
419            ImageSize::Backdrop(s) => s.width_name_str(),
420            ImageSize::Profile(s) => s.width_name_str(),
421        }
422    }
423}
424
425/// Episode still/thumbnail sizes (16:9 aspect ratio)
426#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, Default)]
427#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
428#[cfg_attr(
429    feature = "rkyv",
430    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
431)]
432#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq, Hash)))]
433pub enum EpisodeSize {
434    W256,
435    #[default]
436    W512,
437    W768,
438    /// Custom resized poster width
439    CustomResized(u32),
440    Original(Option<u32>),
441}
442
443impl EpisodeSize {
444    pub const ALL: [EpisodeSize; 3] = [Self::W256, Self::W512, Self::W768];
445
446    pub fn from_width(w: u32) -> Self {
447        match w {
448            256 => Self::W256,
449            512 => Self::W512,
450            768 => Self::W768,
451            w => Self::Original(Some(w)),
452        }
453    }
454
455    pub fn height_from_width(w: u32) -> u32 {
456        // Round to nearest integer height for a 16:9 aspect ratio.
457        // This keeps known TMDB sizes exact (multiples of 16) and behaves well
458        // for custom widths.
459        (w.saturating_mul(9).saturating_add(8)) / 16
460    }
461
462    pub fn dimensions(self) -> Option<(u32, u32)> {
463        match self {
464            EpisodeSize::W256 => Some((256, 144)),
465            EpisodeSize::W512 => Some((512, 288)),
466            EpisodeSize::W768 => Some((768, 432)),
467            EpisodeSize::CustomResized(w) => {
468                Some((w, Self::height_from_width(w)))
469            }
470            EpisodeSize::Original(w) => {
471                w.map(|w| (w, Self::height_from_width(w)))
472            }
473        }
474    }
475
476    pub const fn width(&self) -> Option<u32> {
477        match self {
478            Self::W256 => Some(256),
479            Self::W512 => Some(512),
480            Self::W768 => Some(768),
481            Self::CustomResized(w) => Some(*w),
482            Self::Original(Some(w)) => Some(*w),
483            Self::Original(None) => None,
484        }
485    }
486    /// Get the width for this size
487    /// Panics if given an Original variant with no width included
488    pub const fn width_unchecked(&self) -> u32 {
489        match self {
490            Self::W256 => 256,
491            Self::W512 => 512,
492            Self::W768 => 768,
493            Self::CustomResized(w) => *w,
494            Self::Original(Some(w)) => *w,
495            Self::Original(None) => panic!(
496                "Width not available for Original variant with no width included"
497            ),
498        }
499    }
500    pub fn width_name(&self) -> String {
501        match self {
502            Self::CustomResized(_) => "custom".to_string(),
503            Self::Original(Some(_)) => "original".to_string(),
504            Self::Original(None) => "original".to_string(),
505            _ => self.to_tmdb_param().to_string(),
506        }
507    }
508
509    #[inline]
510    pub fn width_name_str(&self) -> &'static str {
511        match self {
512            Self::CustomResized(_) => "custom",
513            Self::Original(Some(_)) => "original",
514            Self::Original(None) => "original",
515            _ => self.to_tmdb_param(),
516        }
517    }
518
519    pub fn sqlx_image_size_variant(&self) -> SqlxImageSizeVariant {
520        match self {
521            Self::CustomResized(_) => SqlxImageSizeVariant::Resized,
522            Self::Original(_) => SqlxImageSizeVariant::Original,
523            _ => SqlxImageSizeVariant::Tmdb,
524        }
525    }
526
527    #[inline]
528    pub const fn to_tmdb_param(&self) -> &'static str {
529        match self {
530            Self::W256 => "w256",
531            Self::W512 => "w512",
532            Self::W768 => "w768",
533            Self::CustomResized(_) => "invalid",
534            Self::Original(_) => "original",
535        }
536    }
537}
538
539impl Display for EpisodeSize {
540    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
541        match self {
542            Self::W256 => write!(f, "256px"),
543            Self::W512 => write!(f, "512px"),
544            Self::W768 => write!(f, "768px"),
545            Self::CustomResized(w) => write!(f, "{}px", w),
546            Self::Original(_) => write!(f, "Original"),
547        }
548    }
549}
550
551/// Poster image sizes (2:3 aspect ratio)
552///
553/// These are the target output sizes the player can request. The server will
554/// resize TMDB source images to these dimensions during storage/caching.
555#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
556#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
557#[cfg_attr(
558    feature = "rkyv",
559    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
560)]
561#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq, Hash)))]
562pub enum PosterSize {
563    /// 92px width - tiny thumbnail
564    W92,
565    /// 154px width - small poster
566    W154,
567    /// 185px width - medium poster (default)
568    W185,
569    /// 342px width - large poster
570    W342,
571    /// 500px width - high quality poster
572    W500,
573    /// 780px width - very high quality poster
574    W780,
575    /// Custom resized poster width
576    CustomResized(u32),
577    /// Original resolution (optional known width)
578    Original(Option<u32>),
579}
580
581impl Default for PosterSize {
582    fn default() -> Self {
583        PosterSize::Original(None)
584    }
585}
586
587impl PosterSize {
588    /// All available poster sizes for UI enumeration (excluding Original)
589    pub const ALL: [PosterSize; 6] = [
590        Self::W92,
591        Self::W154,
592        Self::W185,
593        Self::W342,
594        Self::W500,
595        Self::W780,
596    ];
597
598    pub fn from_width(w: u32) -> Self {
599        match w {
600            92 => Self::W92,
601            154 => Self::W154,
602            185 => Self::W185,
603            342 => Self::W342,
604            500 => Self::W500,
605            780 => Self::W780,
606            w => Self::CustomResized(w),
607        }
608    }
609
610    pub fn original(w: u32) -> Self {
611        Self::Original(Some(w))
612    }
613
614    pub fn height_from_width(w: u32) -> u32 {
615        (w / 2) * 3
616    }
617
618    /// Get the pixel dimensions for this size (width, height at 2:3 ratio)
619    pub fn dimensions(self) -> Option<(u32, u32)> {
620        match self {
621            PosterSize::W92 => Some((92, 138)),
622            PosterSize::W154 => Some((154, 231)),
623            PosterSize::W185 => Some((185, 277)),
624            PosterSize::W342 => Some((342, 513)),
625            PosterSize::W500 => Some((500, 750)),
626            PosterSize::W780 => Some((780, 1170)),
627            PosterSize::CustomResized(w) => Some((w, (w / 2) * 3)),
628            PosterSize::Original(w) => w.map(|w| (w, (w / 2) * 3)),
629        }
630    }
631
632    /// Get the width for this size
633    pub const fn width(&self) -> Option<u32> {
634        match self {
635            Self::W92 => Some(92),
636            Self::W154 => Some(154),
637            Self::W185 => Some(185),
638            Self::W342 => Some(342),
639            Self::W500 => Some(500),
640            Self::W780 => Some(780),
641            Self::CustomResized(w) => Some(*w),
642            Self::Original(Some(w)) => Some(*w),
643            Self::Original(None) => None,
644        }
645    }
646
647    /// Get the width for this size
648    /// Panics if given an Original variant with no width included
649    pub const fn width_unchecked(&self) -> u32 {
650        match self {
651            Self::W92 => 92,
652            Self::W154 => 154,
653            Self::W185 => 185,
654            Self::W342 => 342,
655            Self::W500 => 500,
656            Self::W780 => 780,
657            Self::CustomResized(w) => *w,
658            Self::Original(Some(w)) => *w,
659            Self::Original(None) => panic!(
660                "Width not available for Original variant with no width included"
661            ),
662        }
663    }
664
665    pub fn width_name(&self) -> String {
666        match self {
667            Self::CustomResized(_) => "custom".to_string(),
668            Self::Original(Some(_)) => "original".to_string(),
669            Self::Original(None) => "original".to_string(),
670            _ => self.to_tmdb_param().to_string(),
671        }
672    }
673
674    #[inline]
675    pub fn width_name_str(&self) -> &'static str {
676        match self {
677            Self::CustomResized(_) => "custom",
678            Self::Original(Some(_)) => "original",
679            Self::Original(None) => "original",
680            _ => self.to_tmdb_param(),
681        }
682    }
683
684    pub fn sqlx_image_size_variant(&self) -> SqlxImageSizeVariant {
685        match self {
686            Self::CustomResized(_) => SqlxImageSizeVariant::Resized,
687            Self::Original(_) => SqlxImageSizeVariant::Original,
688            _ => SqlxImageSizeVariant::Tmdb,
689        }
690    }
691
692    /// Convert to URL string size
693    #[inline]
694    pub const fn to_tmdb_param(&self) -> &'static str {
695        match self {
696            Self::W92 => "w92",
697            Self::W154 => "w154",
698            Self::W185 => "w185",
699            Self::W342 => "w342",
700            Self::W500 => "w500",
701            Self::W780 => "w780",
702            Self::CustomResized(_width) => "invalid",
703            Self::Original(_) => "original",
704        }
705    }
706}
707
708impl Display for PosterSize {
709    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
710        match self {
711            Self::W92 => write!(f, "92px"),
712            Self::W154 => write!(f, "154px"),
713            Self::W185 => write!(f, "185px"),
714            Self::W342 => write!(f, "342px"),
715            Self::W500 => write!(f, "500px"),
716            Self::W780 => write!(f, "780px"),
717            Self::CustomResized(w) => write!(f, "{}px", w),
718            Self::Original(_) => write!(f, "Original"),
719        }
720    }
721}
722
723/// 16:9 Widescreen Media Backdrop Sizes
724#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq)]
725#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
726#[cfg_attr(
727    feature = "rkyv",
728    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
729)]
730#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq, Hash)))]
731pub enum BackdropSize {
732    W300,
733    W780,
734    W1280,
735    /// Custom resized width
736    CustomResized(u32),
737    Original(Option<u32>),
738}
739impl Default for BackdropSize {
740    fn default() -> Self {
741        BackdropSize::Original(None)
742    }
743}
744
745impl BackdropSize {
746    pub const ALL: [BackdropSize; 3] = [Self::W300, Self::W780, Self::W1280];
747
748    pub fn from_width(w: u32) -> Self {
749        match w {
750            300 => Self::W300,
751            780 => Self::W780,
752            1280 => Self::W1280,
753            w => Self::Original(Some(w)),
754        }
755    }
756
757    pub fn original(w: u32) -> Self {
758        Self::Original(Some(w))
759    }
760
761    // TODO: Consider pre-cropping backdrop to wider aspect
762    // to save memory/bandwidth vs current player-size crop
763    pub fn height_from_width(w: u32) -> u32 {
764        // Round to nearest integer height for a 16:9 aspect ratio.
765        (w.saturating_mul(9).saturating_add(8)) / 16
766    }
767
768    pub fn dimensions(self) -> Option<(u32, u32)> {
769        match self {
770            Self::W300 => Some((300, 169)),
771            Self::W780 => Some((780, 439)),
772            Self::W1280 => Some((1280, 720)),
773            Self::CustomResized(w) => Some((w, Self::height_from_width(w))),
774            Self::Original(w) => w.map(|w| (w, Self::height_from_width(w))),
775        }
776    }
777
778    pub const fn width(&self) -> Option<u32> {
779        match self {
780            Self::W300 => Some(300),
781            Self::W780 => Some(780),
782            Self::W1280 => Some(1280),
783            Self::CustomResized(w) => Some(*w),
784            Self::Original(Some(w)) => Some(*w),
785            Self::Original(None) => None,
786        }
787    }
788    /// Get the width for this size
789    /// Panics if given an Original variant with no width included
790    pub const fn width_unchecked(&self) -> u32 {
791        match self {
792            Self::W300 => 300,
793            Self::W780 => 780,
794            Self::W1280 => 1280,
795            Self::CustomResized(w) => *w,
796            Self::Original(Some(w)) => *w,
797            Self::Original(None) => panic!(
798                "Width not available for Original variant with no width included"
799            ),
800        }
801    }
802
803    pub fn width_name(&self) -> String {
804        match self {
805            Self::CustomResized(_) => "custom".to_string(),
806            Self::Original(Some(_)) => "original".to_string(),
807            Self::Original(None) => "original".to_string(),
808            _ => self.to_tmdb_param().to_string(),
809        }
810    }
811
812    #[inline]
813    pub fn width_name_str(&self) -> &'static str {
814        match self {
815            Self::CustomResized(_) => "custom",
816            Self::Original(Some(_)) => "original",
817            Self::Original(None) => "original",
818            _ => self.to_tmdb_param(),
819        }
820    }
821
822    pub fn sqlx_image_size_variant(&self) -> SqlxImageSizeVariant {
823        match self {
824            Self::CustomResized(_) => SqlxImageSizeVariant::Resized,
825            Self::Original(_) => SqlxImageSizeVariant::Original,
826            _ => SqlxImageSizeVariant::Tmdb,
827        }
828    }
829
830    #[inline]
831    pub const fn to_tmdb_param(&self) -> &'static str {
832        match self {
833            Self::W300 => "w300",
834            Self::W780 => "w780",
835            Self::W1280 => "w1280",
836            Self::CustomResized(_) => "invalid",
837            Self::Original(_) => "original",
838        }
839    }
840}
841
842impl Display for BackdropSize {
843    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
844        match self {
845            Self::W300 => write!(f, "300px"),
846            Self::W780 => write!(f, "780px"),
847            Self::W1280 => write!(f, "1280px"),
848            Self::CustomResized(w) => write!(f, "Custom({}px)", w),
849            Self::Original(Some(w)) => write!(f, "Original({}px)", w),
850            Self::Original(None) => write!(f, "Original"),
851        }
852    }
853}
854
855/// Profile/cast image sizes (2:3 aspect ratio)
856#[derive(Debug, Clone, Copy, PartialEq, Hash, Eq, Default)]
857#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
858#[cfg_attr(
859    feature = "rkyv",
860    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
861)]
862#[cfg_attr(feature = "rkyv", rkyv(derive(Debug, PartialEq, Eq, Hash)))]
863pub enum ProfileSize {
864    W45,
865    #[default]
866    W185,
867    W632,
868    /// Custom resized width
869    CustomResized(u32),
870    Original(Option<u32>),
871}
872
873impl ProfileSize {
874    pub const ALL: [ProfileSize; 3] = [Self::W45, Self::W185, Self::W632];
875
876    pub fn from_width(w: u32) -> Self {
877        match w {
878            45 => Self::W45,
879            185 => Self::W185,
880            632 => Self::W632,
881            w => Self::Original(Some(w)),
882        }
883    }
884
885    // TODO: Consider pre-cropping backdrop to wider aspect
886    // to save memory/bandwidth vs current player-size crop
887    pub fn height_from_width(w: u32) -> u32 {
888        (w / 2) * 3
889    }
890
891    pub fn dimensions(self) -> Option<(u32, u32)> {
892        match self {
893            ProfileSize::W45 => Some((45, 138)),
894            ProfileSize::W185 => Some((185, 270)),
895            ProfileSize::W632 => Some((632, 450)),
896            ProfileSize::CustomResized(w) => Some((w, (w / 2) * 3)),
897            ProfileSize::Original(w) => w.map(|w| (w, (w / 2) * 3)),
898        }
899    }
900
901    pub const fn width(&self) -> Option<u32> {
902        match self {
903            Self::W45 => Some(45),
904            Self::W185 => Some(185),
905            Self::W632 => Some(632),
906            Self::CustomResized(w) => Some(*w),
907            Self::Original(Some(w)) => Some(*w),
908            Self::Original(None) => None,
909        }
910    }
911    /// Get the width for this size
912    /// Panics if given an Original variant with no width included
913    pub const fn width_unchecked(&self) -> u32 {
914        match self {
915            Self::W45 => 45,
916            Self::W185 => 185,
917            Self::W632 => 632,
918            Self::CustomResized(w) => *w,
919            Self::Original(Some(w)) => *w,
920            Self::Original(None) => panic!(
921                "Width not available for Original variant with no width included"
922            ),
923        }
924    }
925
926    pub fn width_name(&self) -> String {
927        match self {
928            ProfileSize::W45 | ProfileSize::W185 | ProfileSize::W632 => {
929                self.to_tmdb_param().to_string()
930            }
931            ProfileSize::CustomResized(_) => "custom".to_string(),
932            ProfileSize::Original(Some(_)) => "original".to_string(),
933            ProfileSize::Original(None) => "original".to_string(),
934        }
935    }
936
937    #[inline]
938    pub fn width_name_str(&self) -> &'static str {
939        match self {
940            Self::CustomResized(_) => "custom",
941            Self::Original(Some(_)) => "original",
942            Self::Original(None) => "original",
943            _ => self.to_tmdb_param(),
944        }
945    }
946
947    pub fn sqlx_image_size_variant(&self) -> SqlxImageSizeVariant {
948        match self {
949            Self::CustomResized(_) => SqlxImageSizeVariant::Resized,
950            Self::Original(_) => SqlxImageSizeVariant::Original,
951            _ => SqlxImageSizeVariant::Tmdb,
952        }
953    }
954
955    #[inline]
956    pub const fn to_tmdb_param(&self) -> &'static str {
957        match self {
958            Self::W45 => "w45",
959            Self::W185 => "w185",
960            Self::W632 => "w632",
961            Self::CustomResized(_) => "invalid",
962            Self::Original(_) => "original",
963        }
964    }
965}
966
967impl Display for ProfileSize {
968    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
969        match self {
970            Self::W45 => write!(f, "45px"),
971            Self::W185 => write!(f, "185px"),
972            Self::W632 => write!(f, "632px"),
973            Self::CustomResized(w) => write!(f, "{}px", w),
974            Self::Original(w) => {
975                if let Some(w) = w {
976                    write!(f, "{}px", w)
977                } else {
978                    write!(f, "Original")
979                }
980            }
981        }
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn backdrop_height_from_width_matches_known_tmdb_sizes() {
991        assert_eq!(BackdropSize::height_from_width(300), 169);
992        assert_eq!(BackdropSize::height_from_width(780), 439);
993        assert_eq!(BackdropSize::height_from_width(1280), 720);
994    }
995
996    #[test]
997    fn episode_height_from_width_matches_known_tmdb_sizes() {
998        assert_eq!(EpisodeSize::height_from_width(256), 144);
999        assert_eq!(EpisodeSize::height_from_width(512), 288);
1000        assert_eq!(EpisodeSize::height_from_width(768), 432);
1001    }
1002
1003    #[test]
1004    fn image_size_dimensions_unchecked_is_available_for_fixed_sizes() {
1005        let samples = [
1006            ImageSize::poster(),
1007            ImageSize::poster_large(),
1008            ImageSize::Backdrop(BackdropSize::W780),
1009            ImageSize::Thumbnail(EpisodeSize::W512),
1010            ImageSize::Profile(ProfileSize::W185),
1011        ];
1012
1013        for imz in samples {
1014            let dims = imz.dimensions_unchecked();
1015            assert!(dims.0 > 0);
1016            assert!(dims.1 > 0);
1017        }
1018    }
1019}