//! 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>, } /// 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 { 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 { 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, 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 = 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 = 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::(&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, 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::() .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 = 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, 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, 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::() .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()) }