346 lines
11 KiB
Rust
346 lines
11 KiB
Rust
use crate::youtube::Video;
|
||
use serde_json::Value;
|
||
use std::{
|
||
io,
|
||
sync::mpsc::{self, Receiver, SyncSender},
|
||
thread,
|
||
};
|
||
use tubular::miruro::{Client, Detail, Identity, SourceDocument};
|
||
use unicode_normalization::UnicodeNormalization;
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Media {
|
||
pub id: String,
|
||
pub title: String,
|
||
// Retain optional/null metadata exactly as returned; it is not a Video.
|
||
pub metadata: Value,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Episode {
|
||
pub identity: Identity,
|
||
pub title: Option<String>,
|
||
pub synopsis: Option<String>,
|
||
pub aired_on: Option<String>,
|
||
pub duration_seconds: Option<u64>,
|
||
pub metadata: Value,
|
||
}
|
||
|
||
impl Episode {
|
||
pub fn snapshot(&self, media: &Media) -> Video {
|
||
video(
|
||
self.identity.key(),
|
||
format!(
|
||
"{} · Episode {}{}",
|
||
media.title,
|
||
self.identity.episode,
|
||
self.title
|
||
.as_ref()
|
||
.map(|title| format!(" · {title}"))
|
||
.unwrap_or_default()
|
||
),
|
||
self.aired_on.clone(),
|
||
)
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct Series {
|
||
pub media: Media,
|
||
pub episodes: Vec<Episode>,
|
||
}
|
||
|
||
pub enum Request {
|
||
Search(String),
|
||
Episodes { media_id: String, slug: String },
|
||
Source(Video),
|
||
}
|
||
pub enum Response {
|
||
Catalog(Vec<Media>),
|
||
Series(Series),
|
||
Source(Video, Box<SourceDocument>),
|
||
}
|
||
|
||
// One running request, no queued network requests. The UI retains only its newest intent.
|
||
pub struct Worker {
|
||
sender: SyncSender<(u64, Request)>,
|
||
receiver: Receiver<(u64, io::Result<Response>)>,
|
||
pub busy: bool,
|
||
generation: u64,
|
||
}
|
||
impl Worker {
|
||
pub fn new() -> Self {
|
||
let (sender, requests) = mpsc::sync_channel::<(u64, Request)>(1);
|
||
let (results, receiver) = mpsc::sync_channel(1);
|
||
thread::spawn(move || {
|
||
let client = Client::default();
|
||
while let Ok((generation, request)) = requests.recv() {
|
||
let result = match request {
|
||
Request::Search(query) => client
|
||
.search(&query)
|
||
.and_then(catalog)
|
||
.map(Response::Catalog),
|
||
Request::Episodes { media_id, slug } => client
|
||
.detail(&media_id, &slug)
|
||
.and_then(|detail| series(&media_id, detail))
|
||
.map(Response::Series),
|
||
Request::Source(video) => Identity::from_key(&video.id)
|
||
.and_then(|id| {
|
||
client.source_document(&id.media_id, id.episode, &video.title)
|
||
})
|
||
.map(|source| Response::Source(video, Box::new(source))),
|
||
};
|
||
if results.send((generation, result)).is_err() {
|
||
break;
|
||
}
|
||
}
|
||
});
|
||
Self {
|
||
sender,
|
||
receiver,
|
||
busy: false,
|
||
generation: 0,
|
||
}
|
||
}
|
||
pub fn submit(&mut self, generation: u64, request: Request) -> io::Result<()> {
|
||
if self.busy {
|
||
return Err(io::Error::other("Miruro worker is busy"));
|
||
}
|
||
self.sender
|
||
.try_send((generation, request))
|
||
.map_err(|_| io::Error::other("Miruro worker unavailable"))?;
|
||
self.busy = true;
|
||
self.generation = generation;
|
||
Ok(())
|
||
}
|
||
pub fn poll(&mut self) -> Option<(u64, io::Result<Response>)> {
|
||
match self.receiver.try_recv() {
|
||
Ok(result) => {
|
||
self.busy = false;
|
||
Some(result)
|
||
}
|
||
Err(mpsc::TryRecvError::Empty) => None,
|
||
Err(mpsc::TryRecvError::Disconnected) if self.busy => {
|
||
self.busy = false;
|
||
Some((
|
||
self.generation,
|
||
Err(io::Error::other("Miruro worker stopped")),
|
||
))
|
||
}
|
||
Err(_) => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
fn invalid(message: &str) -> io::Error {
|
||
io::Error::new(io::ErrorKind::InvalidData, message)
|
||
}
|
||
fn title(media: &Value) -> io::Result<String> {
|
||
let get = |language| {
|
||
media
|
||
.get("title")
|
||
.and_then(|v| v.get(language))
|
||
.and_then(Value::as_str)
|
||
.map(|text| text.trim_matches(js_whitespace))
|
||
.filter(|s| !s.is_empty())
|
||
};
|
||
let selected = match (get("english"), get("romaji")) {
|
||
(Some(a), Some(b)) => {
|
||
if a.encode_utf16().count() <= b.encode_utf16().count() {
|
||
a
|
||
} else {
|
||
b
|
||
}
|
||
}
|
||
(Some(a), None) | (None, Some(a)) => a,
|
||
_ => media
|
||
.get("title")
|
||
.and_then(|value| value.get("native"))
|
||
.and_then(Value::as_str)
|
||
.or_else(|| media.get("id").and_then(Value::as_str))
|
||
.ok_or_else(|| invalid("Miruro title is missing"))?,
|
||
};
|
||
Ok(selected.to_owned())
|
||
}
|
||
// ECMAScript \s/trim, not Rust's Unicode whitespace (which includes U+0085).
|
||
fn js_whitespace(ch: char) -> bool {
|
||
matches!(ch, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}'
|
||
| '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}'
|
||
| '\u{205f}' | '\u{3000}' | '\u{feff}')
|
||
}
|
||
pub fn slug(title: &str) -> io::Result<String> {
|
||
let cleaned: String = title
|
||
.nfd()
|
||
.filter(|ch| !('\u{0300}'..='\u{036f}').contains(ch))
|
||
.flat_map(char::to_lowercase)
|
||
.filter(|ch| {
|
||
ch.is_ascii_lowercase() || ch.is_ascii_digit() || js_whitespace(*ch) || *ch == '-'
|
||
})
|
||
.collect();
|
||
let mut slug = String::new();
|
||
for ch in cleaned.trim_matches(js_whitespace).chars() {
|
||
if ch.is_ascii_alphanumeric() {
|
||
slug.push(ch);
|
||
} else if (js_whitespace(ch) || ch == '-') && !slug.ends_with('-') {
|
||
slug.push('-');
|
||
}
|
||
}
|
||
if slug.is_empty() {
|
||
Err(invalid("Miruro title has no representable route slug"))
|
||
} else {
|
||
Ok(slug)
|
||
}
|
||
}
|
||
fn video(id: String, title: String, date: Option<String>) -> Video {
|
||
Video {
|
||
id,
|
||
title,
|
||
channel: Some("Miruro".into()),
|
||
channel_id: None,
|
||
uploader_id: None,
|
||
timestamp: None,
|
||
publication_date: date,
|
||
}
|
||
}
|
||
fn media(metadata: Value) -> io::Result<Media> {
|
||
let id = metadata
|
||
.get("id")
|
||
.and_then(Value::as_str)
|
||
.ok_or_else(|| invalid("Miruro catalog ID is missing"))?
|
||
.to_owned();
|
||
Identity::new(id.clone(), 1)?;
|
||
Ok(Media {
|
||
id,
|
||
title: title(&metadata)?,
|
||
metadata,
|
||
})
|
||
}
|
||
|
||
pub fn catalog(items: Vec<Value>) -> io::Result<Vec<Media>> {
|
||
if items.len() > 8 {
|
||
return Err(invalid("Miruro autocomplete exceeded eight results"));
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
items
|
||
.into_iter()
|
||
.map(|item| {
|
||
let media = media(item)?;
|
||
if !seen.insert(media.id.clone()) {
|
||
return Err(invalid("Miruro returned duplicate catalog IDs"));
|
||
}
|
||
Ok(media)
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub fn series(media_id: &str, detail: Detail) -> io::Result<Series> {
|
||
let media = media(detail.media)?;
|
||
if media.id != media_id {
|
||
return Err(invalid("Miruro detail ID did not match the requested ID"));
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
let episodes = detail
|
||
.episodes
|
||
.into_iter()
|
||
.map(|metadata| {
|
||
let number = metadata
|
||
.get("episode_number")
|
||
.and_then(Value::as_u64)
|
||
.and_then(|n| u32::try_from(n).ok())
|
||
.ok_or_else(|| invalid("Miruro episode number is invalid"))?;
|
||
let identity = Identity::new(media_id.to_owned(), number)?;
|
||
if !seen.insert(number) {
|
||
return Err(invalid("Miruro returned duplicate episode numbers"));
|
||
}
|
||
let text = |field| {
|
||
metadata
|
||
.get(field)
|
||
.and_then(Value::as_str)
|
||
.map(str::to_owned)
|
||
};
|
||
Ok(Episode {
|
||
identity,
|
||
title: text("title"),
|
||
synopsis: text("synopsis"),
|
||
aired_on: text("aired_on"),
|
||
duration_seconds: metadata.get("duration_seconds").and_then(Value::as_u64),
|
||
metadata,
|
||
})
|
||
})
|
||
.collect::<io::Result<Vec<_>>>()?;
|
||
Ok(Series { media, episodes })
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
#[test]
|
||
fn catalog_and_actual_episode_numbers_remain_distinct() {
|
||
let media = serde_json::json!({"id":"opaque_ID", "title":{"english":"The Apothecary Diaries", "romaji":"Kusuriya no Hitorigoto"}, "episode_count":12, "episode_counts":{"sub":null}, "episode_duration_minutes":null});
|
||
let rows = catalog(vec![media.clone()]).unwrap();
|
||
assert_eq!(rows[0].id, "opaque_ID");
|
||
assert!(rows[0].metadata["episode_counts"]["sub"].is_null());
|
||
assert!(catalog(vec![media.clone(), media.clone()]).is_err());
|
||
let episode = serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned","synopsis":"Actual synopsis","aired_on":"2026-10-02","duration_seconds":1500});
|
||
let detail = || Detail {
|
||
media: media.clone(),
|
||
episodes: vec![episode.clone()],
|
||
};
|
||
let data = series("opaque_ID", detail()).unwrap();
|
||
assert_eq!(data.episodes.len(), 1);
|
||
assert_eq!(data.episodes[0].identity.episode, 3);
|
||
assert_eq!(data.episodes[0].identity.media_id, "opaque_ID");
|
||
assert_eq!(data.episodes[0].duration_seconds, Some(1500));
|
||
assert_eq!(
|
||
data.episodes[0].synopsis.as_deref(),
|
||
Some("Actual synopsis")
|
||
);
|
||
assert!(data.episodes[0].snapshot(&data.media).channel_id.is_none());
|
||
assert!(series("other", detail()).is_err());
|
||
assert!(
|
||
series(
|
||
"opaque_ID",
|
||
Detail {
|
||
media: media.clone(),
|
||
episodes: vec![episode.clone(), episode]
|
||
}
|
||
)
|
||
.is_err()
|
||
);
|
||
assert!(
|
||
series(
|
||
"opaque_ID",
|
||
Detail {
|
||
media,
|
||
episodes: vec![serde_json::json!({"absolute_number":49})]
|
||
}
|
||
)
|
||
.is_err()
|
||
);
|
||
assert!(slug("薬屋").is_err());
|
||
assert_eq!(slug(" A Test!! -- Title ").unwrap(), "a-test-title");
|
||
assert_eq!(slug(" --Title-- ").unwrap(), "-title-");
|
||
assert_eq!(slug("École déjà-vu").unwrap(), "ecole-deja-vu");
|
||
assert_eq!(slug("Cafe\u{0301}").unwrap(), "cafe");
|
||
assert_eq!(slug("A薬屋 KK İ").unwrap(), "kk-i");
|
||
assert_eq!(
|
||
slug("\u{feff}A\u{00a0}B\u{2003}C\u{feff}").unwrap(),
|
||
"a-b-c"
|
||
);
|
||
assert_eq!(slug("A\u{0085}B").unwrap(), "ab");
|
||
assert!(slug("!!薬屋!!").is_err());
|
||
assert_eq!(
|
||
title(&serde_json::json!({"id":"id","title":{"english":"AB","romaji":"CD"}})).unwrap(),
|
||
"AB"
|
||
);
|
||
assert_eq!(
|
||
title(&serde_json::json!({"id":"id","title":{"english":"𐀀A","romaji":"BC"}})).unwrap(),
|
||
"BC"
|
||
);
|
||
assert_eq!(
|
||
title(&serde_json::json!({"id":"id","title":{"native":""}})).unwrap(),
|
||
""
|
||
);
|
||
}
|
||
}
|