mod etag;
mod hex;
mod raw;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use anyhow::{anyhow, bail, ensure, Context, Result};
use chrono::{DateTime, NaiveDate, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub(crate) use self::etag::Etag;
pub(crate) use self::hex::Hex;
pub(crate) use self::raw::Raw;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[repr(transparent)]
#[serde(transparent)]
pub(crate) struct SeriesId(Uuid);
impl SeriesId {
#[inline]
pub(crate) fn random() -> Self {
Self(Uuid::new_v4())
}
}
impl fmt::Display for SeriesId {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for SeriesId {
type Err = uuid::Error;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[repr(transparent)]
#[serde(transparent)]
pub(crate) struct EpisodeId(Uuid);
impl EpisodeId {
#[inline]
pub(crate) fn random() -> Self {
Self(Uuid::new_v4())
}
}
impl fmt::Display for EpisodeId {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl FromStr for EpisodeId {
type Err = uuid::Error;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self(uuid::Uuid::from_str(s)?))
}
}
#[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
}
#[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,
}
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(),
}
}
}
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: BTreeSet<RemoteSeriesId>,
},
Episode {
uuid: EpisodeId,
remotes: BTreeSet<RemoteEpisodeId>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "remote")]
pub(crate) enum RemoteSeriesId {
Tvdb { id: u32 },
Tmdb { id: u32 },
Imdb { id: Raw<16> },
}
impl RemoteSeriesId {
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}/")
}
}
}
}
impl fmt::Display for RemoteSeriesId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RemoteSeriesId::Tvdb { id } => {
write!(f, "thetvdb.com ({id})")
}
RemoteSeriesId::Tmdb { id } => {
write!(f, "themoviedb.org ({id})")
}
RemoteSeriesId::Imdb { id } => {
write!(f, "imdb.com ({id})")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case", tag = "remote")]
pub(crate) enum RemoteEpisodeId {
Tvdb { id: u32 },
Tmdb { id: u32 },
Imdb { id: Raw<16> },
}
#[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 = "Option::is_none")]
pub(crate) overview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) poster: Option<Image>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) banner: Option<Image>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) fanart: Option<Image>,
#[serde(default, skip_serializing_if = "is_false")]
pub(crate) tracked: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) last_modified: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) last_etag: Option<Etag>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
with = "btree_as_vec"
)]
pub(crate) last_sync: BTreeMap<RemoteSeriesId, DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) remote_id: Option<RemoteSeriesId>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) remote_ids: Vec<RemoteSeriesId>,
}
#[inline]
fn is_false(b: &bool) -> bool {
!*b
}
mod btree_as_vec {
use std::collections::BTreeMap;
use std::fmt;
use serde::de;
use serde::ser;
use serde::ser::SerializeSeq;
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, "expected 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")]
pub(crate) struct Watched {
pub(crate) id: Uuid,
pub(crate) series: SeriesId,
pub(crate) episode: EpisodeId,
pub(crate) timestamp: DateTime<Utc>,
}
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(untagged)]
pub(crate) enum SeasonNumber {
#[default]
Specials,
Number(u32),
}
impl SeasonNumber {
#[inline]
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 Season {
#[serde(default, skip_serializing_if = "SeasonNumber::is_special")]
pub(crate) number: SeasonNumber,
#[serde(default)]
pub(crate) air_date: Option<NaiveDate>,
#[serde(default)]
pub(crate) name: Option<String>,
#[serde(default)]
pub(crate) overview: Option<String>,
#[serde(default)]
pub(crate) poster: Option<Image>,
}
#[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 = "Option::is_none")]
pub(crate) overview: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) absolute_number: Option<u32>,
#[serde(default)]
pub(crate) season: SeasonNumber,
pub(crate) number: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) aired: Option<NaiveDate>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) filename: Option<Image>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) remote_id: Option<RemoteEpisodeId>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub(crate) remote_ids: BTreeSet<RemoteEpisodeId>,
}
impl Episode {
pub(crate) fn has_aired(&self, now: &DateTime<Utc>) -> bool {
let Some(aired) = &self.aired else {
return false;
};
*aired <= now.date_naive()
}
}
impl fmt::Display for Episode {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} / {}", self.season, self.number)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ImageExt {
Jpg,
}
impl ImageExt {
fn parse(input: &str) -> Result<Self> {
match input {
"jpg" => Ok(ImageExt::Jpg),
_ => {
bail!("unsupported image format")
}
}
}
}
impl fmt::Display for ImageExt {
#[inline]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ImageExt::Jpg => write!(f, "jpg"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ArtKind {
Posters,
Banners,
Backgrounds,
Episodes,
}
impl ArtKind {
fn parse(input: &str) -> Result<Self> {
match input {
"posters" => Ok(ArtKind::Posters),
"banners" => Ok(ArtKind::Banners),
"backgrounds" => Ok(ArtKind::Backgrounds),
"episodes" => Ok(ArtKind::Episodes),
_ => {
bail!("unsupported art kind")
}
}
}
}
impl fmt::Display for ArtKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ArtKind::Posters => write!(f, "posters"),
ArtKind::Banners => write!(f, "banners"),
ArtKind::Backgrounds => write!(f, "backgrounds"),
ArtKind::Episodes => write!(f, "episodes"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", tag = "type", content = "data")]
pub(crate) enum TvdbImageKind {
Legacy(u64, ArtKind, Hex<16>),
V4(u64, ArtKind, Hex<16>),
Banner(Hex<16>),
BannerSuffixed(u64, Raw<16>),
Graphical(Hex<16>),
GraphicalSuffixed(u64, Raw<16>),
Fanart(Hex<16>),
FanartSuffixed(u64, Raw<16>),
ScreenCap(u64, Hex<16>),
Episodes(u32, u32),
Blank(u32),
Missing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct TvdbImage {
#[serde(flatten)]
pub(crate) kind: TvdbImageKind,
pub(crate) ext: ImageExt,
}
impl fmt::Display for TvdbImage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let ext = &self.ext;
match &self.kind {
TvdbImageKind::Legacy(series_id, kind, id) => {
write!(f, "/banners/series/{series_id}/{kind}/{id}.{ext}")
}
TvdbImageKind::V4(series_id, kind, id) => {
write!(f, "/banners/v4/series/{series_id}/{kind}/{id}.{ext}")
}
TvdbImageKind::Banner(id) => {
write!(f, "/banners/posters/{id}.{ext}")
}
TvdbImageKind::BannerSuffixed(series_id, suffix) => {
write!(f, "/banners/posters/{series_id}-{suffix}.{ext}")
}
TvdbImageKind::Graphical(id) => {
write!(f, "/banners/graphical/{id}.{ext}")
}
TvdbImageKind::GraphicalSuffixed(series_id, suffix) => {
write!(f, "/banners/graphical/{series_id}-{suffix}.{ext}")
}
TvdbImageKind::Fanart(id) => {
write!(f, "/banners/fanart/original/{id}.{ext}")
}
TvdbImageKind::FanartSuffixed(series_id, suffix) => {
write!(f, "/banners/fanart/original/{series_id}-{suffix}.{ext}")
}
TvdbImageKind::ScreenCap(episode_id, id) => {
write!(f, "/banners/v4/episode/{episode_id}/screencap/{id}.{ext}")
}
TvdbImageKind::Episodes(episode_id, image_id) => {
write!(f, "/banners/episodes/{episode_id}/{image_id}.{ext}")
}
TvdbImageKind::Blank(series_id) => {
write!(f, "/banners/blank/{series_id}.{ext}")
}
TvdbImageKind::Missing => {
write!(f, "/banners/images/missing/series.{ext}")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", tag = "type", content = "data")]
pub(crate) enum TmdbImageKind {
Base64(Raw<32>),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct TmdbImage {
#[serde(flatten)]
pub(crate) kind: TmdbImageKind,
pub(crate) ext: ImageExt,
}
impl fmt::Display for TmdbImage {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let ext = &self.ext;
match self.kind {
TmdbImageKind::Base64(id) => {
write!(f, "/t/p/original/{id}.{ext}")?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", tag = "from")]
pub(crate) enum Image {
Tvdb(TvdbImage),
Tmdb(TmdbImage),
}
impl Image {
pub(crate) fn parse_tvdb(mut input: &str) -> Result<Self> {
input = input.trim_start_matches('/');
let mut it = input.split('/');
ensure!(
matches!(it.next(), Some("banners")),
"{input}: missing `banners`"
);
Self::parse_banner_it(it).with_context(|| anyhow!("bad image: {input}"))
}
#[inline]
pub(crate) fn parse_tvdb_banner(input: &str) -> Result<Self> {
Self::parse_banner_it(input.split('/')).with_context(|| anyhow!("bad image: {input}"))
}
#[inline]
pub(crate) fn parse_tmdb(input: &str) -> Result<Self> {
let input = input.trim_start_matches('/');
let Some((id, ext)) = input.split_once('.') else {
bail!("missing extension");
};
let id = Raw::new(id).context("base identifier")?;
let kind = TmdbImageKind::Base64(id);
let ext = ImageExt::parse(ext)?;
Ok(Image::Tmdb(TmdbImage { kind, ext }))
}
fn parse_banner_it<'a, I>(mut it: I) -> Result<Self>
where
I: DoubleEndedIterator<Item = &'a str>,
{
use arrayvec::ArrayVec;
let rest = it.next_back().context("missing last component")?;
let Some((rest, ext)) = rest.split_once('.') else {
bail!("missing extension");
};
let ext = ImageExt::parse(ext)?;
let mut array = ArrayVec::<_, 6>::new();
for part in it {
array.try_push(part).map_err(|e| anyhow!("{e}"))?;
}
array.try_push(rest).map_err(|e| anyhow!("{e}"))?;
let kind = match &array[..] {
["blank", series_id] => TvdbImageKind::Blank(series_id.parse()?),
["images", "missing", "series"] => TvdbImageKind::Missing,
["v4", "series", series_id, kind, rest] => {
let kind = ArtKind::parse(kind)?;
let id = Hex::from_hex(rest).context("bad id")?;
TvdbImageKind::V4(series_id.parse()?, kind, id)
}
["series", series_id, kind, id] => {
let series_id = series_id.parse()?;
let kind = ArtKind::parse(kind)?;
let id = Hex::from_hex(id).context("bad id")?;
TvdbImageKind::Legacy(series_id, kind, id)
}
["posters", rest] => {
if let Some((series_id, suffix)) = rest.split_once('-') {
let series_id = series_id.parse()?;
let suffix = Raw::new(suffix).context("suffix overflow")?;
TvdbImageKind::BannerSuffixed(series_id, suffix)
} else {
let id = Hex::from_hex(rest).context("bad id")?;
TvdbImageKind::Banner(id)
}
}
["graphical", rest] => {
if let Some((series_id, suffix)) = rest.split_once('-') {
let series_id = series_id.parse()?;
let suffix = Raw::new(suffix).context("suffix overflow")?;
TvdbImageKind::GraphicalSuffixed(series_id, suffix)
} else {
let id = Hex::from_hex(rest).context("bad hex")?;
TvdbImageKind::Graphical(id)
}
}
["fanart", "original", rest] => {
if let Some((series_id, suffix)) = rest.split_once('-') {
let series_id = series_id.parse()?;
let suffix = Raw::new(suffix).context("suffix overflow")?;
TvdbImageKind::FanartSuffixed(series_id, suffix)
} else {
let id = Hex::from_hex(rest).context("bad hex")?;
TvdbImageKind::Fanart(id)
}
}
["v4", "episode", episode_id, "screencap", rest] => {
let id = Hex::from_hex(rest).context("bad id")?;
TvdbImageKind::ScreenCap(episode_id.parse()?, id)
}
["episodes", episode_id, rest] => {
TvdbImageKind::Episodes(episode_id.parse()?, rest.parse()?)
}
_ => {
bail!("unsupported image");
}
};
Ok(Image::Tvdb(TvdbImage { kind, ext }))
}
}
impl fmt::Display for Image {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Image::Tvdb(image) => write!(f, "tvdb:{image}"),
Image::Tmdb(image) => write!(f, "tmdb:{image}"),
}
}
}
impl From<TvdbImage> for Image {
#[inline]
fn from(image: TvdbImage) -> Self {
Image::Tvdb(image)
}
}
impl From<TmdbImage> for Image {
#[inline]
fn from(image: TmdbImage) -> Self {
Image::Tmdb(image)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub(crate) enum TaskKind {
FindUpdates,
DownloadSeriesById { series_id: SeriesId },
DownloadSeriesByRemoteId { remote_id: RemoteSeriesId },
}
#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub(crate) enum TaskFinished {
#[default]
None,
UpdateSeries {
series_id: SeriesId,
last_etag: Option<Etag>,
last_modifed: Option<DateTime<Utc>>,
},
}
impl TaskFinished {
fn is_none(&self) -> bool {
matches!(self, TaskFinished::None)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct Task {
pub(crate) id: Uuid,
#[serde(flatten)]
pub(crate) kind: TaskKind,
pub(crate) scheduled: DateTime<Utc>,
#[serde(default, skip_serializing_if = "TaskFinished::is_none")]
pub(crate) finished: TaskFinished,
}
impl Task {
pub(crate) fn is_series(&self, id: &SeriesId) -> bool {
match &self.kind {
TaskKind::DownloadSeriesById { series_id, .. } => *series_id == *id,
TaskKind::DownloadSeriesByRemoteId { .. } => false,
TaskKind::FindUpdates => false,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SearchSeries {
pub(crate) id: RemoteSeriesId,
pub(crate) name: String,
pub(crate) poster: Option<Image>,
pub(crate) overview: Option<String>,
pub(crate) first_aired: Option<NaiveDate>,
}
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>,
}