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, pub synopsis: Option, pub aired_on: Option, pub duration_seconds: Option, 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, } pub enum Request { Search(String), Episodes { media_id: String, slug: String }, Source(Video), } pub enum Response { Catalog(Vec), Series(Series), Source(Video, Box), } // 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)>, 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)> { 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 { 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 { 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) -> 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 { 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) -> io::Result> { 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 { 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::>>()?; 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(), "" ); } }