Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

pub mod clock;
pub mod encoding;
pub mod list;
pub mod path;
pub mod policy;
pub mod recognise;
Expand Down
163 changes: 163 additions & 0 deletions crates/core/src/list.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
//! What a list holds.
//!
//! A list is the entries of one account on one service. An entry is two
//! things at once: a work as the service knows it, and where the account
//! stands on it.

use std::time::Duration;

use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize};

/// An entry, by the number the service gives the work: `Media.id` on
/// AniList.
///
/// The number of the work and not of the account's entry for it, so it is
/// the same on every account and outlives the entry leaving a list.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct EntryId(pub i64);

/// One entry of a list, as the service gives it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry {
/// The work.
pub id: EntryId,
/// The title a person is shown.
pub title: String,
/// Every other spelling the service gives of the title.
pub spellings: Vec<String>,
/// What kind of release the work is, and none where the service does
/// not say.
pub format: Option<Format>,
/// How many episodes the work has, and none where the service holds no
/// count.
pub episodes: Option<u32>,
/// How long one episode is, and none where the service does not say.
pub episode_length: Option<Duration>,
/// The year the work began, and none where the service does not say.
pub year: Option<u16>,
/// Whether any of the work has been released.
pub released: bool,
/// Where the account stands on the work.
pub status: Status,
/// How many episodes the account has seen.
pub progress: u32,
/// How many times the account has seen the work again.
pub rewatches: u32,
/// The account's score, and none where it gave none.
pub score: Option<Rating>,
}

/// What kind of release a work is.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Format {
/// `TV` on AniList.
Tv,
/// `TV_SHORT` on AniList.
TvShort,
/// `MOVIE` on AniList.
Film,
/// `SPECIAL` on AniList.
Special,
/// `OVA` on AniList.
Ova,
/// `ONA` on AniList.
Ona,
/// `MUSIC` on AniList.
Music,
}

/// Where a show stands on a list.
///
/// `Rewatching` is a status here because it is one on AniList and on
/// Shikimori. MyAnimeList and Kitsu keep it as a flag beside the status. The
/// other five are on all four lists, which name them differently.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
/// Meant to be watched and not begun.
Planned,
/// Being watched.
Watching,
/// Being watched again.
Rewatching,
/// Watched to the end.
Completed,
/// Set aside, to be taken up again.
OnHold,
/// Given up.
Dropped,
}

/// A score in whole points, from 1 to 100.
///
/// On AniList each user chooses the format a score is shown in, and the list
/// takes and answers a score in a hundred points whichever was chosen.
/// MyAnimeList and Shikimori score out of ten and Kitsu out of twenty, and
/// both multiply into a hundred exactly. Nought is not a rating: an entry
/// with no score carries none.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct Rating(u8);

impl Rating {
/// A rating, or nothing where the points are not from 1 to 100.
#[must_use]
pub fn new(points: u8) -> Option<Self> {
(1..=100).contains(&points).then_some(Self(points))
}

/// The points, from 1 to 100.
#[must_use]
pub const fn points(self) -> u8 {
self.0
}
}

/// Read through [`Rating::new`], so that a score out of range is refused
/// where a derived implementation would let it in.
impl<'de> Deserialize<'de> for Rating {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let points = u8::deserialize(deserializer)?;
Self::new(points).ok_or_else(|| {
D::Error::custom(format_args!(
"rating is from 1 to 100, and this is {points}"
))
})
}
}

