feat: separate Miruro and YouTube browser interfaces
This commit is contained in:
+139
-70
@@ -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-");
|
||||
|
||||
Reference in New Issue
Block a user