feat: integrate Miruro playback and episode progress

This commit is contained in:
2026-10-12 22:23:15 +02:00
parent 0fe24ff302
commit 0e909001d0
9 changed files with 1676 additions and 92 deletions
+276
View File
@@ -0,0 +1,276 @@
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;
pub enum Request {
Search(String),
Episodes { media_id: String, slug: String },
Source(Video),
}
pub enum Response {
Rows(Vec<(Video, String)>),
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_rows)
.map(Response::Rows),
Request::Episodes { media_id, slug } => client
.detail(&media_id, &slug)
.and_then(|detail| episode_rows(&media_id, detail))
.map(Response::Rows),
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,
}
}
}
pub fn is_miruro(video: &Video) -> bool {
video.id.starts_with("miruro:") || video.id.starts_with("miruro-series:")
}
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,
}
}
pub fn catalog_rows(items: Vec<Value>) -> io::Result<Vec<(Video, String)>> {
items
.into_iter()
.map(|media| {
let id = media
.get("id")
.and_then(Value::as_str)
.ok_or_else(|| invalid("Miruro catalog ID is missing"))?;
Identity::new(id.to_owned(), 1)?;
let title = title(&media)?;
let description = media
.get("description")
.and_then(Value::as_str)
.unwrap_or(
"Catalog title: Enter opens returned episodes; only episodes can be saved.",
)
.to_owned();
Ok((
video(format!("miruro-series:{id}"), title, None),
description,
))
})
.collect()
}
pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result<Vec<(Video, String)>> {
let series = title(&detail.media)?;
let mut seen = std::collections::HashSet::new();
detail
.episodes
.into_iter()
.map(|episode| {
let number = episode
.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 label = episode.get("title").and_then(Value::as_str).unwrap_or("");
let title = format!(
"{series} · Episode {number}{}",
if label.is_empty() {
String::new()
} else {
format!(" · {label}")
}
);
let description = episode
.get("synopsis")
.and_then(Value::as_str)
.unwrap_or("Episode metadata unavailable.")
.to_owned();
let date = episode
.get("aired_on")
.and_then(Value::as_str)
.map(str::to_owned);
Ok((video(identity.key(), title, date), description))
})
.collect()
}
#[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"}});
let rows = catalog_rows(vec![media.clone()]).unwrap();
assert_eq!(rows[0].0.id, "miruro-series:opaque_ID");
assert!(Identity::from_key(&rows[0].0.id).is_err());
let episodes = episode_rows(
"opaque_ID",
Detail {
media,
episodes: vec![
serde_json::json!({"episode_number":3,"absolute_number":49,"title":"Returned"}),
],
},
)
.unwrap();
let identity = Identity::from_key(&episodes[0].0.id).unwrap();
assert_eq!(identity.episode, 3);
assert_eq!(identity.media_id, "opaque_ID");
assert!(episodes[0].0.channel_id.is_none());
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(),
""
);
}
}