mod etag;
mod raw;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use anyhow::Result;
use chrono::{DateTime, NaiveDate, Utc};
use relative_path::RelativePath;
use serde::de::IntoDeserializer;
use serde::{de, ser, Deserialize, Serialize};
use uuid::Uuid;
pub(crate) use self::etag::Etag;
pub(crate) use self::raw::Raw;
macro_rules! id {
($name:ident) => {
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[repr(transparent)]
#[serde(transparent)]
pub(crate) struct $name(Uuid);
impl $name {
#[inline]
#[allow(unused)]
pub(crate) fn random() -> Self {
Self(Uuid::new_v4())
}
}
impl fmt::Display for $name {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for $name {
type Err = uuid::Error;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?))
}
}
};
}
id!(SeriesId);
id!(EpisodeId);
id!(MovieId);
id!(WatchedId);
id!(TaskId);
impl SeriesId {
#[inline]
pub(crate) fn id(&self) -> &Uuid {
&self.0
}
}
#[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ThemeType {
Light,
#[default]
Dark,
}
#[inline]
fn default_days() -> u64 {
7
}
#[inline]
fn default_dashboard_limit() -> usize {
1
}
#[inline]
fn default_dashboard_page() -> usize {
6
}
#[inline]
fn default_schedule_limit() -> usize {
1
}
#[inline]
fn default_schedule_page() -> usize {
7
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct Config {
#[serde(default)]
pub(crate) theme: ThemeType,
#[serde(default)]
pub(crate) tvdb_legacy_apikey: String,
#[serde(default)]
pub(crate) tmdb_api_key: String,
#[serde(default = "default_days")]
pub(crate) schedule_duration_days: u64,
#[serde(default = "default_dashboard_limit")]
pub(crate) dashboard_limit: usize,
#[serde(default = "default_dashboard_page")]
pub(crate) dashboard_page: usize,
#[serde(default = "default_schedule_limit")]
pub(crate) schedule_limit: usize,
#[serde(default = "default_schedule_page")]
pub(crate) schedule_page: usize,
}
impl Config {
pub(crate) fn dashboard_limit(&self) -> usize {
self.dashboard_limit.max(1) * self.dashboard_page.max(1)
}
pub(crate) fn dashboard_page(&self) -> usize {
self.dashboard_page.max(1)
}
pub(crate) fn schedule_page(&self) -> usize {
self.schedule_page.max(1)
}
}
impl Default for Config {
#[inline]
fn default() -> Self {
Self {
theme: Default::default(),
tvdb_legacy_apikey: Default::default(),
tmdb_api_key: Default::default(),
schedule_duration_days: default_days(),
dashboard_limit: default_dashboard_limit(),
dashboard_page: default_dashboard_page(),
schedule_limit: default_schedule_limit(),
schedule_page: default_schedule_page(),
}
}
}
impl Config {
#[inline]
pub(crate) fn theme(&self) -> iced::Theme {
match self.theme {
ThemeType::Light => iced::Theme::Light,
ThemeType::Dark => iced::Theme::Dark,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "type")]
pub(crate) enum RemoteId {
Series {
uuid: SeriesId,
remotes: Vec<RemoteSeriesId>,
},
Episode {
uuid: EpisodeId,
remotes: Vec<RemoteEpisodeId>,
},
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum RemoteSeasonId {
Tmdb { id: u32, season: SeasonNumber },
Imdb { id: Raw<16>, season: SeasonNumber },
}
impl RemoteSeasonId {
pub(crate) fn url(&self) -> String {
match self {
RemoteSeasonId::Tmdb { id, season } => {
let season = match season {
SeasonNumber::Specials => 0,
SeasonNumber::Number(n) => *n,
};
format!("https://www.themoviedb.org/tv/{id}/season/{season}")
}
RemoteSeasonId::Imdb { id, season } => {
let season = match season {
SeasonNumber::Specials => -1,
SeasonNumber::Number(n) => *n as i64,
};
format!("https://www.imdb.com/title/{id}/episodes?season={season}")
}
}
}
}
impl fmt::Display for RemoteSeasonId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RemoteSeasonId::Tmdb { id, season } => {
write!(f, "tmdb:{id} ({season})")
}
RemoteSeasonId::Imdb { id, season } => {
write!(f, "imdb:{id} ({season})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum RemoteSeriesId {
Tvdb { id: u32 },
Tmdb { id: u32 },
Imdb { id: Raw<16> },
}
impl RemoteSeriesId {
pub(crate) fn into_season(self, season: SeasonNumber) -> Option<RemoteSeasonId> {
match self {
RemoteSeriesId::Tmdb { id } => Some(RemoteSeasonId::Tmdb { id, season }),
RemoteSeriesId::Imdb { id } => Some(RemoteSeasonId::Imdb { id, season }),
_ => None,
}
}
pub(crate) fn url(&self) -> String {
match self {
RemoteSeriesId::Tvdb { id } => {
format!("https://thetvdb.com/search?query={id}")
}
RemoteSeriesId::Tmdb { id } => {
format!("https://www.themoviedb.org/tv/{id}")
}
RemoteSeriesId::Imdb { id } => {
format!("https://www.imdb.com/title/{id}/")
}
}
}
pub(crate) fn is_supported(&self) -> bool {
matches!(
self,
RemoteSeriesId::Tmdb { .. } | RemoteSeriesId::Tvdb { .. }
)
}
}
impl fmt::Display for RemoteSeriesId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RemoteSeriesId::Tvdb { id } => {
write!(f, "tvdb:{id}")
}
RemoteSeriesId::Tmdb { id } => {
write!(f, "tmdb:{id}")
}
RemoteSeriesId::Imdb { id } => {
write!(f, "imdb:{id}")
}
}
}
}
impl Serialize for RemoteSeriesId {
#[inline]
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: ser::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for RemoteSeriesId {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct RemoteSeriesIdVisitor;
impl<'de> de::Visitor<'de> for RemoteSeriesIdVisitor {
type Value = RemoteSeriesId;
#[inline]
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a remote series id")
}
#[inline]
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let (head, tail) = v
.split_once(':')
.ok_or_else(|| de::Error::custom("missing `:`"))?;
match head {
"tmdb" => Ok(RemoteSeriesId::Tmdb {
id: tail.parse().map_err(E::custom)?,
}),
"tvdb" => Ok(RemoteSeriesId::Tvdb {
id: tail.parse().map_err(E::custom)?,
}),
"imdb" => Ok(RemoteSeriesId::Imdb {
id: Raw::new(tail)
.ok_or_else(|| de::Error::custom("overflowing imdb identifier"))?,
}),
kind => Err(de::Error::invalid_value(de::Unexpected::Str(kind), &self)),
}
}
#[inline]
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: de::MapAccess<'de>,
{
let mut remote = None;
let mut id = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"remote" => {
remote = Some(map.next_value::<String>()?);
}
"id" => {
id = Some(map.next_value::<serde_json::Value>()?);
}
kind => {
return Err(de::Error::custom(format_args!("unsupported key: {kind}")));
}
}
}
let (Some(remote), Some(id)) = (remote, id) else {
return Err(de::Error::custom("missing remote or id"));
};
let id = id.into_deserializer();
match remote.as_str() {
"tmdb" => Ok(RemoteSeriesId::Tmdb {
id: u32::deserialize(id).map_err(de::Error::custom)?,
}),
"tvdb" => Ok(RemoteSeriesId::Tvdb {
id: u32::deserialize(id).map_err(de::Error::custom)?,
}),
"imdb" => Ok(RemoteSeriesId::Imdb {
id: Raw::deserialize(id).map_err(de::Error::custom)?,
}),
kind => Err(de::Error::invalid_value(de::Unexpected::Str(kind), &self)),
}
}
}
deserializer.deserialize_any(RemoteSeriesIdVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub(crate) enum RemoteEpisodeId {
Tvdb { id: u32 },
Tmdb { id: u32 },
Imdb { id: Raw<16> },
}
impl fmt::Display for RemoteEpisodeId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RemoteEpisodeId::Tvdb { id } => {
write!(f, "tvdb:{id}")
}
RemoteEpisodeId::Tmdb { id } => {
write!(f, "tmdb:{id}")
}
RemoteEpisodeId::Imdb { id } => {
write!(f, "imdb:{id}")
}
}
}
}
impl Serialize for RemoteEpisodeId {
#[inline]
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: ser::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for RemoteEpisodeId {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct RemoteEpisodeIdVisitor;
impl<'de> de::Visitor<'de> for RemoteEpisodeIdVisitor {
type Value = RemoteEpisodeId;
#[inline]
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a remote series id")
}
#[inline]
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let (head, tail) = v
.split_once(':')
.ok_or_else(|| de::Error::custom("missing `:`"))?;
match head {
"tmdb" => Ok(RemoteEpisodeId::Tmdb {
id: tail.parse().map_err(E::custom)?,
}),
"tvdb" => Ok(RemoteEpisodeId::Tvdb {
id: tail.parse().map_err(E::custom)?,
}),
"imdb" => Ok(RemoteEpisodeId::Imdb {
id: Raw::new(tail)
.ok_or_else(|| de::Error::custom("overflowing imdb identifier"))?,
}),
kind => Err(de::Error::invalid_value(de::Unexpected::Str(kind), &self)),
}
}
#[inline]
fn visit_map<A>(self, mut map: A) -> std::result::Result<Self::Value, A::Error>
where
A: de::MapAccess<'de>,
{
let mut remote = None;
let mut id = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"remote" => {
remote = Some(map.next_value::<String>()?);
}
"id" => {
id = Some(map.next_value::<serde_json::Value>()?);
}
kind => {
return Err(de::Error::custom(format_args!("unsupported key: {kind}")));
}
}
}
let (Some(remote), Some(id)) = (remote, id) else {
return Err(de::Error::custom("missing remote or id"));
};
let id = id.into_deserializer();
match remote.as_str() {
"tmdb" => Ok(RemoteEpisodeId::Tmdb {
id: u32::deserialize(id).map_err(de::Error::custom)?,
}),
"tvdb" => Ok(RemoteEpisodeId::Tvdb {
id: u32::deserialize(id).map_err(de::Error::custom)?,
}),
"imdb" => Ok(RemoteEpisodeId::Imdb {
id: Raw::deserialize(id).map_err(de::Error::custom)?,
}),
kind => Err(de::Error::invalid_value(de::Unexpected::Str(kind), &self)),
}
}
}
deserializer.deserialize_any(RemoteEpisodeIdVisitor)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "remote")]
pub(crate) enum RemoteMovieId {
Tmdb { id: u32 },
Imdb { id: Raw<16> },
}
impl RemoteMovieId {
pub(crate) fn url(&self) -> String {
match self {
RemoteMovieId::Tmdb { id } => {
format!("https://www.themoviedb.org/tv/{id}")
}
RemoteMovieId::Imdb { id } => {
format!("https://www.imdb.com/title/{id}/")
}
}
}
}
impl fmt::Display for RemoteMovieId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RemoteMovieId::Tmdb { id } => {
write!(f, "themoviedb.org ({id})")
}
RemoteMovieId::Imdb { id } => {
write!(f, "imdb.com ({id})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum CustomGraphic {
Poster,
Banner,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct SeriesGraphics {
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) custom: BTreeSet<CustomGraphic>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) poster: Option<ImageV2>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) posters: BTreeSet<ImageV2>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) banner: Option<ImageV2>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) banners: BTreeSet<ImageV2>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) fanart: Option<ImageV2>,
}
impl SeriesGraphics {
fn merge_from(&mut self, other: Self) {
if !self.custom.contains(&CustomGraphic::Poster) {
self.poster = other.poster;
}
if !self.custom.contains(&CustomGraphic::Banner) {
self.banner = other.banner;
}
self.posters = other.posters;
self.banners = other.banners;
self.fanart = other.fanart;
}
fn is_empty(&self) -> bool {
self.poster.is_none() && self.banner.is_none() && self.fanart.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct Series {
pub(crate) id: SeriesId,
pub(crate) title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) first_air_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) overview: String,
#[serde(default, skip_serializing_if = "SeriesGraphics::is_empty")]
pub(crate) graphics: SeriesGraphics,
#[serde(default)]
pub(crate) tracked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) remote_id: Option<RemoteSeriesId>,
#[serde(default, rename = "poster", skip_serializing)]
#[deprecated = "replaced by .graphics"]
pub(crate) compat_poster: Option<crate::compat::Image>,
#[serde(default, rename = "banner", skip_serializing)]
#[deprecated = "replaced by .graphics"]
pub(crate) compat_banner: Option<crate::compat::Image>,
#[serde(default, rename = "fanart", skip_serializing)]
#[deprecated = "replaced by .graphics"]
pub(crate) compat_fanart: Option<crate::compat::Image>,
#[serde(rename = "last_modified", default, skip_serializing)]
#[deprecated = "deprecated for storing separately in sync database"]
pub(crate) compat_last_modified: Option<DateTime<Utc>>,
#[serde(rename = "last_etag", default, skip_serializing)]
#[deprecated = "deprecated for storing separately in sync database"]
pub(crate) compat_last_etag: Option<Etag>,
#[serde(rename = "last_sync", default, skip_serializing, with = "btree_as_vec")]
#[deprecated = "deprecated for storing separately in sync database"]
pub(crate) compat_last_sync: BTreeMap<RemoteSeriesId, DateTime<Utc>>,
}
impl Series {
#[allow(deprecated)]
pub(crate) fn new_series(update: crate::service::UpdateSeries) -> Self {
Self {
id: update.id,
title: update.title,
first_air_date: update.first_air_date,
overview: update.overview,
graphics: update.graphics,
remote_id: Some(update.remote_id),
tracked: true,
compat_poster: None,
compat_banner: None,
compat_fanart: None,
compat_last_modified: None,
compat_last_etag: None,
compat_last_sync: BTreeMap::new(),
}
}
pub(crate) fn merge_from(&mut self, other: crate::service::UpdateSeries) {
self.title = other.title;
self.first_air_date = other.first_air_date;
self.overview = other.overview;
self.graphics.merge_from(other.graphics);
self.remote_id = Some(other.remote_id);
}
pub(crate) fn poster(&self) -> Option<&ImageV2> {
self.graphics.poster.as_ref()
}
pub(crate) fn banner(&self) -> Option<&ImageV2> {
self.graphics.banner.as_ref()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct Movie {
pub(crate) id: MovieId,
pub(crate) title: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) remote_id: Option<RemoteMovieId>,
}
pub(crate) mod btree_as_vec {
use std::collections::BTreeMap;
use std::fmt;
use serde::de;
use serde::ser;
use serde::ser::SerializeSeq;
#[allow(unused)]
pub(crate) fn serialize<S, K, V>(
value: &BTreeMap<K, V>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: ser::Serializer,
K: ser::Serialize,
V: ser::Serialize,
{
let mut serializer = serializer.serialize_seq(Some(value.len()))?;
for (key, value) in value {
serializer.serialize_element(&(key, value))?;
}
serializer.end()
}
pub(crate) fn deserialize<'de, S, K, V>(deserializer: S) -> Result<BTreeMap<K, V>, S::Error>
where
S: de::Deserializer<'de>,
K: Ord + de::Deserialize<'de>,
V: de::Deserialize<'de>,
{
return deserializer.deserialize_seq(Visitor(BTreeMap::new()));
}
impl<'de, K, V> de::Visitor<'de> for Visitor<K, V>
where
K: Ord + de::Deserialize<'de>,
V: de::Deserialize<'de>,
{
type Value = BTreeMap<K, V>;
#[inline]
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "a sequence")
}
#[inline]
fn visit_seq<A>(mut self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: de::SeqAccess<'de>,
{
while let Some(element) = seq.next_element::<(K, V)>()? {
self.0.insert(element.0, element.1);
}
Ok(self.0)
}
}
struct Visitor<K, V>(BTreeMap<K, V>);
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", untagged)]
pub(crate) enum WatchedKind {
Series {
series: SeriesId,
episode: EpisodeId,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct Watched {
pub(crate) id: WatchedId,
pub(crate) timestamp: DateTime<Utc>,
#[serde(flatten)]
pub(crate) kind: WatchedKind,
}
#[derive(
Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(untagged)]
pub(crate) enum SeasonNumber {
#[default]
Specials,
Number(u32),
}
impl SeasonNumber {
#[inline]
pub(crate) fn is_special(&self) -> bool {
matches!(self, SeasonNumber::Specials)
}
pub(crate) fn short(&self) -> SeasonShort<'_> {
SeasonShort { season: self }
}
}
pub(crate) struct SeasonShort<'a> {
season: &'a SeasonNumber,
}
impl fmt::Display for SeasonShort<'_> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self.season {
SeasonNumber::Specials => "S".fmt(f),
SeasonNumber::Number(n) => n.fmt(f),
}
}
}
impl fmt::Display for SeasonNumber {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SeasonNumber::Specials => write!(f, "Specials"),
SeasonNumber::Number(number) => write!(f, "Season {number}"),
}
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct SeasonGraphics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) poster: Option<ImageV2>,
}
impl SeasonGraphics {
fn is_empty(&self) -> bool {
self.poster.is_none()
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct Season {
#[serde(default, skip_serializing_if = "SeasonNumber::is_special")]
pub(crate) number: SeasonNumber,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) air_date: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) name: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) overview: String,
#[serde(default, rename = "poster", skip_serializing_if = "Option::is_none")]
pub(crate) compat_poster: Option<crate::compat::Image>,
#[serde(default, skip_serializing_if = "SeasonGraphics::is_empty")]
pub(crate) graphics: SeasonGraphics,
}
impl Season {
pub(crate) fn poster(&self) -> Option<&ImageV2> {
self.graphics.poster.as_ref()
}
}
#[derive(Default, Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct EpisodeGraphics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) filename: Option<ImageV2>,
}
impl EpisodeGraphics {
fn is_empty(&self) -> bool {
self.filename.is_none()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct Episode {
pub(crate) id: EpisodeId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) name: Option<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub(crate) overview: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) absolute_number: Option<u32>,
#[serde(default, skip_serializing_if = "SeasonNumber::is_special")]
pub(crate) season: SeasonNumber,
pub(crate) number: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) aired: Option<NaiveDate>,
#[serde(default, rename = "filename", skip_serializing_if = "Option::is_none")]
pub(crate) compat_filename: Option<crate::compat::Image>,
#[serde(default, skip_serializing_if = "EpisodeGraphics::is_empty")]
pub(crate) graphics: EpisodeGraphics,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) remote_id: Option<RemoteEpisodeId>,
}
impl Episode {
pub(crate) fn filename(&self) -> Option<&ImageV2> {
self.graphics.filename.as_ref()
}
pub(crate) fn will_air(&self, today: &NaiveDate) -> bool {
let Some(aired) = &self.aired else {
return false;
};
*aired > *today
}
pub(crate) fn has_aired(&self, today: &NaiveDate) -> bool {
let Some(aired) = &self.aired else {
return false;
};
*aired <= *today
}
pub(crate) fn aired_timestamp(&self) -> Option<DateTime<Utc>> {
self.aired
.as_ref()
.and_then(|&d| Some(DateTime::from_utc(d.and_hms_opt(12, 0, 0)?, Utc)))
}
}
impl fmt::Display for Episode {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} / {}", self.season, self.number)
}
}
#[derive(
Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize,
)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ImageExt {
Jpg,
#[default]
Unsupported,
}
impl fmt::Display for ImageExt {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImageExt::Jpg => write!(f, "jpg"),
ImageExt::Unsupported => write!(f, "unsupported"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(transparent)]
pub(crate) struct ImageHash(u128);
impl ImageHash {
pub(crate) fn as_u128(&self) -> u128 {
self.0
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub(crate) enum ImageV2 {
Tvdb { uri: Box<RelativePath> },
Tmdb { uri: Box<RelativePath> },
}
impl ImageV2 {
pub(crate) fn hash(&self) -> ImageHash {
ImageHash(match self {
ImageV2::Tvdb { uri } => crate::cache::hash128(&(0xd410b8f4u32, uri)),
ImageV2::Tmdb { uri } => crate::cache::hash128(&(0xc66bff3eu32, uri)),
})
}
pub(crate) fn tvdb<S>(string: &S) -> Option<Self>
where
S: ?Sized + AsRef<str>,
{
Some(string.as_ref().trim_start_matches('/'))
.filter(|s| !s.is_empty())
.map(|uri| Self::Tvdb { uri: uri.into() })
}
pub(crate) fn tmdb<S>(string: &S) -> Option<Self>
where
S: ?Sized + AsRef<str>,
{
Some(string.as_ref().trim_start_matches('/'))
.filter(|s| !s.is_empty())
.map(|uri| Self::Tmdb { uri: uri.into() })
}
}
impl fmt::Display for ImageV2 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImageV2::Tvdb { uri } => write!(f, "tvdb:{uri}"),
ImageV2::Tmdb { uri } => write!(f, "tmdb:{uri}"),
}
}
}
impl Serialize for ImageV2 {
#[inline]
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: ser::Serializer,
{
serializer.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ImageV2 {
#[inline]
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: de::Deserializer<'de>,
{
struct ImageV2Visitor;
impl<'de> de::Visitor<'de> for ImageV2Visitor {
type Value = ImageV2;
#[inline]
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "an image v2 uri")
}
#[inline]
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
let (head, uri) = v
.split_once(':')
.ok_or_else(|| de::Error::custom("missing `:`"))?;
match head {
"tmdb" => Ok(ImageV2::Tmdb { uri: uri.into() }),
"tvdb" => Ok(ImageV2::Tvdb { uri: uri.into() }),
kind => Err(de::Error::invalid_value(de::Unexpected::Str(kind), &self)),
}
}
}
deserializer.deserialize_str(ImageV2Visitor)
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub(crate) struct Pending {
pub(crate) series: SeriesId,
pub(crate) episode: EpisodeId,
pub(crate) timestamp: DateTime<Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct SearchSeries {
pub(crate) id: RemoteSeriesId,
pub(crate) name: String,
pub(crate) poster: Option<ImageV2>,
pub(crate) overview: String,
pub(crate) first_aired: Option<NaiveDate>,
}
impl SearchSeries {
pub(crate) fn poster(&self) -> Option<&ImageV2> {
self.poster.as_ref()
}
}
#[derive(Debug, Clone)]
pub(crate) struct SearchMovie {
pub(crate) id: RemoteMovieId,
pub(crate) title: String,
pub(crate) poster: Option<ImageV2>,
pub(crate) overview: String,
pub(crate) release_date: Option<NaiveDate>,
}
impl SearchMovie {
pub(crate) fn poster(&self) -> Option<&ImageV2> {
self.poster.as_ref()
}
}
pub(crate) struct ScheduledSeries {
pub(crate) series_id: SeriesId,
pub(crate) episodes: Vec<EpisodeId>,
}
pub(crate) struct ScheduledDay {
pub(crate) date: NaiveDate,
pub(crate) schedule: Vec<ScheduledSeries>,
}