forked from 1b-agent/opencadstudio-upstream
250 lines
8.8 KiB
Rust
250 lines
8.8 KiB
Rust
//! Start-page tutorial-video list, sourced from the official YouTube playlist.
|
|
//!
|
|
//! Native builds scan the playlist at boot and cache the result. Web builds
|
|
//! read a same-origin `videos.json` and thumbnails generated by the Pages
|
|
//! workflow, avoiding YouTube's browser CORS restrictions.
|
|
|
|
/// The official tutorials playlist.
|
|
pub const PLAYLIST_URL: &str =
|
|
"https://youtube.com/playlist?list=PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
|
|
|
|
const PLAYLIST_ID: &str = "PLZq_TEkIFh9bAnoOX1HiCAunm3anZDBOl";
|
|
|
|
/// One playlist entry, ready for the Start page.
|
|
#[derive(Clone, Debug, serde::Deserialize)]
|
|
pub struct VideoEntry {
|
|
pub id: String,
|
|
pub title: String,
|
|
/// JPEG bytes of the thumbnail (from cache or the network), if available.
|
|
#[serde(default)]
|
|
pub thumb: Option<Vec<u8>>,
|
|
}
|
|
|
|
/// Watch URL for one of the playlist's videos, keeping the playlist context.
|
|
pub fn watch_url(id: &str) -> String {
|
|
format!("https://www.youtube.com/watch?v={id}&list={PLAYLIST_ID}")
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
const UA: &str = concat!("OpenCADStudio/", env!("OCS_APP_VERSION"));
|
|
|
|
/// Directory of the on-disk thumbnail / listing cache.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn cache_dir() -> Option<std::path::PathBuf> {
|
|
crate::config::config_dir().map(|d| d.join("video_thumbs"))
|
|
}
|
|
|
|
/// Load the listing cached by the previous successful fetch — the instant,
|
|
/// offline-friendly Start-page state while the live fetch runs.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn load_cached() -> Vec<VideoEntry> {
|
|
let Some(dir) = cache_dir() else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(listing) = std::fs::read_to_string(dir.join("playlist.txt")) else {
|
|
return Vec::new();
|
|
};
|
|
listing
|
|
.lines()
|
|
.filter_map(|line| {
|
|
let (id, title) = line.split_once('\t')?;
|
|
if id.len() != 11 || title.is_empty() {
|
|
return None;
|
|
}
|
|
Some(VideoEntry {
|
|
id: id.to_string(),
|
|
title: title.to_string(),
|
|
thumb: std::fs::read(dir.join(format!("{id}.jpg"))).ok(),
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Fetch the playlist's current entries, in playlist order, refreshing the
|
|
/// on-disk cache. `Err` when offline or the page markup changes — the caller
|
|
/// keeps whatever [`load_cached`] provided.
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
pub fn fetch_playlist() -> Result<Vec<VideoEntry>, String> {
|
|
let agent = crate::network::agent(std::time::Duration::from_secs(15));
|
|
let page = agent
|
|
.get(PLAYLIST_URL)
|
|
.header("User-Agent", "Mozilla/5.0")
|
|
.call()
|
|
.map_err(|e| e.to_string())?
|
|
.body_mut()
|
|
.with_config()
|
|
.limit(8 * 1024 * 1024)
|
|
.read_to_string()
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Ordered, de-duplicated video ids from the page's embedded JSON. The
|
|
// `"videoId":"…"` pattern has been stable for years and doubles as the
|
|
// canary: no matches → markup changed → keep the cached list.
|
|
let mut ids: Vec<String> = Vec::new();
|
|
let mut rest = page.as_str();
|
|
while let Some(pos) = rest.find("\"videoId\":\"") {
|
|
let start = pos + "\"videoId\":\"".len();
|
|
let Some(end) = rest[start..].find('"') else {
|
|
break;
|
|
};
|
|
let id = &rest[start..start + end];
|
|
if id.len() == 11 && !ids.iter().any(|k| k == id) {
|
|
ids.push(id.to_string());
|
|
}
|
|
rest = &rest[start + end..];
|
|
if ids.len() >= 50 {
|
|
break;
|
|
}
|
|
}
|
|
if ids.is_empty() {
|
|
return Err("no videos found in playlist page".into());
|
|
}
|
|
|
|
let dir = cache_dir();
|
|
if let Some(d) = &dir {
|
|
let _ = std::fs::create_dir_all(d);
|
|
}
|
|
|
|
let mut out: Vec<VideoEntry> = Vec::with_capacity(ids.len());
|
|
for id in ids {
|
|
// Title via oEmbed — stable and key-less. A video without a title
|
|
// (deleted / private) is skipped.
|
|
let title = agent
|
|
.get(&format!(
|
|
"https://www.youtube.com/oembed?url=https://youtu.be/{id}&format=json"
|
|
))
|
|
.header("User-Agent", UA)
|
|
.call()
|
|
.ok()
|
|
.and_then(|mut r| r.body_mut().read_to_string().ok())
|
|
.and_then(|body| {
|
|
serde_json::from_str::<serde_json::Value>(&body)
|
|
.ok()?
|
|
.get("title")?
|
|
.as_str()
|
|
.map(str::to_string)
|
|
});
|
|
let Some(title) = title else { continue };
|
|
|
|
// Thumbnail: cache first, else download once and store it.
|
|
let cache_path = dir.as_ref().map(|d| d.join(format!("{id}.jpg")));
|
|
let mut thumb = cache_path.as_ref().and_then(|p| std::fs::read(p).ok());
|
|
if thumb.is_none() {
|
|
let fetched = agent
|
|
.get(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg"))
|
|
.header("User-Agent", UA)
|
|
.call()
|
|
.ok()
|
|
.and_then(|mut r| {
|
|
r.body_mut()
|
|
.with_config()
|
|
.limit(2 * 1024 * 1024)
|
|
.read_to_vec()
|
|
.ok()
|
|
});
|
|
if let (Some(bytes), Some(p)) = (&fetched, &cache_path) {
|
|
let _ = std::fs::write(p, bytes);
|
|
}
|
|
thumb = fetched;
|
|
}
|
|
out.push(VideoEntry { id, title, thumb });
|
|
}
|
|
if out.is_empty() {
|
|
return Err("no video titles resolved".into());
|
|
}
|
|
|
|
// The playlist page lists oldest-first; the Start page wants the newest
|
|
// tutorials on top. Reversed before persisting, so the cached listing
|
|
// (and every later offline launch) shares the same order.
|
|
out.reverse();
|
|
|
|
// Persist the listing so the next launch starts from it.
|
|
if let Some(d) = &dir {
|
|
let listing: String = out
|
|
.iter()
|
|
.map(|v| format!("{}\t{}\n", v.id, v.title.replace(['\t', '\n'], " ")))
|
|
.collect();
|
|
let _ = std::fs::write(d.join("playlist.txt"), listing);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
pub async fn fetch_playlist_web() -> Result<Vec<VideoEntry>, String> {
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
|
|
let window = web_sys::window().ok_or("no window")?;
|
|
let response = JsFuture::from(window.fetch_with_str("videos.json"))
|
|
.await
|
|
.map_err(|_| "videos.json fetch failed")?
|
|
.dyn_into::<web_sys::Response>()
|
|
.map_err(|_| "videos.json response is invalid")?;
|
|
if !response.ok() {
|
|
return Err(format!("videos.json HTTP {}", response.status()));
|
|
}
|
|
let text = JsFuture::from(
|
|
response
|
|
.text()
|
|
.map_err(|_| "videos.json text() unavailable")?,
|
|
)
|
|
.await
|
|
.map_err(|_| "videos.json body read failed")?;
|
|
let body = text.as_string().ok_or("videos.json body is not a string")?;
|
|
let mut entries: Vec<VideoEntry> =
|
|
serde_json::from_str(&body).map_err(|error| error.to_string())?;
|
|
entries.retain(|entry| entry.id.len() == 11 && !entry.title.trim().is_empty());
|
|
if entries.is_empty() {
|
|
return Err("videos.json contains no videos".into());
|
|
}
|
|
|
|
let thumbnails = iced::futures::future::join_all(
|
|
entries
|
|
.iter()
|
|
.map(|entry| fetch_thumbnail_web(entry.id.clone())),
|
|
)
|
|
.await;
|
|
for (entry, thumbnail) in entries.iter_mut().zip(thumbnails) {
|
|
entry.thumb = thumbnail.ok();
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
async fn fetch_thumbnail_web(id: String) -> Result<Vec<u8>, String> {
|
|
let local = format!("video_thumbs/{id}.jpg");
|
|
match fetch_thumbnail_url(&local).await {
|
|
Ok(bytes) => Ok(bytes),
|
|
Err(_) => {
|
|
// Local development uses the checked-in JSON snapshot without
|
|
// binary thumbnails. YouTube's image CDN explicitly permits CORS,
|
|
// so it is a safe fallback; deployed Pages builds use local files.
|
|
fetch_thumbnail_url(&format!("https://i.ytimg.com/vi/{id}/mqdefault.jpg")).await
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
async fn fetch_thumbnail_url(url: &str) -> Result<Vec<u8>, String> {
|
|
use wasm_bindgen::JsCast;
|
|
use wasm_bindgen_futures::JsFuture;
|
|
|
|
let window = web_sys::window().ok_or("no window")?;
|
|
let response = JsFuture::from(window.fetch_with_str(url))
|
|
.await
|
|
.map_err(|_| "thumbnail fetch failed")?
|
|
.dyn_into::<web_sys::Response>()
|
|
.map_err(|_| "thumbnail response is invalid")?;
|
|
if !response.ok() {
|
|
return Err(format!("thumbnail HTTP {}", response.status()));
|
|
}
|
|
let buffer = JsFuture::from(
|
|
response
|
|
.array_buffer()
|
|
.map_err(|_| "thumbnail arrayBuffer() unavailable")?,
|
|
)
|
|
.await
|
|
.map_err(|_| "thumbnail body read failed")?;
|
|
Ok(js_sys::Uint8Array::new(&buffer).to_vec())
|
|
}
|