//! YouTube DJ-set tracklist extraction — the evidence rungs the feasibility //! research ranks FIRST (creator- and viewer-declared data): the video's //! chapters, its description, and timestamped tracklists in top comments. //! (TODO-Feature-Docs/playlist-management-and-setlist-import, research //! §3/§15: "the high-value, reliable subset is metadata import … broader //! automatic recognition must remain optional".) No audio is fetched and //! nothing is recognized — this is metadata import, honest about it, and a //! set with no posted tracklist simply reports that instead of guessing. //! //! Everything runs in-process over reqwest (the no-child-processes posture): //! one GET of the watch page, plus at most one innertube POST for comments //! when the page itself carried no tracklist. The extracted lines feed the //! EXISTING pasted-tracklist pipeline (playlist_formats::parse_text → //! resolve_draft → review modal → commit), so every downstream behavior — //! setlist stamps, library matching, stream fallbacks, unresolved rows — //! is the one already shipped and tested. use serde_json::Value; /// What one video yielded: identity for the draft header plus the chosen /// tracklist text and where it came from. pub struct SetlistFetch { pub title: String, pub author: String, pub tracklist: String, /// "chapters" | "description" | "a comment by …" — shown in the modal's /// warnings so the evidence rung is visible (research §3: "show which /// rung produced a result"). pub provenance: String, pub warnings: Vec, } /// Minimum timestamped lines before a block counts as a tracklist — one or /// two stamps are a lyric quote or a "best drop" comment, not a setlist. const MIN_TRACKLIST_LINES: usize = 3; /// Top-level comments scanned for a tracklist before giving up. const MAX_COMMENTS_SCANNED: usize = 40; // ---- URL handling ----------------------------------------------------------- /// Accepts watch/short/live/embed/youtu.be URLs (and a bare 11-char id) and /// returns the canonical video id. pub fn video_id_from_url(url: &str) -> Option { let s = url.trim(); let is_id = |c: &str| { c.len() == 11 && c.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') }; if is_id(s) { return Some(s.to_string()); } let after_scheme = s.split("://").nth(1).unwrap_or(s); let (host, rest) = after_scheme.split_once('/')?; let host = host.split('@').next_back().unwrap_or(host).to_lowercase(); let host = host.split(':').next().unwrap_or(&host); let path_q = rest.split('#').next().unwrap_or(rest); let (path, query) = match path_q.split_once('?') { Some((p, q)) => (p, Some(q)), None => (path_q, None), }; let candidate = if host == "youtu.be" { path.split('/').next().map(str::to_string) } else if host.ends_with("youtube.com") || host.ends_with("youtube-nocookie.com") { let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); match segs.first().copied() { Some("watch") => query.and_then(|q| { q.split('&') .find_map(|kv| kv.strip_prefix("v=").map(str::to_string)) }), Some("shorts") | Some("live") | Some("embed") | Some("v") => { segs.get(1).map(|s| s.to_string()) } _ => None, } } else { None }; candidate.filter(|c| is_id(c)) } // ---- watch-page JSON slicing -------------------------------------------------- /// Slice the JSON object that begins at the first `{` after `needle`, /// brace-counting with string/escape awareness — the watch page embeds its /// data as `var ytInitialData = {...};` inside script tags, megabytes long. pub(crate) fn slice_json_after<'a>(html: &'a str, needle: &str) -> Option<&'a str> { let at = html.find(needle)?; let rest = &html[at + needle.len()..]; let open = rest.find('{')?; let bytes = rest.as_bytes(); let mut depth = 0usize; let mut in_str = false; let mut escaped = false; for (i, &b) in bytes.iter().enumerate().skip(open) { if in_str { if escaped { escaped = false; } else if b == b'\\' { escaped = true; } else if b == b'"' { in_str = false; } continue; } match b { b'"' => in_str = true, b'{' => depth += 1, b'}' => { depth -= 1; if depth == 0 { return Some(&rest[open..=i]); } } _ => {} } } None } /// Depth-first search for every object under `root` that contains `key`, /// applying `f` to the value. Youtube's trees are deep, volatile, and /// renderer-name-happy; searching for leaf keys survives their reshuffles /// better than hardcoded paths. pub(crate) fn walk<'a>(root: &'a Value, key: &str, f: &mut dyn FnMut(&'a Value)) { match root { Value::Object(map) => { if let Some(v) = map.get(key) { f(v); } for v in map.values() { walk(v, key, f); } } Value::Array(items) => { for v in items { walk(v, key, f); } } _ => {} } } /// First string under any of `keys` in reading order — for label-ish leaves /// ("simpleText", nested runs) where youtube varies the wrapper. pub(crate) fn first_string(v: &Value, keys: &[&str]) -> Option { match v { Value::String(s) => Some(s.clone()), _ => { for k in keys { let mut found = None; walk(v, k, &mut |x| { if found.is_none() { if let Some(s) = x.as_str() { found = Some(s.to_string()); } } }); if found.is_some() { return found; } } None } } } // ---- the three evidence rungs ------------------------------------------------- /// A line "looks timestamped" when it carries a `m:ss`-or-longer stamp plus /// some letters — the same acceptance the pasted-tracklist parser applies, /// reused for candidate SCORING before anything is parsed for real. pub(crate) fn is_timestamped_line(line: &str) -> bool { let has_letters = line.chars().filter(|c| c.is_alphabetic()).count() >= 2; if !has_letters { return false; } line.split(|c: char| c.is_whitespace() || c == '[' || c == ']' || c == '(' || c == ')') .any(|tok| { let t = tok.trim_matches(|c: char| !c.is_ascii_digit() && c != ':'); crate::playlist_formats::parse_timestamp(t).is_some() }) } /// Chapters out of ytInitialData: every `chapterRenderer` carries a title /// and `timeRangeStartMillis`. Ordered, creator-authored — the top rung. pub(crate) fn chapters_tracklist(initial_data: &Value) -> Option { let mut rows: Vec<(i64, String)> = Vec::new(); walk(initial_data, "chapterRenderer", &mut |ch| { let ms = ch .get("timeRangeStartMillis") .and_then(Value::as_i64) .unwrap_or(-1); let title = ch .get("title") .and_then(|t| first_string(t, &["simpleText", "text"])); if ms >= 0 { if let Some(t) = title { rows.push((ms, t)); } } }); if rows.len() < MIN_TRACKLIST_LINES { return None; } rows.sort_by_key(|(ms, _)| *ms); rows.dedup(); Some( rows.into_iter() .map(|(ms, t)| format!("{} {}", fmt_ts(ms), t)) .collect::>() .join("\n"), ) } /// The description's best block: the longest run of timestamped lines with /// at most one untimed line between them (headers like "Tracklist:" break /// nothing; prose paragraphs do). pub(crate) fn description_tracklist(description: &str) -> Option { fn flush<'a>( current: &mut Vec<&'a str>, stamped: &mut usize, best: &mut Option<(usize, Vec<&'a str>)>, ) { if *stamped >= MIN_TRACKLIST_LINES && best.as_ref().map(|(n, _)| *stamped > *n).unwrap_or(true) { *best = Some((*stamped, current.clone())); } current.clear(); *stamped = 0; } let mut best: Option<(usize, Vec<&str>)> = None; let mut current: Vec<&str> = Vec::new(); let mut gap = 0usize; let mut stamped = 0usize; for line in description.lines() { let trimmed = line.trim(); if is_timestamped_line(trimmed) { current.push(trimmed); stamped += 1; gap = 0; } else if !current.is_empty() && !trimmed.is_empty() && gap == 0 { // One stray untimed line rides along (a wrapped title, an "ID"), // a second breaks the block. gap = 1; } else if !current.is_empty() { flush(&mut current, &mut stamped, &mut best); gap = 0; } } flush(&mut current, &mut stamped, &mut best); best.map(|(_, lines)| lines.join("\n")) } /// Comment texts out of an innertube `next` response. Youtube has shipped /// two shapes in the wild — legacy `contentText` runs and the newer /// `commentEntityPayload` — so both are walked; each yields (author, text). pub(crate) fn comment_texts(next_response: &Value) -> Vec<(String, String)> { let mut out: Vec<(String, String)> = Vec::new(); walk(next_response, "commentEntityPayload", &mut |p| { let text = p .get("properties") .and_then(|pr| pr.get("content")) .and_then(|c| c.get("content")) .and_then(Value::as_str); let author = p .get("author") .and_then(|a| a.get("displayName")) .and_then(Value::as_str) .unwrap_or("a commenter"); if let Some(t) = text { out.push((author.to_string(), t.to_string())); } }); walk(next_response, "commentRenderer", &mut |c| { let text = c .get("contentText") .map(|ct| collect_runs(ct)) .filter(|t| !t.is_empty()); let author = c .get("authorText") .and_then(|a| first_string(a, &["simpleText"])) .unwrap_or_else(|| "a commenter".into()); if let Some(t) = text { out.push((author, t)); } }); out.truncate(MAX_COMMENTS_SCANNED); out } /// Flatten a `runs`/`simpleText` rich-text node into plain text, preserving /// the run boundaries' own newlines (timestamps arrive as separate runs). pub(crate) fn collect_runs(v: &Value) -> String { if let Some(s) = v.get("simpleText").and_then(Value::as_str) { return s.to_string(); } let mut out = String::new(); walk(v, "text", &mut |t| { if let Some(s) = t.as_str() { out.push_str(s); } }); out } /// The comments continuation token for the initial `next` call, discovered /// generically: the token whose enclosing area names the comment section. pub(crate) fn comments_token(initial_data: &Value) -> Option { // The comment section's continuation is the one inside an // itemSectionRenderer whose identifier mentions comments. let mut token: Option = None; walk(initial_data, "itemSectionRenderer", &mut |sec| { if token.is_some() { return; } let ident = sec .get("sectionIdentifier") .and_then(Value::as_str) .unwrap_or(""); if !ident.contains("comment") { return; } walk(sec, "continuationCommand", &mut |cc| { if token.is_none() { if let Some(t) = cc.get("token").and_then(Value::as_str) { token = Some(t.to_string()); } } }); }); token } fn fmt_ts(ms: i64) -> String { let total = ms / 1000; let h = total / 3600; let m = (total % 3600) / 60; let s = total % 60; if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") } } /// Innertube client version, read from the page so the comments call speaks /// the same dialect the page was served with; a dated fallback otherwise. pub(crate) fn client_version(html: &str) -> String { slice_after_quote(html, "\"INNERTUBE_CONTEXT_CLIENT_VERSION\":\"") .unwrap_or("2.20260101.00.00") .to_string() } fn slice_after_quote<'a>(html: &'a str, needle: &str) -> Option<&'a str> { let at = html.find(needle)?; let rest = &html[at + needle.len()..]; rest.split('"').next() } // ---- the fetch ---------------------------------------------------------------- /// Just the video's identity (title, author) — for the LISTENER's draft /// header, whose sets typically have NO posted tracklist (that's why they /// get listened to): the full `fetch` returns Err for those and took the /// title down with it (Kyle's first own-set run was named by raw video id). pub async fn fetch_title(url: &str) -> Option<(String, String)> { let id = video_id_from_url(url)?; let client = reqwest::Client::builder() .user_agent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \ Chrome/126.0.0.0 Safari/537.36", ) .timeout(std::time::Duration::from_secs(15)) .build() .ok()?; let html = client .get(format!("https://www.youtube.com/watch?v={id}&hl=en")) .header("Cookie", "CONSENT=YES+1; SOCS=CAI") .send() .await .ok()? .text() .await .ok()?; let player: Value = slice_json_after(&html, "ytInitialPlayerResponse") .and_then(|s| serde_json::from_str(s).ok())?; let details = player.get("videoDetails")?; let title = details.get("title").and_then(Value::as_str)?.to_string(); let author = details .get("author") .and_then(Value::as_str) .unwrap_or("") .to_string(); Some((title, author)) } /// Fetch one video's metadata and pick its best tracklist. At most two /// requests; every rung failure downgrades to the next with a warning /// instead of failing the whole import (research §12). pub async fn fetch(url: &str) -> Result { let id = video_id_from_url(url).ok_or("That doesn't look like a YouTube video link")?; let client = reqwest::Client::builder() .user_agent( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) \ Chrome/126.0.0.0 Safari/537.36", ) .timeout(std::time::Duration::from_secs(25)) .build() .map_err(|e| e.to_string())?; let html = client .get(format!("https://www.youtube.com/watch?v={id}&hl=en")) .header("Cookie", "CONSENT=YES+1; SOCS=CAI") .header("Accept-Language", "en") .send() .await .map_err(|e| format!("YouTube did not answer: {e}"))? .error_for_status() .map_err(|e| format!("YouTube refused the page: {e}"))? .text() .await .map_err(|e| e.to_string())?; let player: Value = slice_json_after(&html, "ytInitialPlayerResponse") .and_then(|s| serde_json::from_str(s).ok()) .ok_or("Could not read the video's data — is the link public?")?; // Unavailable/private/region-locked videos still serve a page — with a // playability error instead of details. Say that plainly (research §8). let playability = player .get("playabilityStatus") .and_then(|p| p.get("status")) .and_then(Value::as_str) .unwrap_or("OK"); if playability != "OK" && player.get("videoDetails").is_none() { let reason = player .get("playabilityStatus") .and_then(|p| p.get("reason")) .and_then(Value::as_str) .unwrap_or("it may be private, removed, or region-locked"); return Err(format!("YouTube says this video is unavailable — {reason}")); } let details = player.get("videoDetails").cloned().unwrap_or(Value::Null); let title = details .get("title") .and_then(Value::as_str) .unwrap_or("YouTube set") .to_string(); let author = details .get("author") .and_then(Value::as_str) .unwrap_or("") .to_string(); let description = details .get("shortDescription") .and_then(Value::as_str) .unwrap_or("") .to_string(); let initial_data: Value = slice_json_after(&html, "ytInitialData") .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or(Value::Null); let mut warnings = Vec::new(); // Rung 1: chapters. Rung 2: the description's timestamped block. if let Some(list) = chapters_tracklist(&initial_data) { return Ok(SetlistFetch { title, author, tracklist: list, provenance: "the video's chapters".into(), warnings, }); } if let Some(list) = description_tracklist(&description) { return Ok(SetlistFetch { title, author, tracklist: list, provenance: "the video description".into(), warnings, }); } // Rung 3: top comments — one innertube call, failure-soft. match comments_token(&initial_data) { None => warnings.push("The video page exposed no comments to scan.".into()), Some(token) => { let version = client_version(&html); let body = serde_json::json!({ "context": { "client": { "clientName": "WEB", "clientVersion": version, "hl": "en", "gl": "US", }}, "continuation": token, }); let resp = client .post("https://www.youtube.com/youtubei/v1/next?prettyPrint=false") .json(&body) .send() .await; match resp { Err(e) => warnings.push(format!("Comments could not be scanned: {e}")), Ok(r) => match r.json::().await { Err(e) => warnings.push(format!("Comments could not be read: {e}")), Ok(next) => { let mut best: Option<(usize, String, String)> = None; for (who, text) in comment_texts(&next) { let stamped = text.lines().filter(|l| is_timestamped_line(l.trim())).count(); if stamped >= MIN_TRACKLIST_LINES && best.as_ref().map(|(n, _, _)| stamped > *n).unwrap_or(true) { let block: Vec<&str> = text .lines() .map(str::trim) .filter(|l| is_timestamped_line(l)) .collect(); best = Some((stamped, who.clone(), block.join("\n"))); } } if let Some((n, who, list)) = best { warnings.push(format!( "Tracklist found in a comment by {who} ({n} timestamped lines) — viewer-supplied, worth a skim." )); return Ok(SetlistFetch { title, author, tracklist: list, provenance: format!("a comment by {who}"), warnings, }); } warnings.push("No comment carried a timestamped tracklist.".into()); } }, } } } Err(format!( "No tracklist found on \"{title}\" — no chapters, no timestamped description, and {} \ Paste one into the tracklist door if you have it.", if warnings.iter().any(|w| w.contains("comment")) { "none of the top comments carry one." } else { "the comments could not be scanned." } )) } #[cfg(test)] mod tests { use super::*; #[test] fn video_ids_from_every_url_shape() { for (url, want) in [ ("https://www.youtube.com/watch?v=dQw4w9WgXcQ", Some("dQw4w9WgXcQ")), ("https://youtu.be/dQw4w9WgXcQ?t=42", Some("dQw4w9WgXcQ")), ("https://www.youtube.com/live/abc-DEF_123?feature=share", Some("abc-DEF_123")), ("https://music.youtube.com/watch?v=dQw4w9WgXcQ&list=RD", Some("dQw4w9WgXcQ")), ("www.youtube.com/shorts/abc-DEF_123", Some("abc-DEF_123")), ("dQw4w9WgXcQ", Some("dQw4w9WgXcQ")), ("https://example.com/watch?v=dQw4w9WgXcQ", None), ("https://www.youtube.com/watch?v=tooShort", None), ] { assert_eq!( video_id_from_url(url).as_deref(), want, "{url} must yield {want:?}" ); } } #[test] fn json_slicer_handles_braces_in_strings() { let html = r#""#; let json = slice_json_after(html, "ytInitialData").unwrap(); let v: Value = serde_json::from_str(json).unwrap(); assert_eq!(v["b"]["c"], 1); } #[test] fn chapters_walk_and_format() { let data: Value = serde_json::from_str( r#"{"markersMap":[{"value":{"chapters":[ {"chapterRenderer":{"title":{"simpleText":"Bicep - Glue"},"timeRangeStartMillis":0}}, {"chapterRenderer":{"title":{"simpleText":"Overmono - So U Kno"},"timeRangeStartMillis":330000}}, {"chapterRenderer":{"title":{"simpleText":"Joy O - Sin Palta"},"timeRangeStartMillis":3900000}} ]}}]}"#, ) .unwrap(); let list = chapters_tracklist(&data).unwrap(); assert_eq!( list, "0:00 Bicep - Glue\n5:30 Overmono - So U Kno\n1:05:00 Joy O - Sin Palta" ); } #[test] fn description_block_beats_prose_and_survives_headers() { let desc = "Recorded live at the warehouse.\nBig thanks to everyone!\n\nTracklist:\n00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\nID - ID\n12:44 Artist Three - Closer\n\nFollow me on socials at 10:00 tonight"; let block = description_tracklist(desc).unwrap(); assert_eq!( block, "00:00 Artist One - Opener\n04:12 Artist Two - Middle (Remix)\n12:44 Artist Three - Closer" ); } #[test] fn too_few_stamps_is_no_tracklist() { assert!(description_tracklist("00:01 hi\nsome words\n03:30 drop!").is_none()); assert!(!is_timestamped_line("10000 subscribers!")); assert!(is_timestamped_line("1:23:45 Artist - Title")); assert!(is_timestamped_line("Artist - Title [04:20]")); } #[test] fn comment_payloads_both_shapes() { let next: Value = serde_json::from_str( r#"{"frameworkUpdates":{"entityBatchUpdate":{"mutations":[ {"payload":{"commentEntityPayload":{ "properties":{"content":{"content":"00:00 A - B\n03:00 C - D\n06:00 E - F"}}, "author":{"displayName":"@digger"}}}} ]}}, "old":[{"commentRenderer":{ "authorText":{"simpleText":"@legacy"}, "contentText":{"runs":[{"text":"great set"}]}}}]}"#, ) .unwrap(); let texts = comment_texts(&next); assert_eq!(texts.len(), 2); assert_eq!(texts[0].0, "@digger"); assert!(texts[0].1.contains("03:00 C - D")); assert_eq!(texts[1], ("@legacy".into(), "great set".into())); } #[test] fn comments_token_found_by_section_identifier() { let data: Value = serde_json::from_str( r#"{"contents":[ {"itemSectionRenderer":{"sectionIdentifier":"related-items", "contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"WRONG"}}}}]}}, {"itemSectionRenderer":{"sectionIdentifier":"comment-item-section", "contents":[{"continuationItemRenderer":{"continuationEndpoint":{"continuationCommand":{"token":"RIGHT"}}}}]}} ]}"#, ) .unwrap(); assert_eq!(comments_token(&data).as_deref(), Some("RIGHT")); } }