Files
tubular/src/miruro_browser.rs
T

346 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(),
""
);
}
}