main
John Lauer Publish 0.5.1 5ce075e 8h ago
//! YouTube, for any Adom user: find out which channel an upload would land on, give feedback on the
//! title and the description, and upload through adom-google (private or unlisted first). When
//! adom-google is not set up, say how to set it up; when it cannot reach the channel, say why and
//! name the other way (YouTube Studio in a browser the AI drives).

use std::process::Command;

fn run(args: &[&str]) -> Option<(bool, String)> {
    Command::new("adom-google").args(args).output().ok().map(|o| (o.status.success(), format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr))))
}

const SETUP: &str = "Set up adom-google for YouTube: `adom-wiki pkg install adom/adom-google`, then `adom-google --account youtube auth --url-file /tmp/yt.url`. Before opening the link, cut its `scope` down to youtube.upload and youtube only (YouTube access cannot be asked for together with other Google access), open it in a browser, and on Google's 'Choose your account or a brand account' screen pick the CHANNEL the film belongs on. Keep the auth command running until Allow is clicked.";
const STUDIO: &str = "The other way, when adom-google cannot reach their channel: sign in to studio.youtube.com in a browser you drive (a pup window keeps their login), press Create, Upload videos, give it the file, paste the title and description, add the thumbnail, set Visibility to Private or Unlisted, and Save. Slower, but it always works.";

