feat: separate Miruro and YouTube browser interfaces

This commit is contained in:
2026-10-13 17:38:01 +02:00
parent 318e908da1
commit df35b1cddb
5 changed files with 2060 additions and 462 deletions
+81 -372
View File
File diff suppressed because one or more lines are too long
+139 -70
View File
@@ -8,13 +8,56 @@ use std::{
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 {
Rows(Vec<(Video, String)>),
Catalog(Vec<Media>),
Series(Series),
Source(Video, Box<SourceDocument>),
}
@@ -35,12 +78,12 @@ impl Worker {
let result = match request {
Request::Search(query) => client
.search(&query)
.and_then(catalog_rows)
.map(Response::Rows),
.and_then(catalog)
.map(Response::Catalog),
Request::Episodes { media_id, slug } => client
.detail(&media_id, &slug)
.and_then(|detail| episode_rows(&media_id, detail))
.map(Response::Rows),
.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)
@@ -89,9 +132,6 @@ impl Worker {
}
}
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)
}
@@ -162,38 +202,48 @@ fn video(id: String, title: String, date: Option<String>) -> Video {
publication_date: date,
}
}
pub fn catalog_rows(items: Vec<Value>) -> io::Result<Vec<(Video, String)>> {
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(|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,
))
.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 episode_rows(media_id: &str, detail: Detail) -> io::Result<Vec<(Video, String)>> {
let series = title(&detail.media)?;
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();
detail
let episodes = detail
.episodes
.into_iter()
.map(|episode| {
let number = episode
.map(|metadata| {
let number = metadata
.get("episode_number")
.and_then(Value::as_u64)
.and_then(|n| u32::try_from(n).ok())
@@ -202,27 +252,23 @@ pub fn episode_rows(media_id: &str, detail: Detail) -> io::Result<Vec<(Video, St
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))
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()
.collect::<io::Result<Vec<_>>>()?;
Ok(Series { media, episodes })
}
#[cfg(test)]
@@ -230,24 +276,47 @@ 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());
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-");
+1793
View File
File diff suppressed because it is too large Load Diff