From 9cc8fcb10bd142480789d11991f93ac68f8de4ad Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Thu, 22 Oct 2026 12:08:33 -0700 Subject: [PATCH] Make YouTube auto-upload a setting, shown in both the YouTube and Substack sheets The publish sheet always has a YouTube line now, not only for posts with local videos, with a note saying how many there are and who will see them, or that YouTube isn't connected. The YouTube sheet shows the same setting as Auto-upload. Changing it in either saves it at once. It keeps the state.toml key the remembered choice used, so that choice carries over. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +-- crates/stet-app/src/platform.rs | 3 +- crates/stet-app/src/publish.rs | 51 +++++++++++++++++---------------- crates/stet-app/src/session.rs | 2 ++ crates/stet-app/src/youtube.rs | 45 +++++++++++++++++++++++++++-- 5 files changed, 75 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index c911236..e3236ff 100644 --- a/README.md +++ b/README.md @@ -492,10 +492,10 @@ you press on Substack. 1. **Signing in.** Substack has no publishing API, so Stet uses the one its own editor does, with your session cookie. The first time, the menu asks for it: in your browser, on substack.com, DevTools → Application → Cookies → `substack.sid`. It is kept in the macOS login keychain and nowhere else, never shown, and sent only to Substack over HTTPS (to a custom domain only once Substack says the domain is yours). 2. **What carries over.** Headings, emphasis, links, lists, quotes, code, pictures with captions, footnotes. A table goes up as preformatted text, because Substack's editor has none; the menu says so before you send. -3. **Videos.** A YouTube address becomes an embed. A local video is either uploaded to YouTube and embedded, or left as a link: the menu's **Videos** line picks which. Each video is uploaded once. Stet remembers the file by its contents (in `youtube-uploads.json` beside its settings), so publishing the same post again, or another post with the same video, reuses the upload, and only uploads again if that video was deleted from YouTube. +3. **Videos.** A YouTube address becomes an embed. A local video is either uploaded to YouTube and embedded, or left as a link: the **YouTube** line turns auto-upload on (the default) or off. It is one setting, shared with the YouTube page, and saved as soon as you change it; the line under it says how many local videos the post has and who will see them. Each video is uploaded once. Stet remembers the file by its contents (in `youtube-uploads.json` beside its settings), so publishing the same post again, or another post with the same video, reuses the upload, and only uploads again if that video was deleted from YouTube. 4. **It may break.** This is Substack's private interface, undocumented and theirs to change. -**Connecting YouTube.** `:youtube` (File → Connect YouTube…) signs in with your own Google OAuth client. Make one first: in Google Cloud, enable the *YouTube Data API v3*, then Credentials → Create OAuth client ID → *Desktop app*. Paste its ID and secret, and **Sign in with Google** finishes in your browser. The client and the sign-in are kept in the macOS login keychain. Once you're signed in, the same page shows the channel and who sees uploads (Unlisted by default, which is enough for an embed). Google's rules apply: a project that has not passed YouTube's API audit can only upload private videos, and while its consent screen is in *Testing* the sign-in lapses after seven days. +**Connecting YouTube.** `:youtube` (File → Connect YouTube…) signs in with your own Google OAuth client. Make one first: in Google Cloud, enable the *YouTube Data API v3*, then Credentials → Create OAuth client ID → *Desktop app*. Paste its ID and secret, and **Sign in with Google** finishes in your browser. The client and the sign-in are kept in the macOS login keychain. Once you're signed in, the same page shows the channel, who sees uploads (Unlisted by default, which is enough for an embed) and **Auto-upload**, the setting the publish menu shows too. Google's rules apply: a project that has not passed YouTube's API audit can only upload private videos, and while its consent screen is in *Testing* the sign-in lapses after seven days. Pages like this one are how Stet asks for settings. They sit in the command menu's panel and work the way it does: ↑ and ↓ move, typing fills in the selected line, ← and → change a choice, Enter acts, Esc closes. diff --git a/crates/stet-app/src/platform.rs b/crates/stet-app/src/platform.rs index f1c8d74..ef9c966 100644 --- a/crates/stet-app/src/platform.rs +++ b/crates/stet-app/src/platform.rs @@ -128,7 +128,8 @@ pub struct State { pub substack: Option, /// Who sees uploaded videos: `unlisted`, `public` or `private`. pub youtube_privacy: Option, - /// What publishing did with local videos last: `youtube` or `link`. + /// Whether publishing uploads local videos to YouTube (`youtube`, the + /// default) or leaves them as links to the file (`link`). pub videos: Option, } diff --git a/crates/stet-app/src/publish.rs b/crates/stet-app/src/publish.rs index e83d27d..88c2844 100644 --- a/crates/stet-app/src/publish.rs +++ b/crates/stet-app/src/publish.rs @@ -14,9 +14,6 @@ use stet_substack::{ pub const SHEET: &str = "publish"; const AUDIENCES: [(&str, &str); 2] = [("Everyone", "everyone"), ("Paid subscribers", "only_paid")]; -/// What to do with local videos, and the word the choice is remembered by. -#[cfg(feature = "youtube")] -const VIDEOS: [(&str, &str); 2] = [("Upload to YouTube", "youtube"), ("Link to the file", "link")]; enum Event { /// Who the stored cookie belongs to; the flag says Substack refused it. @@ -208,12 +205,19 @@ impl Publisher { let audiences = AUDIENCES.map(|(label, _)| label); rows.push(Row::choice("audience", "Audience", &audiences, 0)); #[cfg(feature = "youtube")] - if !document.local_videos.is_empty() { - // Upload, unless linking was chosen last time or YouTube is not signed in. - let remembered = crate::platform::State::load().videos; - let link = remembered.as_deref() == Some("link") || (!self.offline && !stet_youtube::keychain::signed_in()); - let options = VIDEOS.map(|(label, _)| label); - rows.push(Row::choice("videos", "Videos", &options, link as usize)); + { + // The YouTube setting, shown whether or not this post has videos. + rows.push(crate::youtube::auto_upload_row("YouTube", "Auto-upload videos", "Off")); + let videos = match document.local_videos.len() { + 0 => "No local videos in this post".to_string(), + 1 => "One local video".to_string(), + count => format!("{count} local videos"), + }; + let account = match self.offline || stet_youtube::keychain::signed_in() { + true => crate::youtube::privacy().label().to_string(), + false => "not connected: run :youtube to sign in".to_string(), + }; + rows.push(Row::note("youtube-about", "", &format!("{videos} · {account}"))); } let pictures = match document.local_images().len() { 0 => String::new(), @@ -225,13 +229,15 @@ impl Publisher { "", &format!("It goes up as a draft; nothing is sent to readers.{pictures}"), )); - // The videos row says what becomes of them; their warnings would repeat it. + // Videos going to YouTube need no warning that they cannot go to Substack. + #[cfg(feature = "youtube")] + let uploading = crate::youtube::auto_upload(); + #[cfg(not(feature = "youtube"))] + let uploading = false; let warnings: Vec<&String> = document .warnings .iter() - .filter(|warning| { - !(cfg!(feature = "youtube") && document.local_videos.iter().any(|v| warning.contains(v.as_str()))) - }) + .filter(|warning| !(uploading && document.local_videos.iter().any(|v| warning.contains(v.as_str())))) .collect(); if let Some(first) = warnings.first() { let more = match warnings.len() - 1 { @@ -294,22 +300,17 @@ impl Publisher { let markdown = editor.buf.text(); let document = convert(&markdown, &Options::default()); #[cfg(feature = "youtube")] - let to_youtube: Vec = match sheet.chosen("videos") { + let to_youtube: Vec = match sheet.chosen(crate::youtube::AUTO) { Some(0) => document.local_videos.clone(), _ => Vec::new(), }; #[cfg(feature = "youtube")] - if let Some(chosen) = sheet.chosen("videos") { - if chosen == 0 && !stet_youtube::keychain::signed_in() { - sheet.status = Some(( - "YouTube is not signed in: run :youtube first, or link the videos.".into(), - true, - )); - return; - } - let mut state = crate::platform::State::load(); - state.videos = Some(VIDEOS[chosen.min(1)].1.to_string()); - state.save(); + if !to_youtube.is_empty() && !stet_youtube::keychain::signed_in() { + sheet.status = Some(( + "YouTube is not signed in: run :youtube first, or turn auto-upload off.".into(), + true, + )); + return; } #[cfg(feature = "youtube")] let local = !document.local_images().is_empty() || !to_youtube.is_empty(); diff --git a/crates/stet-app/src/session.rs b/crates/stet-app/src/session.rs index bb759be..b5c3e6b 100644 --- a/crates/stet-app/src/session.rs +++ b/crates/stet-app/src/session.rs @@ -153,6 +153,8 @@ impl Session { other => rest.push(other), } } + #[cfg(feature = "youtube")] + self.youtube.keep_auto_upload(&self.editor); if !moved_visually { self.view.goal_x = None; } diff --git a/crates/stet-app/src/youtube.rs b/crates/stet-app/src/youtube.rs index 1c4fc90..6f3cecb 100644 --- a/crates/stet-app/src/youtube.rs +++ b/crates/stet-app/src/youtube.rs @@ -27,6 +27,8 @@ pub struct Connector { run: u64, /// No keychain and no network: a screenshot wants only the sheet. pub offline: bool, + /// The auto-upload setting as last saved, once read. + auto: Option, } /// Who sees uploads, as last chosen. @@ -38,6 +40,27 @@ pub fn privacy() -> Privacy { .unwrap_or_default() } +/// Whether publishing uploads a document's local videos, as set. On unless +/// turned off. +pub fn auto_upload() -> bool { + crate::platform::State::load().videos.as_deref() != Some("link") +} + +fn set_auto_upload(on: bool) { + let mut state = crate::platform::State::load(); + state.videos = Some(if on { "youtube" } else { "link" }.to_string()); + state.save(); +} + +/// The auto-upload setting as a row, labelled as `label` with `on` and `off`. +/// Both sheets show it under the same id, and whichever changes it saves it. +pub fn auto_upload_row(label: &str, on: &str, off: &str) -> Row { + Row::choice(AUTO, label, &[on, off], (!auto_upload()) as usize) +} + +/// The id of the auto-upload row. +pub const AUTO: &str = "auto"; + fn sign_in_rows(client_id: &str) -> [Row; 3] { [ Row::text("client-id", "Client ID", client_id, "….apps.googleusercontent.com"), @@ -50,12 +73,13 @@ fn sign_in_rows(client_id: &str) -> [Row; 3] { ] } -fn signed_in_rows() -> [Row; 3] { +fn signed_in_rows() -> [Row; 4] { let labels = Privacy::ALL.map(Privacy::label); let chosen = Privacy::ALL.iter().position(|p| *p == privacy()).unwrap_or(0); [ Row::note("channel", "Channel", "Checking your sign-in…"), Row::choice("privacy", "Privacy", &labels, chosen), + auto_upload_row("Auto-upload", "On", "Off"), Row::note( "about", "", @@ -72,7 +96,7 @@ fn signed_in_buttons() -> Vec