/// Which channel each adom-google account uploads to.
pub fn check() -> (String, Vec<String>) {
    let Some((ok, accounts)) = run(&["accounts"]) else {
        return ("adom-google is not installed, so there is no direct way to upload yet".into(), vec![SETUP.into(), STUDIO.into()]);
    };
    if !ok { return ("adom-google is installed but not authorized".into(), vec![SETUP.into(), STUDIO.into()]); }
    let names: Vec<String> = accounts.lines().filter_map(|l| l.trim_start_matches('*').split_whitespace().next().map(str::to_string)).filter(|n| !n.is_empty() && !n.starts_with("Select") && n.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')).collect();
    let mut hints = vec![]; let mut found = 0;
    for n in &names {
        match run(&["--account", n, "youtube", "channels"]) {
            Some((true, out)) => { let first = out.lines().next().unwrap_or("").trim().to_string(); if first.is_empty() { hints.push(format!("account `{n}`: signed in, but that Google identity has no YouTube channel")); } else { found += 1; hints.push(format!("account `{n}` uploads to: {first}")); } }
            Some((false, out)) if out.contains("API not enabled") || out.contains("switched OFF") => hints.push(format!("account `{n}`: the YouTube API is switched off for the Google app this account signs in with, so it cannot upload. That is fixed by whoever runs that app (for Adom's own apps: [email protected]), not by the user.")),
            Some((false, out)) if out.contains("scope") => hints.push(format!("account `{n}`: no YouTube permission on this sign-in (it was made for other Google services)")),
            _ => hints.push(format!("account `{n}`: could not read its YouTube channel")),
        }
    }
    hints.push("Ask the human which channel the film belongs on and say the channel's name back to them before uploading. A person often has several (personal, family, company).".into());
    if found == 0 { hints.push(SETUP.into()); }
    hints.push(STUDIO.into());
    (format!("{found} of {} adom-google account(s) can upload to a YouTube channel", names.len()), hints)
}

/// Gentle feedback on title candidates: `youtube title "A" "B" ...`.
pub fn title(cands: &[String]) -> (String, Vec<String>) {
    let mut hints = vec![];
    let hype = ["insane", "mind-blowing", "you won't believe", "game changer", "revolutionary", "ultimate", "secret", "shocking"];
    for t in cands {
        let n = t.chars().count(); let low = t.to_lowercase(); let mut notes = vec![];
        if n > 60 { notes.push(format!("{n} characters: search results and phones cut titles near 55 to 60")); } else if n < 25 { notes.push(format!("{n} characters: there is room to say what was made")); } else { notes.push(format!("{n} characters: fits")); }
        if t.contains('|') || t.contains(" - ") { notes.push("drop the separator and the channel or brand name: YouTube already shows the channel".into()); }
        if t.ends_with('!') || t.ends_with('.') { notes.push("no closing punctuation".into()); }
        if hype.iter().any(|h| low.contains(h)) { notes.push("hype words read as clickbait; say the concrete thing instead".into()); }
        if t.chars().filter(|c| c.is_uppercase()).count() > n / 2 { notes.push("all caps reads as shouting".into()); }
        if !low.split_whitespace().take(4).any(|w| ["ai", "pcb", "board", "i", "my", "how", "building", "designing"].contains(&w.trim_matches(|c: char| !c.is_alphanumeric()))) { notes.push("put the searched-for words (AI, PCB, the thing made, the tool) near the front".into()); }
        hints.push(format!("\"{t}\": {}", notes.join("; ")));
    }
    hints.push("Offer the human about five titles, best first, and let them pick or reword: say what was made, for whom or why, and the tool, in the words a person would search for. A personal story (\"for my daughter's dorm\") beats a product name nobody searches for yet.".into());
    hints.push("The thumbnail and the title work as a pair: the thumbnail carries 3 to 5 big words and one picture (the finished board or the person), the title carries the rest. Do not repeat the title in the thumbnail.".into());
    (format!("{} title(s) looked at", cands.len()), hints)
}

/// A thumbnail that YouTube will take: 1280 x 720 (or larger 16:9), JPG or PNG, under 2 MB.
pub fn thumbnail(file: &str) -> (String, Vec<String>) {
    let probe = Command::new("ffprobe").args(["-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=p=0", file]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default();
    let size = std::fs::metadata(file).map(|m| m.len()).unwrap_or(0);
    let (w, h) = probe.split_once(',').map(|(a, b)| (a.parse::<u32>().unwrap_or(0), b.parse::<u32>().unwrap_or(0))).unwrap_or((0, 0));
    let mut hints = vec![];
    if w == 0 { hints.push("Could not read the picture: make a 1280 x 720 JPG or PNG.".into()); }
    else { if w * 9 != h * 16 { hints.push(format!("{w} x {h} is not 16:9: YouTube will add bars or crop it; 1280 x 720 is the standard.")); } if w < 1280 { hints.push(format!("{w} px wide is small: 1280 x 720 or larger stays sharp.")); } }
    if size > 2_000_000 { hints.push(format!("{:.1} MB is over YouTube's 2 MB limit: save it as a JPG at a lower quality.", size as f64 / 1e6)); }
    hints.push("How to compose one: show the finished product where it is used, not a bare board (if the film has no such frame, stage one in the Adom 3D viewer: camera low and close, the surroundings behind it, the viewer's controls hidden). Take a clean frame (`ffmpeg -ss <t> -i film.mp4 -frames:v 1 frame.png`), a frame of the narrator from their camera take if they were on camera, and 3 to 5 large words on the left. Keep faces and words out of the bottom-right corner (the duration badge) and the bottom-left (link previews on social sites cover it). View it at 320 px wide: if the words are not readable there, they are too small.".into());
    hints.push("Show the human two or three versions and let them choose. It is their face and their channel.".into());
    (format!("thumbnail {w} x {h}, {:.2} MB", size as f64 / 1e6), hints)
}

pub struct Upload<'a> { pub file: &'a str, pub title: &'a str, pub description_file: Option<&'a str>, pub thumbnail: Option<&'a str>, pub privacy: &'a str, pub account: Option<&'a str>, pub tags: Option<&'a str> }

pub fn upload(u: &Upload) -> Result<(String, Vec<String>), (String, Vec<String>)> {
    if !std::path::Path::new(u.file).is_file() { return Err((format!("no film at {}", u.file), vec![])); }
    let desc = match u.description_file { Some(f) => std::fs::read_to_string(f).map_err(|e| (format!("could not read {f}: {e}"), vec![]))?, None => String::new() };
    let mut args: Vec<String> = vec![];
    if let Some(a) = u.account { args.push("--account".into()); args.push(a.into()); }
    args.extend(["youtube", "upload", u.file, "--title", u.title, "--description", &desc, "--privacy", u.privacy, "--category", "28", "--json"].iter().map(|s| s.to_string()));
    if let Some(t) = u.thumbnail { args.push("--thumbnail".into()); args.push(t.into()); }
    if let Some(t) = u.tags { args.push("--tags".into()); args.push(t.into()); }
    let refs: Vec<&str> = args.iter().map(String::as_str).collect();
    match run(&refs) {
        None => Err(("adom-google is not installed".into(), vec![SETUP.into(), STUDIO.into()])),
        Some((false, out)) => Err(("the upload did not go through".into(), vec![out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(300).collect(), "Run `youtube check` to see which channel each account reaches.".into(), STUDIO.into()])),
        Some((true, out)) => {
            let url = out.split('"').find(|s| s.contains("youtube.com/watch") || s.contains("youtu.be/")).unwrap_or("").to_string();
            let mut hints = vec![format!("It is {}: only people with the link can find it (unlisted is the default, so the human always sees it on YouTube before the world does). Give the human the link and the channel name, and have them watch it on YouTube itself (picture quality, thumbnail, captions, chapter markers) before anything is made public.", u.privacy)];
            if u.privacy == "public" { hints[0] = "It is public. Next time upload as private or unlisted first so the human can check it on YouTube before the world sees it.".into(); }
            hints.push("Chapters come from the description: a list of `m:ss Title` lines that starts at 0:00, with every chapter at least 10 seconds long. Captions: upload the film's .srt in YouTube Studio (Subtitles), or leave YouTube's automatic ones.".into());
            hints.push("Then `mark publish --note \"<link>\"`.".into());
            Ok((format!("uploaded: {}", if url.is_empty() { out.lines().last().unwrap_or("").to_string() } else { url }), hints))
        }
    }
}

/// The video's id from a link (youtu.be/<id>, watch?v=<id>, studio .../video/<id>/...) or the id itself.
pub fn video_id(v: &str) -> String {
    for k in ["youtu.be/", "watch?v=", "/video/", "/shorts/"] { if let Some(i) = v.find(k) { return v[i + k.len()..].chars().take_while(|c| c.is_alphanumeric() || *c == '-' || *c == '_').collect(); } }
    v.trim().to_string()
}

/// Open the video's YouTube Studio page in a browser window on the human's desktop, so they land on it
/// without hunting: Details by default, Subtitles for captions.
pub fn open(video: &str, captions: bool, srt: Option<&str>, thread: &str) -> (String, Vec<String>) {
    let id = video_id(video);
    let url = format!("https://studio.youtube.com/video/{id}/{}", if captions { "translations" } else { "edit" });
    let args = serde_json::json!({"sessionId": "aiflow-video-youtube", "url": url, "purpose": "the human's YouTube Studio page for the film just uploaded"}).to_string();
    let opened = Command::new("adom-bridge").args(["--ai-thread", thread, "pup_open_window", &args]).output().map(|o| o.status.success() && String::from_utf8_lossy(&o.stdout).contains("\"ok\":true") || String::from_utf8_lossy(&o.stdout).contains("\"ok\": true")).unwrap_or(false);
    let mut hints = vec![];
    if !opened { hints.push(format!("Could not open a browser window through Adom Bridge. Give the human this link instead: {url}")); }
    hints.push("If Studio shows the wrong channel, the browser is signed in to another Google account: add `?authuser=1` (or 2) to the link, or use the account switcher, until the channel's name matches the one the human named.".into());
    if captions {
        hints.push(format!("Captions: YouTube's automatic ones misspell names, so upload the film's own .srt{}. On this Subtitles page press `Upload manual`, keep `With timing`, press `Continue`, and give the file to the page's file input (`input#captions-file-loader`; with pup: `pup_upload_file`). Studio then holds them as a draft: open `Edit subtitles`, read the first lines back to check they are yours, and press `Publish`.", srt.map(|f| format!(" ({f})")).unwrap_or_default()));
        hints.push("Check afterwards that the video lists an `English` track beside `English (auto-generated)`.".into());
    } else {
        hints.push("Bring that window to the front and tell the human it is their video's page: title, description, thumbnail and visibility are all there to change. Then do the captions: `youtube captions <link> --file <film.srt>`.".into());
    }
    (if opened { format!("YouTube Studio is open for the human: {url}") } else { format!("YouTube Studio page: {url}") }, hints)
}