Files
proton-stream/crates/pstr-core/src/franchise.rs
T
nvrl bb5a6638c4 feat(core,meta,app,android,ios): title logos and runtimes
ani.zip sent each series' TVDB clear logo and every episode's running
time from the start, and neither was kept. Enrichment now stores the logo
in title_metadata.logo_url (kept across a refresh of the same entry, like
the fanart) and episode runtimes in episode_metadata.runtime, TVDB's
first and AniDB's length after it. AniList's duration lands in
TitleDetails::runtime; TMDB episode runtimes are kept too.

Schema v13 adds the two columns and marks AniList titles un-enriched
without deleting anything. TitleDetails::revision lets details written
before runtime existed refetch by id while the old ones stay on screen,
instead of blanking them in the migration as v10 did.

Clear logos are padded to a square, so each client crops them to their
visible pixels before drawing. The logo replaces the name on the
library's banner and the title page (not on a light theme's title page,
where lettering made for dark art can vanish). Episode rows show how long
they run, in place of the file size on Android and iOS; a film's runtime
joins its line of facts.
2026-10-29 16:11:24 +01:00

292 lines
9.7 KiB
Rust

//! Which titles in a library are one story.
//!
//! A share files a franchise however its uploader did: Made in Abyss as a
//! series folder and three film folders, Evangelion as a series, two films and
//! a folder of four more. The provider knows they belong together — AniList
//! records prequels, sequels, side stories, recaps and retellings as relations
//! — so the library can show one tile for the lot and the parts on its page.
//!
//! Grouping is by shared provider id, not by name. Two titles are one
//! franchise when one names the other as related, or when both name the same
//! entry: Mushishi's *Suzu no Shizuku* film relates to *Zoku Shou 2*, which the
//! library does not hold as a title of its own but which the series' episode
//! walk passed through (see [`crate::metadata::TitleDetails::chain`]).
use std::collections::HashMap;
use crate::library::{Title, TitleKind};
use crate::metadata::MetadataRecord;
/// One franchise: indices into the titles it was built from, in release order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Franchise {
pub members: Vec<usize>,
}
impl Franchise {
/// The member that stands for the rest: the first series to air, or the
/// first film when there is no series. What a viewer means by "Made in
/// Abyss" is the show, not the recap film that came out after it.
pub fn lead(&self, titles: &[&Title]) -> usize {
self.members
.iter()
.copied()
.find(|&index| titles[index].kind == TitleKind::Series)
.unwrap_or(self.members[0])
}
}
/// Group `titles` into franchises, every title in exactly one.
///
/// Titles with no match are franchises of one. The groups come back in the
/// order of their first member in `titles`, so a sorted list stays sorted.
pub fn group(titles: &[&Title], metadata: &HashMap<String, MetadataRecord>) -> Vec<Franchise> {
let mut sets = DisjointSets::new(titles.len());
// The first title to name each provider id, for later titles naming it too.
let mut named: HashMap<(crate::metadata::ProviderId, &str), usize> = HashMap::new();
for (index, title) in titles.iter().enumerate() {
let Some(found) = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref())
else {
continue;
};
let details = found.details.as_ref();
let ids = std::iter::once(found.remote_id.as_str())
.chain(details.into_iter().flat_map(|details| {
details
.related
.iter()
.chain(&details.chain)
.map(String::as_str)
}))
.filter(|id| !id.is_empty());
for id in ids {
match named.get(&(found.provider, id)) {
Some(&other) => sets.union(index, other),
None => {
named.insert((found.provider, id), index);
}
}
}
}
let mut groups: Vec<Franchise> = Vec::new();
let mut slot: HashMap<usize, usize> = HashMap::new();
for index in 0..titles.len() {
let root = sets.find(index);
match slot.get(&root) {
Some(&at) => groups[at].members.push(index),
None => {
slot.insert(root, groups.len());
groups.push(Franchise {
members: vec![index],
});
}
}
}
for franchise in &mut groups {
franchise
.members
.sort_by_cached_key(|&index| release_key(titles[index], metadata));
}
groups
}
/// What release order sorts on: the provider's start date, else the year the
/// files state, else last.
pub fn release_key(title: &Title, metadata: &HashMap<String, MetadataRecord>) -> String {
let found = metadata
.get(&title.key)
.and_then(|record| record.metadata.as_ref());
found
.and_then(|found| found.details.as_ref())
.and_then(|details| details.start_date.clone())
.or_else(|| {
found
.and_then(|found| found.year)
.map(|year| year.to_string())
})
.or_else(|| title.year.map(|year| year.to_string()))
// `~` sorts after every digit: undated goes last.
.unwrap_or_else(|| "~".to_string())
}
struct DisjointSets {
parent: Vec<usize>,
}
impl DisjointSets {
fn new(size: usize) -> Self {
Self {
parent: (0..size).collect(),
}
}
fn find(&mut self, mut index: usize) -> usize {
while self.parent[index] != index {
self.parent[index] = self.parent[self.parent[index]];
index = self.parent[index];
}
index
}
fn union(&mut self, left: usize, right: usize) {
let (left, right) = (self.find(left), self.find(right));
if left != right {
// The earlier title stays the root, which keeps `group` stable.
let (root, child) = (left.min(right), left.max(right));
self.parent[child] = root;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::{ProviderId, TitleDetails, TitleMetadata};
fn title(key: &str, kind: TitleKind) -> Title {
Title {
key: key.into(),
name: key.into(),
year: None,
kind,
seasons: Vec::new(),
share_ids: Vec::new(),
}
}
fn matched(
key: &str,
id: &str,
start: &str,
related: &[&str],
chain: &[&str],
) -> MetadataRecord {
MetadataRecord {
title_key: key.into(),
provider: ProviderId::AniList,
metadata: Some(TitleMetadata {
provider: ProviderId::AniList,
remote_id: id.into(),
name: key.into(),
original_name: None,
overview: None,
year: None,
kind: TitleKind::Series,
poster_url: None,
backdrop_url: None,
logo_url: None,
rating: None,
genres: Vec::new(),
episodes: None,
url: None,
details: Some(TitleDetails {
start_date: Some(start.into()),
related: related.iter().map(|id| id.to_string()).collect(),
chain: chain.iter().map(|id| id.to_string()).collect(),
..TitleDetails::default()
}),
}),
fetched_at: 0,
manual: false,
}
}
fn keys(groups: &[Franchise], titles: &[&Title]) -> Vec<Vec<String>> {
groups
.iter()
.map(|group| {
group
.members
.iter()
.map(|&index| titles[index].key.clone())
.collect()
})
.collect()
}
/// Made in Abyss: the films name the series, the series names the films.
/// Paprika names nothing in the library and stays alone.
#[test]
fn titles_that_name_each_other_are_one_franchise_in_release_order() {
let owned = [
title("dawn of the deep soul", TitleKind::Film),
title("made in abyss", TitleKind::Series),
title("paprika", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"dawn of the deep soul".to_string(),
matched(
"dawn of the deep soul",
"100643",
"2020-01-17",
&["97986"],
&[],
),
),
(
"made in abyss".to_string(),
matched("made in abyss", "97986", "2017-07-07", &["100643"], &[]),
),
(
"paprika".to_string(),
matched("paprika", "1943", "2006-11-25", &[], &[]),
),
]);
let groups = group(&titles, &metadata);
assert_eq!(
keys(&groups, &titles),
vec![
vec!["made in abyss", "dawn of the deep soul"],
vec!["paprika"]
]
);
assert_eq!(titles[groups[0].lead(&titles)].key, "made in abyss");
}
/// Mushishi: the special names an entry the series only reached by
/// walking its sequels for episodes. That is still the same story.
#[test]
fn an_entry_the_series_walked_through_joins_what_names_it() {
let owned = [
title("mushishi", TitleKind::Series),
title("suzu no shizuku", TitleKind::Film),
];
let titles: Vec<&Title> = owned.iter().collect();
let metadata = HashMap::from([
(
"mushishi".to_string(),
matched(
"mushishi",
"457",
"2005-10-23",
&["20526"],
&["457", "20595", "20751"],
),
),
(
"suzu no shizuku".to_string(),
matched("suzu no shizuku", "21008", "2015-05-16", &["20751"], &[]),
),
]);
assert_eq!(group(&titles, &metadata).len(), 1);
}
/// Nothing matched, nothing to group by — and two unmatched titles are
/// not one franchise because they share an empty id.
#[test]
fn unmatched_titles_stand_alone() {
let owned = [
title("game of thrones", TitleKind::Series),
title("something else", TitleKind::Series),
];
let titles: Vec<&Title> = owned.iter().collect();
assert_eq!(group(&titles, &HashMap::new()).len(), 2);
}
}