feat(core,app,android,ios): My List, synced, and New badges with new-episode rows

My List is a list of titles to watch later, kept in my-list.json by
title key (pstr-core::mylist) and synced as one list/<key> register per
title, so an add or a removal on one device reaches every other. A list
file that will not parse is never written over: changing the list is
refused instead. It is a filter, a row on the library, a button on the
banner and the title page, and an entry in each tile's menu.

pstr-core::news reads node_first_seen against each share's first crawl,
so a newly added share does not badge its whole library. A title that
arrived in the last two weeks and was never played is New; a series
being watched counts the unwatched episodes that arrived after it was
last played ("3 new"). They fill the tile badge and the "New episodes"
and "Recently added" rows. shelves::home puts every row in one order
for all three apps, and a shelf tile of an airing show shows a
countdown ("Ep 8 · in 2 d").

The bridge gains TitleRecord.on_list and .badge, set_on_list, the
MyList filter, airing_countdown and SyncRecord.list_changed.
This commit is contained in:
nvrl committed 2026-10-30 22:15:51 +01:00
1 parent cd9e8eea81
commit f66faf6b75
31 files changed
+1142 -70

No files matched your search

+37 -9
View File
@@ -10,6 +10,7 @@ use std::collections::{HashMap, HashSet};
use crate::catalog::CatalogNode;
use crate::library::{Library, Title, TitleKind, sort_name};
use crate::metadata::{MetadataRecord, TitleNames};
use crate::mylist::MyList;
/// What order a grid is in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
@@ -66,11 +67,14 @@ pub enum Filter {
Unwatched,
/// With at least one episode kept on this device.
Downloaded,
/// On My List.
MyList,
}
impl Filter {
pub const ALL: [Self; 6] = [
pub const ALL: [Self; 7] = [
Self::All,
Self::MyList,
Self::Series,
Self::Films,
Self::Watching,
@@ -86,12 +90,18 @@ impl Filter {
Self::Watching => "Watching",
Self::Unwatched => "Not started",
Self::Downloaded => "Downloaded",
Self::MyList => "My List",
}
}
/// Whether the grid shows `title`. `offline` says whether a file is kept
/// on this device, which only the front end knows.
pub fn admits(self, title: &Title, offline: impl Fn(&CatalogNode) -> bool) -> bool {
pub fn admits(
self,
title: &Title,
offline: impl Fn(&CatalogNode) -> bool,
list: &MyList,
) -> bool {
let watched = title.watched_count();
match self {
Self::All => true,
@@ -102,6 +112,7 @@ impl Filter {
}
Self::Unwatched => watched == 0 && title.resume().is_none(),
Self::Downloaded => title.episodes().any(|episode| offline(&episode.node)),
Self::MyList => list.contains(&title.key),
}
}
}
@@ -455,12 +466,18 @@ mod tests {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let none = |_: &CatalogNode| false;
assert!(Filter::All.admits(&film, none) && Filter::All.admits(&series, none));
assert!(Filter::Films.admits(&film, none) && !Filter::Films.admits(&series, none));
assert!(Filter::Series.admits(&series, none) && !Filter::Series.admits(&film, none));
let list = MyList::default();
assert!(Filter::All.admits(&film, none, &list) && Filter::All.admits(&series, none, &list));
assert!(
Filter::Films.admits(&film, none, &list) && !Filter::Films.admits(&series, none, &list)
);
assert!(
Filter::Series.admits(&series, none, &list)
&& !Filter::Series.admits(&film, none, &list)
);
// Nothing watched of either: not started, and not being watched.
assert!(Filter::Unwatched.admits(&film, none));
assert!(!Filter::Watching.admits(&film, none));
assert!(Filter::Unwatched.admits(&film, none, &list));
assert!(!Filter::Watching.admits(&film, none, &list));
}
#[test]
@@ -486,7 +503,18 @@ mod tests {
let library = Library::build(vec![node("a", 1), node("b", 2)], &HashMap::new());
let show = &library.titles[0];
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b"));
assert!(!Filter::Downloaded.admits(show, |_| false));
let list = MyList::default();
assert!(Filter::Downloaded.admits(show, |node| node.link_id == "b", &list));
assert!(!Filter::Downloaded.admits(show, |_| false, &list));
}
#[test]
fn the_my_list_filter_admits_only_what_is_on_the_list() {
let film = title("f", "Film", TitleKind::Film);
let series = title("s", "Series", TitleKind::Series);
let mut list = MyList::default();
list.add("s", 1);
assert!(Filter::MyList.admits(&series, |_| false, &list));
assert!(!Filter::MyList.admits(&film, |_| false, &list));
}
}
+2
View File
@@ -16,7 +16,9 @@ pub mod franchise;
pub mod language;
pub mod library;
pub mod metadata;
pub mod mylist;
pub mod naming;
pub mod news;
pub mod prefs;
pub mod profile;
pub mod shares;
+141
View File
@@ -0,0 +1,141 @@
//! My List: the titles the viewer put aside to watch, kept in
//! `my-list.json` beside the other settings.
//!
//! Keyed by title key, like a hand-picked match, so a title keeps its place on
//! the list across a recrawl and on every device that has the same share. A
//! title that leaves the library stays on the list, unseen, and is back on it
//! when the share brings it back.
//!
//! It travels with the profile, one `list/<title key>` register per title
//! ([`crate::profile`]): adding a title on the phone puts it on the laptop's
//! list, and taking it off on one takes it off on all of them.
use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::config::{AppDirs, read_json, write_json};
use crate::error::Result;
/// The titles on the list, by key, with when each was put there in Unix
/// seconds.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MyList {
#[serde(default)]
titles: BTreeMap<String, i64>,
}
impl MyList {
pub fn contains(&self, key: &str) -> bool {
self.titles.contains_key(key)
}
pub fn is_empty(&self) -> bool {
self.titles.is_empty()
}
/// When `key` was put on the list, if it is on it.
pub fn added_at(&self, key: &str) -> Option<i64> {
self.titles.get(key).copied()
}
/// Put `key` on the list at `at`. One already there keeps its place.
pub fn add(&mut self, key: &str, at: i64) {
self.titles.entry(key.to_owned()).or_insert(at);
}
pub fn remove(&mut self, key: &str) {
self.titles.remove(key);
}
/// Put `key` on the list or take it off, saying which.
pub fn toggle(&mut self, key: &str, at: i64) -> bool {
if self.contains(key) {
self.remove(key);
false
} else {
self.add(key, at);
true
}
}
/// Every key with when it was added, in key order.
pub fn entries(&self) -> impl Iterator<Item = (&str, i64)> {
self.titles.iter().map(|(key, at)| (key.as_str(), *at))
}
/// The keys, most recently added first: the order the row is in.
pub fn newest_first(&self) -> Vec<&str> {
let mut keys: Vec<(&str, i64)> = self.entries().collect();
keys.sort_by_key(|(_, at)| std::cmp::Reverse(*at));
keys.into_iter().map(|(key, _)| key).collect()
}
/// Set `key`'s entry as another device had it: on the list since `at`,
/// or, with `None`, off it.
pub(crate) fn set(&mut self, key: &str, at: Option<i64>) {
match at {
Some(at) => {
self.titles.insert(key.to_owned(), at);
}
None => self.remove(key),
}
}
}
pub(crate) fn file(dirs: &AppDirs) -> PathBuf {
dirs.config.join("my-list.json")
}
/// The list, empty when there is no file yet.
pub fn load(dirs: &AppDirs) -> Result<MyList> {
Ok(read_json::<MyList>(&file(dirs))?.unwrap_or_default())
}
pub fn save(dirs: &AppDirs, list: &MyList) -> Result<()> {
write_json(&file(dirs), list)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_title_added_twice_keeps_the_time_it_was_first_added() {
let mut list = MyList::default();
list.add("frieren", 10);
list.add("frieren", 20);
assert_eq!(list.added_at("frieren"), Some(10));
}
#[test]
fn toggling_puts_a_title_on_the_list_and_takes_it_off_again() {
let mut list = MyList::default();
assert!(list.toggle("frieren", 10));
assert!(list.contains("frieren"));
assert!(!list.toggle("frieren", 20));
assert!(list.is_empty());
}
#[test]
fn the_row_shows_the_most_recently_added_title_first() {
let mut list = MyList::default();
list.add("a", 30);
list.add("b", 10);
list.add("c", 20);
assert_eq!(list.newest_first(), ["a", "c", "b"]);
}
#[test]
fn the_list_survives_a_save_and_a_load() {
let root = std::env::temp_dir().join(format!("pstr-mylist-{}", std::process::id()));
let dirs = AppDirs::from_paths(root.join("c"), root.join("d"), root.join("k")).unwrap();
assert!(load(&dirs).unwrap().is_empty());
let mut list = MyList::default();
list.add("one piece", 5);
save(&dirs, &list).unwrap();
assert_eq!(load(&dirs).unwrap(), list);
let _ = std::fs::remove_dir_all(&root);
}
}
+304
View File
@@ -0,0 +1,304 @@
//! What arrived since the viewer last looked: the **New** and **3 new**
//! badges, the "New episodes" and "Recently added" rows, and the countdown on
//! an airing show's tile.
//!
//! All of it is read off [`crate::catalog::Catalog::first_seen`], the time a
//! crawl first found each file. A file the first crawl of its share found is
//! not new, however recent that crawl: it was there before this device looked,
//! and a share added today would otherwise badge its whole library.
use std::collections::HashMap;
use crate::library::{Episode, Title, TitleKind};
use crate::shelves::Shelf;
/// How long a title that arrived stays **New**, unless it is started first.
pub const NEW_FOR: i64 = 14 * 86_400;
/// What a tile says about what arrived.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Badge {
/// A title that arrived in the last [`NEW_FOR`] and was never played.
New,
/// A series being watched, with this many unwatched episodes that arrived
/// after it was last played.
Episodes(usize),
}
impl Badge {
pub fn label(self) -> String {
match self {
Self::New => "New".to_owned(),
Self::Episodes(count) => format!("{count} new"),
}
}
}
/// When each file arrived, read against when its share was first crawled.
pub struct Arrivals<'a> {
first_seen: &'a HashMap<(String, String), i64>,
/// The earliest sighting in each share: its first crawl.
first_crawl: HashMap<&'a str, i64>,
}
impl<'a> Arrivals<'a> {
pub fn new(first_seen: &'a HashMap<(String, String), i64>) -> Self {
let mut first_crawl: HashMap<&str, i64> = HashMap::new();
for ((share, _), seen) in first_seen {
first_crawl
.entry(share.as_str())
.and_modify(|first| *first = (*first).min(*seen))
.or_insert(*seen);
}
Self {
first_seen,
first_crawl,
}
}
/// When `episode`'s file arrived, if a crawl after its share's first one
/// found it.
pub fn arrived(&self, episode: &Episode) -> Option<i64> {
let node = &episode.node;
let seen = *self
.first_seen
.get(&(node.share_id.clone(), node.link_id.clone()))?;
let first = self.first_crawl.get(node.share_id.as_str())?;
(seen > *first).then_some(seen)
}
/// The badge for `title`, if it has one.
pub fn badge(&self, title: &Title, now: i64) -> Option<Badge> {
let played = title.last_played();
if played == 0 {
// New only if every file of it arrived: a show the first crawl
// found is not new because a crawl since added an episode.
let arrived: Option<Vec<i64>> = title
.episodes()
.map(|episode| self.arrived(episode))
.collect();
let first = arrived?.into_iter().min()?;
return (now - first < NEW_FOR).then_some(Badge::New);
}
if title.kind != TitleKind::Series {
return None;
}
let count = title
.episodes()
.filter(|episode| !episode.is_watched())
.filter(|episode| self.arrived(episode).is_some_and(|at| at > played))
.count();
(count > 0).then_some(Badge::Episodes(count))
}
/// When `title` last gained a file that counts as arrived.
fn newest(&self, title: &Title) -> Option<i64> {
title
.episodes()
.filter_map(|episode| self.arrived(episode))
.max()
}
}
/// Every badge in `titles`, by key.
pub fn badges(
titles: &[Title],
first_seen: &HashMap<(String, String), i64>,
now: i64,
) -> HashMap<String, Badge> {
let arrivals = Arrivals::new(first_seen);
titles
.iter()
.filter_map(|title| Some((title.key.clone(), arrivals.badge(title, now)?)))
.collect()
}
/// The two rows of what arrived, newest arrival first. `None` for a row with
/// nothing in it.
#[derive(Debug, Default)]
pub struct Rows {
/// "New episodes": the shows being watched that gained episodes.
pub episodes: Option<Shelf>,
/// "Recently added": the titles that are [`Badge::New`].
pub added: Option<Shelf>,
}
pub fn rows(titles: &[Title], first_seen: &HashMap<(String, String), i64>, now: i64) -> Rows {
let arrivals = Arrivals::new(first_seen);
let mut episodes: Vec<(&Title, i64)> = Vec::new();
let mut added: Vec<(&Title, i64)> = Vec::new();
for title in titles {
let (Some(badge), Some(newest)) = (arrivals.badge(title, now), arrivals.newest(title))
else {
continue;
};
match badge {
Badge::Episodes(_) => episodes.push((title, newest)),
Badge::New => added.push((title, newest)),
}
}
let shelf = |label: &str, mut row: Vec<(&Title, i64)>| {
row.sort_by_key(|(_, newest)| std::cmp::Reverse(*newest));
(!row.is_empty()).then(|| Shelf {
label: label.to_owned(),
keys: row
.into_iter()
.map(|(title, _)| title.key.clone())
.collect(),
})
};
Rows {
episodes: shelf("New episodes", episodes),
added: shelf("Recently added", added),
}
}
/// What an airing show's tile says about its next episode: `Ep 8 · in 2 d`,
/// `Ep 8 · in 5 h`, `Ep 8 · soon`.
pub fn countdown(episode: u32, at: i64, now: i64) -> String {
let left = (at - now).max(0);
let when = match left {
0..3_600 => "soon".to_owned(),
3_600..86_400 => format!("in {} h", left / 3_600),
_ => format!("in {} d", left / 86_400),
};
format!("Ep {episode} · {when}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::catalog::{CatalogNode, WatchState};
use crate::library::Library;
const DAY: i64 = 86_400;
fn file(share: &str, link: &str, title: &str, episode: u32) -> CatalogNode {
CatalogNode {
share_id: share.into(),
link_id: link.into(),
volume_id: "v".into(),
parent_link_id: None,
name: format!("{title} - {episode:02}.mkv"),
is_folder: false,
media_type: Some("video/x-matroska".into()),
size: Some(1),
active_revision_id: Some("r".into()),
parsed: crate::naming::parse(&format!("{title} - {episode:02}.mkv")),
}
}
fn watched(at: i64) -> WatchState {
WatchState {
position_secs: 1_400.0,
duration_secs: Some(1_440.0),
watched: true,
updated_at: at,
}
}
/// One share whose first crawl at day 1 found Frieren 1–2, and Dandadan 1
/// arriving at day 20 next to Frieren 3.
fn share(frieren_played: Option<i64>) -> (Library, HashMap<(String, String), i64>) {
let files = vec![
file("s", "f1", "Frieren", 1),
file("s", "f2", "Frieren", 2),
file("s", "f3", "Frieren", 3),
file("s", "d1", "Dandadan", 1),
];
let mut watch = HashMap::new();
if let Some(at) = frieren_played {
watch.insert(("s".to_owned(), "f1".to_owned()), watched(at));
watch.insert(("s".to_owned(), "f2".to_owned()), watched(at));
}
let seen = [("f1", 1), ("f2", 1), ("f3", 20), ("d1", 20)]
.into_iter()
.map(|(link, day)| (("s".to_owned(), link.to_owned()), day * DAY))
.collect();
(Library::build(files, &watch), seen)
}
fn named<'a>(library: &'a Library, name: &str) -> &'a Title {
library
.titles
.iter()
.find(|title| title.name == name)
.unwrap()
}
#[test]
fn what_the_first_crawl_of_a_share_found_is_never_new() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), None);
}
#[test]
fn a_title_that_arrived_is_new_for_two_weeks_unless_it_is_started() {
let (library, seen) = share(None);
let arrivals = Arrivals::new(&seen);
let dandadan = named(&library, "Dandadan");
assert_eq!(arrivals.badge(dandadan, 21 * DAY), Some(Badge::New));
assert_eq!(arrivals.badge(dandadan, 20 * DAY + NEW_FOR), None);
}
#[test]
fn a_show_being_watched_counts_the_episodes_that_arrived_after_the_last_watch() {
let (library, seen) = share(Some(10 * DAY));
let arrivals = Arrivals::new(&seen);
let frieren = named(&library, "Frieren");
assert_eq!(arrivals.badge(frieren, 21 * DAY), Some(Badge::Episodes(1)));
assert_eq!(Badge::Episodes(1).label(), "1 new");
// Watched after episode 3 arrived: nothing is new to the viewer.
let (library, seen) = share(Some(25 * DAY));
let arrivals = Arrivals::new(&seen);
assert_eq!(arrivals.badge(named(&library, "Frieren"), 26 * DAY), None);
}
#[test]
fn the_rows_hold_new_episodes_and_recent_arrivals_and_leave_out_an_empty_one() {
let (library, seen) = share(Some(10 * DAY));
let found = rows(&library.titles, &seen, 21 * DAY);
let labels: Vec<(&str, Vec<&str>)> = [&found.episodes, &found.added]
.into_iter()
.flatten()
.map(|row| {
let names = row
.keys
.iter()
.map(|key| {
library
.titles
.iter()
.find(|title| &title.key == key)
.unwrap()
.name
.as_str()
})
.collect();
(row.label.as_str(), names)
})
.collect();
assert_eq!(
labels,
[
("New episodes", vec!["Frieren"]),
("Recently added", vec!["Dandadan"])
]
);
let (library, seen) = share(None);
let found = rows(&library.titles, &seen, 21 * DAY);
assert!(found.episodes.is_none());
assert_eq!(found.added.unwrap().label, "Recently added");
}
#[test]
fn the_countdown_says_days_then_hours_then_soon() {
assert_eq!(countdown(8, 3 * DAY + 100, 0), "Ep 8 · in 3 d");
assert_eq!(countdown(8, 5 * 3_600 + 10, 0), "Ep 8 · in 5 h");
assert_eq!(countdown(8, 600, 0), "Ep 8 · soon");
}
}
+95 -6
View File
@@ -1,6 +1,6 @@
//! The rest of what makes the app look the same on every device: the shares,
//! the settings, the hand-picked metadata matches and the per-show track
//! choices. Synced next to watch history by [`crate::sync`], in the same file.
//! the settings, the hand-picked metadata matches, the per-show track choices
//! and My List. Synced next to watch history by [`crate::sync`], in the same file.
//!
//! ## Registers
//!
@@ -8,8 +8,8 @@
//! `settings/playback`, `tracks/<title>`, …), its latest value, and when it
//! last changed. Merging is the same rule as watch history — the newer
//! `updated_at` wins, per key — so it is idempotent and order-free across any
//! number of devices. A removed share is a register with no value, so a
//! removal travels like any other change.
//! number of devices. A removed share, or a title taken off My List, is a
//! register with no value, so a removal travels like any other change.
//!
//! ## Noticing changes
//!
@@ -56,6 +56,7 @@ use crate::shares::{AccountFolder, Share, ShareSecrets, ShareStore};
const SHARE: &str = "share/";
const TRACKS: &str = "tracks/";
const MATCH: &str = "match/";
const LIST: &str = "list/";
/// The settings files that travel, by register, and the fields in each that
/// stay on the device that wrote them.
@@ -98,6 +99,12 @@ struct SyncedShare {
folder: Option<AccountFolder>,
}
/// A title on My List.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
struct SyncedListEntry {
added_at: i64,
}
/// A match the viewer picked by hand. Automatic ones are not synced: every
/// device with enrichment on finds them again, and they expire anyway.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -123,6 +130,8 @@ pub struct ProfileChanges {
pub settings: bool,
/// Hand-picked matches and per-show track choices taken.
pub titles: usize,
/// Whether My List changed.
pub list: bool,
}
impl ProfileChanges {
@@ -154,6 +163,8 @@ struct Observed {
/// Whether the share list itself could be read. Without it, a share
/// missing from `values` says nothing about whether it was removed.
shares_listed: bool,
/// The same for My List.
list_read: bool,
}
fn state_file(dirs: &AppDirs) -> PathBuf {
@@ -248,6 +259,18 @@ impl Profile<'_> {
}
}
match crate::mylist::load(self.dirs()) {
Ok(list) => {
observed.list_read = true;
for (title, added_at) in list.entries() {
if let Ok(value) = serde_json::to_value(SyncedListEntry { added_at }) {
observed.values.insert(format!("{LIST}{title}"), value);
}
}
}
Err(error) => tracing::warn!(%error, "My List was not synced"),
}
let catalog = self.catalog.lock();
if let Ok(tracks) = catalog.all_title_track_prefs() {
for (title, prefs) in tracks {
@@ -320,6 +343,19 @@ impl Profile<'_> {
}
return Ok(());
}
if let Some(title) = key.strip_prefix(LIST) {
let added_at = value
.map(parse::<SyncedListEntry>)
.transpose()?
.map(|entry| entry.added_at);
let mut list = crate::mylist::load(self.dirs())?;
if list.added_at(title) != added_at {
list.set(title, added_at);
crate::mylist::save(self.dirs(), &list)?;
changes.list = true;
}
return Ok(());
}
if key == TMDB_KEY {
match value.and_then(Value::as_str) {
Some(secret) => self.store.secrets.set(&tmdb_secret(), secret)?,
@@ -428,9 +464,10 @@ impl Profile<'_> {
/// Record what changed on this device since the last sync, stamped `now`.
fn stamp(registers: &mut Registers, observed: Observed, now: i64) {
for (key, register) in registers.iter_mut() {
let removable = (key.starts_with(SHARE) && observed.shares_listed)
|| (key.starts_with(LIST) && observed.list_read);
if register.value.is_some()
&& key.starts_with(SHARE)
&& observed.shares_listed
&& removable
&& !observed.values.contains_key(key)
&& !observed.unknown.contains(key)
{
@@ -789,6 +826,58 @@ pub(crate) mod tests {
);
}
#[test]
fn a_title_put_on_my_list_on_one_device_is_on_the_list_of_another() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
laptop.sync(&[], 1);
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (_, changes) = phone.sync(&[&sent], 20);
assert!(changes.list);
let taken = crate::mylist::load(&phone.store.dirs).unwrap();
assert_eq!(taken.added_at("frieren"), Some(5));
}
#[test]
fn a_title_taken_off_my_list_on_one_device_leaves_every_list() {
let laptop = Device::new("laptop");
let phone = Device::new("phone");
let mut list = crate::mylist::MyList::default();
list.add("frieren", 5);
list.add("one piece", 6);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[], 10);
let (phone_sent, _) = phone.sync(&[&sent], 20);
let mut list = crate::mylist::load(&phone.store.dirs).unwrap();
list.remove("frieren");
crate::mylist::save(&phone.store.dirs, &list).unwrap();
let (phone_sent, _) = phone.sync(&[&sent, &phone_sent], 30);
let (sent, changes) = laptop.sync(&[&phone_sent], 40);
assert!(changes.list);
let left = crate::mylist::load(&laptop.store.dirs).unwrap();
assert!(!left.contains("frieren"));
assert!(left.contains("one piece"));
// Put back later, it is back everywhere: the removal is older.
let mut list = left;
list.add("frieren", 50);
crate::mylist::save(&laptop.store.dirs, &list).unwrap();
let (sent, _) = laptop.sync(&[&sent], 50);
phone.sync(&[&sent], 60);
assert!(
crate::mylist::load(&phone.store.dirs)
.unwrap()
.contains("frieren")
);
}
#[test]
fn a_synced_share_that_names_another_id_is_refused() {
let phone = Device::new("phone");
+45
View File
@@ -135,6 +135,38 @@ pub fn pick(
picked
}
/// Every row above the library grid, in the order all three apps show them:
/// new episodes of what is being watched, My List, what was recently added,
/// then up to `limit` [`pick`]ed shelves.
pub fn home(
titles: &[Title],
metadata: &HashMap<String, MetadataRecord>,
list: &crate::mylist::MyList,
first_seen: &HashMap<(String, String), i64>,
now: i64,
limit: usize,
) -> Vec<Shelf> {
let news = crate::news::rows(titles, first_seen, now);
let present: std::collections::HashSet<&str> =
titles.iter().map(|title| title.key.as_str()).collect();
let keys: Vec<String> = list
.newest_first()
.into_iter()
.filter(|key| present.contains(key))
.map(str::to_owned)
.collect();
let mine = (!keys.is_empty()).then(|| Shelf {
label: "My List".into(),
keys,
});
news.episodes
.into_iter()
.chain(mine)
.chain(news.added)
.chain(pick(titles, metadata, limit))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -259,4 +291,17 @@ mod tests {
assert_eq!(shelves[0].label, "Airing now");
assert_eq!(shelves[0].keys, vec!["a"]);
}
#[test]
fn my_list_is_a_row_of_what_the_library_holds_newest_first() {
let titles = vec![title("a"), title("b")];
let mut list = crate::mylist::MyList::default();
list.add("a", 1);
list.add("b", 3);
list.add("gone", 5);
let rows = home(&titles, &HashMap::new(), &list, &HashMap::new(), 10, 4);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].label, "My List");
assert_eq!(rows[0].keys, ["b", "a"]);
}
}