#[cfg(test)]
mod tests {
use super::Rating;

#[test]
fn a_rating_is_from_one_to_a_hundred() {
// Nought is no rating, and no rating is `None`: two ways to say it
// would be one too many.
assert_eq!(Rating::new(0), None);
assert_eq!(Rating::new(1).map(Rating::points), Some(1));
assert_eq!(Rating::new(100).map(Rating::points), Some(100));
assert_eq!(Rating::new(101), None);
}

#[test]
fn a_rating_read_out_of_range_is_refused_with_its_points() {
for points in [0, 101] {
let refused = serde_json::from_str::<Rating>(&points.to_string())
.expect_err("no rating")
.to_string();

assert_eq!(
refused,
format!("rating is from 1 to 100, and this is {points}")
);
}
assert_eq!(
serde_json::from_str::<Rating>("80")
.map(Rating::points)
.ok(),
Some(80)
);
}
}
2 changes: 2 additions & 0 deletions crates/daemon/src/detection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ fn refused(error: benshi_store::Error) -> TaskError {
| Error::Write(_)
| Error::Operation { .. }
| Error::Orphan { .. }
| Error::Entry { .. }
| Error::Showless { .. }
| Error::Media { .. }
| Error::Instant { .. } => TaskError::Permanent(error.into()),
}
Expand Down
62 changes: 59 additions & 3 deletions crates/store/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! The canonical local store.
//!
//! One SQLite database holds what was watched, what is being watched, and the
//! operations still to be delivered to a list. Migrations are numbered and each
//! has a test.
//! One SQLite database holds what was watched, what is being watched, the
//! operations still to be delivered to a list, and what each list held when it
//! was last read. Migrations are numbered and each has a test.
//!
//! Recording an episode does not depend on a list being reachable. A network
//! problem, a dead API or an expired token is a delivery problem, so marking an
Expand All @@ -17,6 +17,7 @@
//! television, where the system may delete it between runs. Which directory
//! that is belongs to the layer that knows the platform.

mod list;
mod migrate;
pub mod queue;
mod record;
Expand All @@ -27,6 +28,7 @@ use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};

use benshi_core::ShowId;
use benshi_core::list::EntryId;
use rusqlite::Connection;

pub use migrate::SCHEMA_VERSION;
Expand Down Expand Up @@ -112,6 +114,31 @@ pub enum Error {
/// The row's `id` in `sync_queue`.
id: i64,
},
/// A row of `list_entries` that cannot be read as an entry: a status or
/// a format this build does not know, a score that is not a rating, or
/// spellings that are not a list of text.
#[error("entry {} of {backend} cannot be read: {cause}", .entry.0)]
Entry {
/// The backend whose list holds the row.
backend: String,
/// The entry the row is of.
entry: EntryId,
/// What the column failed on.
#[source]
cause: serde_json::Error,
},
/// A row of `list_entries` with no show: its link in `backend_ids` or the
/// show the link names is not there.
///
/// Foreign keys keep one from being written through this crate, as they
/// keep an [`Error::Orphan`].
#[error("entry {} of {backend} has no show", .entry.0)]
Showless {
/// The backend whose list holds the row.
backend: String,
/// The entry the row is of.
entry: EntryId,
},
/// A row of `watching` whose media is not a path as this crate writes
/// one: a per-cent sign with no two hexadecimal digits after it.
#[error("media of row {id} in `watching` is not an escaped path: {text:?}")]
Expand Down Expand Up @@ -297,6 +324,7 @@ mod tests {
use std::time::{Duration, SystemTime, UNIX_EPOCH};

use benshi_core::ShowId;
use benshi_core::list::EntryId;
use benshi_core::path::RawPath;
use tempfile::TempDir;

Expand Down Expand Up @@ -547,6 +575,17 @@ mod tests {
assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}");
}

#[test]
fn a_list_filed_while_another_connection_writes_is_refused_as_busy() {
let (_home, database) = a_home();
let mut store = a_store_in_a_hurry(&database);
let _writer = a_writer(&database);

let refused = store.file("anilist", &[]);

assert!(matches!(refused, Err(Error::Busy(_))), "got {refused:?}");
}

#[test]
fn an_episode_recorded_while_another_connection_writes_is_refused_as_busy() {
let (_home, database) = a_home();
Expand Down Expand Up @@ -687,6 +726,23 @@ mod tests {
Error::Orphan { id: 7 }.to_string(),
"queued operation 7 is for a show that is not there"
);
assert_eq!(
Error::Entry {
backend: "anilist".to_owned(),
entry: EntryId(21),
cause: unread()
}
.to_string(),
format!("entry 21 of anilist cannot be read: {}", unread())
);
assert_eq!(
Error::Showless {
backend: "anilist".to_owned(),
entry: EntryId(21)
}
.to_string(),
"entry 21 of anilist has no show"
);
assert_eq!(
Error::Media {
id: 7,
Expand Down
Loading
Loading