feat: sign in to YouTube with browser cookies
Answers the bot challenge from inside the app. Settings gains a browser picker listing only the browsers actually installed; choosing one passes --cookies-from-browser to every yt-dlp call, so YouTube sees an authenticated session. Verified against a live block: refused without cookies, resolved with them. Arc is Chromium underneath but is not one of yt-dlp's known names, so it is addressed by its profile directory instead. yt-dlp's errors are translated into something actionable. The stock bot message points at command-line flags a user cannot type; it now names the setting that fixes it, and Safari's protected cookie store gets its own message about Full Disk Access rather than a bare 'Operation not permitted'. A Check connection button reports whether YouTube is reachable, testing against the newest video in the feed — the hardcoded id it used at first had been taken down, so it reported a dead video rather than the connection.
This commit is contained in:
+146
-10
@@ -46,17 +46,138 @@ pub struct AppState {
|
|||||||
/// argv prefix that runs yt-dlp: either a system binary, or the bundled
|
/// argv prefix that runs yt-dlp: either a system binary, or the bundled
|
||||||
/// Python interpreter followed by the zipapp.
|
/// Python interpreter followed by the zipapp.
|
||||||
pub yt_dlp: Vec<String>,
|
pub yt_dlp: Vec<String>,
|
||||||
|
/// Value for yt-dlp's --cookies-from-browser, when signed in.
|
||||||
|
pub cookies_from: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
/// A ready-to-configure yt-dlp process.
|
/// A ready-to-configure yt-dlp process, carrying cookies when configured.
|
||||||
fn yt_dlp(&self) -> tokio::process::Command {
|
async fn yt_dlp(&self) -> tokio::process::Command {
|
||||||
let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]);
|
let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]);
|
||||||
cmd.args(&self.yt_dlp[1..]);
|
cmd.args(&self.yt_dlp[1..]);
|
||||||
|
if let Some(from) = self.cookies_from.lock().await.clone() {
|
||||||
|
cmd.arg("--cookies-from-browser").arg(from);
|
||||||
|
}
|
||||||
cmd
|
cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Browsers yt-dlp can read cookies from, as (id, label, --cookies-from-browser
|
||||||
|
/// value). Arc is Chromium underneath but is not one of yt-dlp's known names,
|
||||||
|
/// so it is addressed by its profile directory.
|
||||||
|
fn browser_options() -> Vec<(String, String, String)> {
|
||||||
|
let home = std::env::var("HOME").unwrap_or_default();
|
||||||
|
vec![
|
||||||
|
("safari", "Safari", "/Applications/Safari.app", "safari".to_string()),
|
||||||
|
("arc", "Arc", "/Applications/Arc.app",
|
||||||
|
format!("chrome:{home}/Library/Application Support/Arc/User Data")),
|
||||||
|
("chrome", "Google Chrome", "/Applications/Google Chrome.app", "chrome".to_string()),
|
||||||
|
("firefox", "Firefox", "/Applications/Firefox.app", "firefox".to_string()),
|
||||||
|
("brave", "Brave", "/Applications/Brave Browser.app", "brave".to_string()),
|
||||||
|
("edge", "Microsoft Edge", "/Applications/Microsoft Edge.app", "edge".to_string()),
|
||||||
|
("vivaldi", "Vivaldi", "/Applications/Vivaldi.app", "vivaldi".to_string()),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(_, _, app, _)| std::path::Path::new(app).exists())
|
||||||
|
.map(|(id, label, _, value)| (id.to_string(), label.to_string(), value))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The browsers actually installed, for the Settings picker.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_browsers() -> Result<Vec<(String, String)>, String> {
|
||||||
|
Ok(browser_options()
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, label, _)| (id, label))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chooses which browser's cookies yt-dlp should use. An empty id signs out.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_cookie_source(
|
||||||
|
browser: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let value = browser_options()
|
||||||
|
.into_iter()
|
||||||
|
.find(|(id, _, _)| *id == browser)
|
||||||
|
.map(|(_, _, value)| value);
|
||||||
|
*state.cookies_from.lock().await = value;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns yt-dlp's stderr into something worth showing.
|
||||||
|
///
|
||||||
|
/// The bot challenge is the one users hit most, and its stock message points at
|
||||||
|
/// command-line flags they have no way to type, so it is replaced with the
|
||||||
|
/// setting that actually fixes it.
|
||||||
|
fn explain_yt_dlp_error(stderr: &str, signed_in: bool) -> String {
|
||||||
|
if stderr.contains("Sign in to confirm") || stderr.contains("not a bot") {
|
||||||
|
return if signed_in {
|
||||||
|
"YouTube is still refusing this machine even with browser cookies. The sign-in may have expired — reopen YouTube in that browser, or wait a while before trying again."
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
"YouTube is asking this machine to prove it is not a bot. Open Settings and pick a browser under Sign in to YouTube; the app will use that browser's session."
|
||||||
|
.into()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if stderr.contains("Operation not permitted") && stderr.contains("Safari") {
|
||||||
|
return "macOS blocked access to Safari's cookies. Give FlightTube Full Disk Access in System Settings → Privacy & Security, or pick a different browser."
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
if stderr.contains("could not find") && stderr.contains("cookies database") {
|
||||||
|
return "That browser has no cookie store on this Mac. Pick another under Settings → Sign in to YouTube."
|
||||||
|
.into();
|
||||||
|
}
|
||||||
|
stderr
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| l.contains("ERROR"))
|
||||||
|
.unwrap_or("yt-dlp failed")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tries a real extraction so Settings can report whether YouTube is reachable.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn test_youtube(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let signed_in = state.cookies_from.lock().await.is_some();
|
||||||
|
// Test against the newest video in the feed. A hardcoded id is no good —
|
||||||
|
// the one this used at first had been taken down, so the check reported a
|
||||||
|
// dead video rather than the connection.
|
||||||
|
let target = state
|
||||||
|
.db
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.list_feed(&FeedFilter { limit: Some(1), ..Default::default() })?
|
||||||
|
.first()
|
||||||
|
.map(|v| v.id.clone())
|
||||||
|
.ok_or("Import your subscriptions first — there is nothing to test with.")?;
|
||||||
|
|
||||||
|
let mut cmd = state.yt_dlp().await;
|
||||||
|
cmd.args([
|
||||||
|
"--no-playlist",
|
||||||
|
"--simulate",
|
||||||
|
"--print",
|
||||||
|
"%(id)s",
|
||||||
|
&format!("https://www.youtube.com/watch?v={target}"),
|
||||||
|
]);
|
||||||
|
let out = cmd
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Could not run yt-dlp: {e}"))?;
|
||||||
|
if out.status.success() {
|
||||||
|
return Ok(if signed_in {
|
||||||
|
"YouTube is reachable, using your browser sign-in.".into()
|
||||||
|
} else {
|
||||||
|
"YouTube is reachable.".into()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(explain_yt_dlp_error(
|
||||||
|
&String::from_utf8_lossy(&out.stderr),
|
||||||
|
signed_in,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
|
/// Comfortably inside the ~6h lifetime of YouTube's signed URLs.
|
||||||
const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600);
|
const STREAM_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(3 * 3600);
|
||||||
|
|
||||||
@@ -354,6 +475,18 @@ pub async fn resolve_stream(
|
|||||||
return Ok(Stream { url: Some(u), playlist: None });
|
return Ok(Stream { url: Some(u), playlist: None });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distinguish "YouTube is refusing us" from "this video has no stream".
|
||||||
|
let signed_in = state.cookies_from.lock().await.is_some();
|
||||||
|
let mut probe = state.yt_dlp().await;
|
||||||
|
probe.args(["--no-playlist", "--simulate", "--print", "%(id)s", &url]);
|
||||||
|
if let Ok(out) = probe.output().await {
|
||||||
|
if !out.status.success() {
|
||||||
|
return Err(explain_yt_dlp_error(
|
||||||
|
&String::from_utf8_lossy(&out.stderr),
|
||||||
|
signed_in,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
Err("Could not find a playable stream for this video.".into())
|
Err("Could not find a playable stream for this video.".into())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +668,7 @@ fn resolution_height(stream_inf: &str) -> Option<u32> {
|
|||||||
|
|
||||||
/// Runs yt-dlp and returns every non-empty stdout line.
|
/// Runs yt-dlp and returns every non-empty stdout line.
|
||||||
async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec<String> {
|
async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec<String> {
|
||||||
let mut cmd = state.yt_dlp();
|
let mut cmd = state.yt_dlp().await;
|
||||||
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
let Ok(out) = cmd.output().await else { return Vec::new() };
|
let Ok(out) = cmd.output().await else { return Vec::new() };
|
||||||
@@ -551,7 +684,7 @@ async fn yt_dlp_lines(state: &State<'_, AppState>, args: &[&str]) -> Vec<String>
|
|||||||
|
|
||||||
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
/// Runs yt-dlp and returns its first non-empty stdout line, or None.
|
||||||
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
|
async fn yt_dlp_print(state: &State<'_, AppState>, args: &[&str]) -> Option<String> {
|
||||||
let mut cmd = state.yt_dlp();
|
let mut cmd = state.yt_dlp().await;
|
||||||
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
cmd.args(["--no-warnings", "--no-playlist", "--simulate"]);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
let out = cmd.output().await.ok()?;
|
let out = cmd.output().await.ok()?;
|
||||||
@@ -811,6 +944,7 @@ pub async fn download_video(
|
|||||||
|
|
||||||
let mut child = state
|
let mut child = state
|
||||||
.yt_dlp()
|
.yt_dlp()
|
||||||
|
.await
|
||||||
.args(&args)
|
.args(&args)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
@@ -932,12 +1066,13 @@ pub async fn download_video(
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let msg = stderr_lines
|
let signed_in = state.cookies_from.lock().await.is_some();
|
||||||
.iter()
|
let joined = stderr_lines.join("\n");
|
||||||
.rev()
|
let msg = if joined.trim().is_empty() {
|
||||||
.find(|l| l.contains("ERROR"))
|
format!("yt-dlp exited with {status}")
|
||||||
.cloned()
|
} else {
|
||||||
.unwrap_or_else(|| format!("yt-dlp exited with {status}"));
|
explain_yt_dlp_error(&joined, signed_in)
|
||||||
|
};
|
||||||
let db = state.db.lock().await;
|
let db = state.db.lock().await;
|
||||||
db.set_download_state(&video_id, DownloadState::Failed, Some(&msg))?;
|
db.set_download_state(&video_id, DownloadState::Failed, Some(&msg))?;
|
||||||
drop(db);
|
drop(db);
|
||||||
@@ -1202,6 +1337,7 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
prereqs: Arc::new(Mutex::new(None)),
|
prereqs: Arc::new(Mutex::new(None)),
|
||||||
streams: Arc::new(Mutex::new(HashMap::new())),
|
streams: Arc::new(Mutex::new(HashMap::new())),
|
||||||
yt_dlp: resolve_yt_dlp(app),
|
yt_dlp: resolve_yt_dlp(app),
|
||||||
|
cookies_from: Arc::new(Mutex::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -161,6 +161,9 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
commands::delete_all_downloads,
|
commands::delete_all_downloads,
|
||||||
commands::list_subtitles,
|
commands::list_subtitles,
|
||||||
commands::fetch_durations,
|
commands::fetch_durations,
|
||||||
|
commands::list_browsers,
|
||||||
|
commands::set_cookie_source,
|
||||||
|
commands::test_youtube,
|
||||||
commands::get_connectivity,
|
commands::get_connectivity,
|
||||||
commands::set_library_path,
|
commands::set_library_path,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
|
|||||||
+20
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
cancelDownload, deleteAllDownloads, deleteDownload, downloadVideo,
|
||||||
fetchDurations, onRefreshProgress, refreshFeeds,
|
fetchDurations, onRefreshProgress, refreshFeeds, setCookieSource,
|
||||||
} from "./api";
|
} from "./api";
|
||||||
import Player from "./components/Player";
|
import Player from "./components/Player";
|
||||||
import Settings from "./components/Settings";
|
import Settings from "./components/Settings";
|
||||||
@@ -55,6 +55,13 @@ export default function App() {
|
|||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
// Index into the current feed, so the player can step through it.
|
// Index into the current feed, so the player can step through it.
|
||||||
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
const [playingIndex, setPlayingIndex] = useState<number | null>(null);
|
||||||
|
const [browser, setBrowser] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem("flighttube.browser") ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
});
|
||||||
const [subLang, setSubLang] = useState(() => {
|
const [subLang, setSubLang] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("flighttube.subLang");
|
const stored = localStorage.getItem("flighttube.subLang");
|
||||||
@@ -84,6 +91,14 @@ export default function App() {
|
|||||||
const [toast, setToast] = useState<string | null>(null);
|
const [toast, setToast] = useState<string | null>(null);
|
||||||
const [failure, setFailure] = useState<string | null>(null);
|
const [failure, setFailure] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// The backend holds no state across launches, so the stored choice has to be
|
||||||
|
// handed back to it before the first yt-dlp call.
|
||||||
|
useEffect(() => {
|
||||||
|
setCookieSource(browser).catch(() => {
|
||||||
|
/* falls back to no cookies */
|
||||||
|
});
|
||||||
|
}, [browser]);
|
||||||
|
|
||||||
const { mode, setMode } = useAppearance();
|
const { mode, setMode } = useAppearance();
|
||||||
const windowFullscreen = useWindowFullscreen();
|
const windowFullscreen = useWindowFullscreen();
|
||||||
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
const { online, reachable, forcedOffline, setForcedOffline, probe } = useConnectivity();
|
||||||
@@ -104,13 +119,14 @@ export default function App() {
|
|||||||
localStorage.setItem("flighttube.quality", quality);
|
localStorage.setItem("flighttube.quality", quality);
|
||||||
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
localStorage.setItem("flighttube.streamQuality", streamQuality);
|
||||||
localStorage.setItem("flighttube.subLang", subLang);
|
localStorage.setItem("flighttube.subLang", subLang);
|
||||||
|
localStorage.setItem("flighttube.browser", browser);
|
||||||
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
localStorage.setItem("flighttube.downloadedOnly", downloadedOnly ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
localStorage.setItem("flighttube.hideShorts", hideShorts ? "1" : "0");
|
||||||
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
localStorage.setItem("flighttube.sidebarHidden", sidebarHidden ? "1" : "0");
|
||||||
} catch {
|
} catch {
|
||||||
/* storage blocked */
|
/* storage blocked */
|
||||||
}
|
}
|
||||||
}, [view, quality, streamQuality, subLang, downloadedOnly, hideShorts, sidebarHidden]);
|
}, [view, quality, streamQuality, subLang, browser, downloadedOnly, hideShorts, sidebarHidden]);
|
||||||
|
|
||||||
// Offline, the only videos that can be played are the ones already on disk,
|
// Offline, the only videos that can be played are the ones already on disk,
|
||||||
// so the feed collapses to those regardless of the toggle.
|
// so the feed collapses to those regardless of the toggle.
|
||||||
@@ -459,6 +475,8 @@ export default function App() {
|
|||||||
onStreamQuality={setStreamQuality}
|
onStreamQuality={setStreamQuality}
|
||||||
subLang={subLang}
|
subLang={subLang}
|
||||||
onSubLang={setSubLang}
|
onSubLang={setSubLang}
|
||||||
|
browser={browser}
|
||||||
|
onBrowser={setBrowser}
|
||||||
onError={setFailure}
|
onError={setFailure}
|
||||||
onImported={(n) => {
|
onImported={(n) => {
|
||||||
reload();
|
reload();
|
||||||
|
|||||||
+10
@@ -32,6 +32,16 @@ export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
|||||||
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
/** Fills in missing video lengths, a batch at a time. Returns how many. */
|
||||||
export const fetchDurations = () => invoke<number>("fetch_durations");
|
export const fetchDurations = () => invoke<number>("fetch_durations");
|
||||||
|
|
||||||
|
/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */
|
||||||
|
export const listBrowsers = () => invoke<Array<[string, string]>>("list_browsers");
|
||||||
|
|
||||||
|
/** Empty string signs out. */
|
||||||
|
export const setCookieSource = (browser: string) =>
|
||||||
|
invoke<void>("set_cookie_source", { browser });
|
||||||
|
|
||||||
|
/** Resolves a known video to check whether YouTube is currently reachable. */
|
||||||
|
export const testYoutube = () => invoke<string>("test_youtube");
|
||||||
|
|
||||||
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
|
/** WebVTT sidecars beside a downloaded video, as [language, path] pairs. */
|
||||||
export const listSubtitles = (videoId: string) =>
|
export const listSubtitles = (videoId: string) =>
|
||||||
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
invoke<Array<[string, string]>>("list_subtitles", { videoId });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
checkPrereqs, importTakeoutCsv, pickLibraryFolder, pickTakeoutFile, previewTakeoutImport,
|
checkPrereqs, importTakeoutCsv, listBrowsers, pickLibraryFolder, pickTakeoutFile,
|
||||||
|
previewTakeoutImport, setCookieSource, testYoutube,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||||
import {
|
import {
|
||||||
@@ -9,7 +10,8 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import TakeoutGuide from "./TakeoutGuide";
|
import TakeoutGuide from "./TakeoutGuide";
|
||||||
import {
|
import {
|
||||||
BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented, SUBPANEL,
|
BTN, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented,
|
||||||
|
SUBPANEL,
|
||||||
} from "./ui";
|
} from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -23,6 +25,8 @@ interface Props {
|
|||||||
onStreamQuality: (q: Quality) => void;
|
onStreamQuality: (q: Quality) => void;
|
||||||
subLang: string;
|
subLang: string;
|
||||||
onSubLang: (l: string) => void;
|
onSubLang: (l: string) => void;
|
||||||
|
browser: string;
|
||||||
|
onBrowser: (b: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,11 +51,40 @@ function StatusRow({ label, value }: { label: string; value: string | null }) {
|
|||||||
|
|
||||||
export default function Settings({
|
export default function Settings({
|
||||||
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
onClose, onImported, appearance, onAppearance, quality, onQuality,
|
||||||
streamQuality, onStreamQuality, subLang, onSubLang, onError,
|
streamQuality, onStreamQuality, subLang, onSubLang, browser, onBrowser, onError,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
const [prereqs, setPrereqs] = useState<Prereqs | null>(null);
|
||||||
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
const [pending, setPending] = useState<{ path: string; preview: ImportPreview } | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
|
||||||
|
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
listBrowsers().then(setBrowsers).catch(() => setBrowsers([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const chooseBrowser = async (id: string) => {
|
||||||
|
onBrowser(id);
|
||||||
|
setCheck(null);
|
||||||
|
try {
|
||||||
|
await setCookieSource(id);
|
||||||
|
} catch (e) {
|
||||||
|
onError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCheck = async () => {
|
||||||
|
setChecking(true);
|
||||||
|
setCheck(null);
|
||||||
|
try {
|
||||||
|
setCheck({ ok: true, message: await testYoutube() });
|
||||||
|
} catch (e) {
|
||||||
|
setCheck({ ok: false, message: String(e) });
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
|
const load = () => checkPrereqs().then(setPrereqs).catch(() => setPrereqs(null));
|
||||||
useEffect(() => { load(); }, []);
|
useEffect(() => { load(); }, []);
|
||||||
@@ -208,6 +241,48 @@ export default function Settings({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
|
<SectionHeading>Sign in to YouTube</SectionHeading>
|
||||||
|
<p className={`mt-1.5 ${HELP}`}>
|
||||||
|
YouTube sometimes asks a machine to prove it is not a bot, and then
|
||||||
|
nothing will stream or download. Pointing the app at a browser you are
|
||||||
|
already signed into clears that. The cookies are read on this Mac and
|
||||||
|
sent only to YouTube.
|
||||||
|
</p>
|
||||||
|
<label className="mt-2 grid grid-cols-[92px_1fr] items-center gap-2">
|
||||||
|
<span className={LABEL}>Use cookies</span>
|
||||||
|
<select
|
||||||
|
value={browser}
|
||||||
|
onChange={(e) => void chooseBrowser(e.target.value)}
|
||||||
|
className={SELECT}
|
||||||
|
>
|
||||||
|
<option value="">Not signed in</option>
|
||||||
|
{browsers.map(([id, label]) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<button onClick={runCheck} disabled={checking} className={`${BTN} cursor-pointer`}>
|
||||||
|
{checking ? "Checking…" : "Check connection"}
|
||||||
|
</button>
|
||||||
|
{check && (
|
||||||
|
<span
|
||||||
|
className={`text-[11px] leading-snug ${
|
||||||
|
check.ok
|
||||||
|
? "text-slate-500 dark:text-slate-400"
|
||||||
|
: "text-red-600 dark:text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{check.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
<SectionHeading>Appearance</SectionHeading>
|
<SectionHeading>Appearance</SectionHeading>
|
||||||
<div className="mt-2 flex items-center justify-between gap-3">
|
<div className="mt-2 flex items-center justify-between gap-3">
|
||||||
|
|||||||
Reference in New Issue
Block a user