feat: channel failures, updater, and a tidier Settings
Failing channels are visible instead of buried in a toast count. Each refresh records its outcome per channel, the sidebar marks the failures and counts them in its heading, and selecting one explains why above its videos. Yours turn out to be three channels returning HTTP 404 — removed or renamed on YouTube. Video lengths now fill for what is on screen. Filling the newest across all subscriptions meant a channel's videos stayed blank forever, since the global newest always won the queue. yt-dlp can be updated from Settings, which also says whether it is current. It breaks whenever YouTube changes something, so it lands in app data and takes precedence over the bundled copy; ffmpeg is stable and ships with each release, so it is shown but not updated. Also: 'Downloaded only' is now 'Local'; Hide Shorts moved to Settings; the seven-step Takeout guide moved behind a button, since it dominated the panel; the player names the height it is actually streaming, which changes as an adaptive stream switches rendition; the player's delete is an icon; and the window minimum drops to 1080 now that the control row has one fewer button, while still never wrapping.
This commit is contained in:
+139
-18
@@ -44,8 +44,9 @@ pub struct AppState {
|
|||||||
/// URLs last hours, so replaying a video should not pay for yt-dlp again.
|
/// URLs last hours, so replaying a video should not pay for yt-dlp again.
|
||||||
pub streams: Arc<Mutex<HashMap<(String, Option<u32>), (String, std::time::Instant)>>>,
|
pub streams: Arc<Mutex<HashMap<(String, Option<u32>), (String, std::time::Instant)>>>,
|
||||||
/// 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. Mutable so an in-app update
|
||||||
pub yt_dlp: Vec<String>,
|
/// takes effect without a restart.
|
||||||
|
pub yt_dlp_argv: Arc<Mutex<Vec<String>>>,
|
||||||
/// Value for yt-dlp's --cookies-from-browser, when signed in.
|
/// Value for yt-dlp's --cookies-from-browser, when signed in.
|
||||||
pub cookies_from: Arc<Mutex<Option<String>>>,
|
pub cookies_from: Arc<Mutex<Option<String>>>,
|
||||||
}
|
}
|
||||||
@@ -53,8 +54,9 @@ pub struct AppState {
|
|||||||
impl AppState {
|
impl AppState {
|
||||||
/// A ready-to-configure yt-dlp process, carrying cookies when configured.
|
/// A ready-to-configure yt-dlp process, carrying cookies when configured.
|
||||||
async 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 argv = self.yt_dlp_argv.lock().await.clone();
|
||||||
cmd.args(&self.yt_dlp[1..]);
|
let mut cmd = tokio::process::Command::new(&argv[0]);
|
||||||
|
cmd.args(&argv[1..]);
|
||||||
if let Some(from) = self.cookies_from.lock().await.clone() {
|
if let Some(from) = self.cookies_from.lock().await.clone() {
|
||||||
cmd.arg("--cookies-from-browser").arg(from);
|
cmd.arg("--cookies-from-browser").arg(from);
|
||||||
}
|
}
|
||||||
@@ -252,7 +254,7 @@ fn bin(name: &str) -> String {
|
|||||||
/// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp
|
/// the app runs its own interpreter against the yt-dlp zipapp. The 3MB zipapp
|
||||||
/// plus a portable Python starts in about half a second; the official
|
/// plus a portable Python starts in about half a second; the official
|
||||||
/// PyInstaller binary took eight, because it unpacks 37MB on every call.
|
/// PyInstaller binary took eight, because it unpacks 37MB on every call.
|
||||||
fn resolve_yt_dlp(app: &AppHandle) -> Vec<String> {
|
fn resolve_yt_dlp(app: &AppHandle, app_data: &std::path::Path) -> Vec<String> {
|
||||||
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
|
for prefix in ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"] {
|
||||||
let candidate = format!("{prefix}/yt-dlp");
|
let candidate = format!("{prefix}/yt-dlp");
|
||||||
if std::path::Path::new(&candidate).exists() {
|
if std::path::Path::new(&candidate).exists() {
|
||||||
@@ -263,7 +265,9 @@ fn resolve_yt_dlp(app: &AppHandle) -> Vec<String> {
|
|||||||
// python3 and python are symlinks; name the real file so the bundle
|
// python3 and python are symlinks; name the real file so the bundle
|
||||||
// does not depend on symlinks surviving the copy.
|
// does not depend on symlinks surviving the copy.
|
||||||
let python = res.join("python/bin/python3.12");
|
let python = res.join("python/bin/python3.12");
|
||||||
let zipapp = res.join("yt-dlp.pyz");
|
// An in-app update lands in app data; the bundle is read-only.
|
||||||
|
let updated = updated_yt_dlp(app_data);
|
||||||
|
let zipapp = if updated.exists() { updated } else { res.join("yt-dlp.pyz") };
|
||||||
if python.exists() && zipapp.exists() {
|
if python.exists() && zipapp.exists() {
|
||||||
return vec![
|
return vec![
|
||||||
python.to_string_lossy().to_string(),
|
python.to_string_lossy().to_string(),
|
||||||
@@ -326,10 +330,19 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
|
|||||||
}
|
}
|
||||||
|
|
||||||
let fresh = Prereqs {
|
let fresh = Prereqs {
|
||||||
yt_dlp: version_of(&state.yt_dlp, "--version").await.map(|v| {
|
yt_dlp: {
|
||||||
let origin = if yt_dlp_is_bundled(&state.yt_dlp) { "bundled" } else { "system" };
|
let argv = state.yt_dlp_argv.lock().await.clone();
|
||||||
|
version_of(&argv, "--version").await.map(|v| {
|
||||||
|
let origin = if !yt_dlp_is_bundled(&argv) {
|
||||||
|
"system"
|
||||||
|
} else if argv.last().map(|p| p.contains("/bin/")).unwrap_or(false) {
|
||||||
|
"updated"
|
||||||
|
} else {
|
||||||
|
"bundled"
|
||||||
|
};
|
||||||
format!("{v} ({origin})")
|
format!("{v} ({origin})")
|
||||||
}),
|
})
|
||||||
|
},
|
||||||
ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| {
|
ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| {
|
||||||
// ffmpeg's first line is long; keep the useful head of it.
|
// ffmpeg's first line is long; keep the useful head of it.
|
||||||
let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
|
let head = v.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
|
||||||
@@ -729,13 +742,23 @@ const DURATION_CONCURRENCY: usize = 1;
|
|||||||
///
|
///
|
||||||
/// yt-dlp would cost seconds per video; a ranged GET of the watch page costs
|
/// yt-dlp would cost seconds per video; a ranged GET of the watch page costs
|
||||||
/// about one, and only ever runs for videos whose length is still unknown.
|
/// about one, and only ever runs for videos whose length is still unknown.
|
||||||
|
///
|
||||||
|
/// `visible` is what the user is actually looking at. Filling globally by date
|
||||||
|
/// instead left most of a channel's videos blank forever, because the newest
|
||||||
|
/// few across all subscriptions always won the queue.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn fetch_durations(state: State<'_, AppState>) -> Result<usize, String> {
|
pub async fn fetch_durations(
|
||||||
let ids = state
|
visible: Vec<String>,
|
||||||
.db
|
state: State<'_, AppState>,
|
||||||
.lock()
|
) -> Result<usize, String> {
|
||||||
.await
|
let ids = {
|
||||||
.videos_missing_duration(DURATION_BATCH as i64)?;
|
let db = state.db.lock().await;
|
||||||
|
if visible.is_empty() {
|
||||||
|
db.videos_missing_duration(DURATION_BATCH as i64)?
|
||||||
|
} else {
|
||||||
|
db.filter_missing_duration(&visible, DURATION_BATCH as i64)?
|
||||||
|
}
|
||||||
|
};
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
@@ -793,6 +816,97 @@ pub fn parse_length_seconds(body: &str) -> Option<i64> {
|
|||||||
rest[..end].parse().ok().filter(|n| *n > 0)
|
rest[..end].parse().ok().filter(|n| *n > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where an updated yt-dlp is kept. The bundle is read-only, so a newer copy
|
||||||
|
/// lives in app data and takes precedence over the shipped one.
|
||||||
|
fn updated_yt_dlp(app_data: &std::path::Path) -> PathBuf {
|
||||||
|
app_data.join("bin").join("yt-dlp.pyz")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct UpdateStatus {
|
||||||
|
pub current: Option<String>,
|
||||||
|
pub latest: Option<String>,
|
||||||
|
pub up_to_date: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks GitHub what the newest yt-dlp release is.
|
||||||
|
///
|
||||||
|
/// Only yt-dlp is checked. It breaks whenever YouTube changes something, so
|
||||||
|
/// staying current matters; ffmpeg is stable and ships with the app.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_yt_dlp_update(state: State<'_, AppState>) -> Result<UpdateStatus, String> {
|
||||||
|
let current = version_of(&state.yt_dlp_argv.lock().await.clone(), "--version").await;
|
||||||
|
|
||||||
|
let latest = state
|
||||||
|
.http
|
||||||
|
.get("https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest")
|
||||||
|
.header(reqwest::header::ACCEPT, "application/vnd.github+json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Could not reach GitHub: {e}"))?
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Unexpected reply from GitHub: {e}"))?;
|
||||||
|
let latest = serde_json::from_str::<serde_json::Value>(&latest)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.get("tag_name").and_then(|t| t.as_str()).map(str::to_string));
|
||||||
|
|
||||||
|
let up_to_date = match (¤t, &latest) {
|
||||||
|
(Some(c), Some(l)) => c.trim() == l.trim(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
Ok(UpdateStatus { current, latest, up_to_date })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Downloads the newest yt-dlp zipapp into app data and switches to it.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_yt_dlp(state: State<'_, AppState>) -> Result<String, String> {
|
||||||
|
let dest = updated_yt_dlp(&state.app_data);
|
||||||
|
if let Some(dir) = dest.parent() {
|
||||||
|
tokio::fs::create_dir_all(dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = state
|
||||||
|
.http
|
||||||
|
.get("https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Download failed: {e}"))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Download failed: {e}"))?;
|
||||||
|
|
||||||
|
if bytes.len() < 1_000_000 {
|
||||||
|
return Err("That download does not look like yt-dlp; leaving the current one in place.".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write beside the target then rename, so a failure never leaves a
|
||||||
|
// half-written interpreter in place of a working one.
|
||||||
|
let tmp = dest.with_extension("pyz.part");
|
||||||
|
tokio::fs::write(&tmp, &bytes)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot write update: {e}"))?;
|
||||||
|
tokio::fs::rename(&tmp, &dest)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Cannot install update: {e}"))?;
|
||||||
|
|
||||||
|
// Point at the new copy without a restart.
|
||||||
|
let mut argv = state.yt_dlp_argv.lock().await;
|
||||||
|
if argv.len() > 1 {
|
||||||
|
let last = argv.len() - 1;
|
||||||
|
argv[last] = dest.to_string_lossy().to_string();
|
||||||
|
}
|
||||||
|
let probe = argv.clone();
|
||||||
|
drop(argv);
|
||||||
|
|
||||||
|
*state.prereqs.lock().await = None;
|
||||||
|
version_of(&probe, "--version")
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| "The update was installed but will not run.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
|
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
|
||||||
state.db.lock().await.list_channels()
|
state.db.lock().await.list_channels()
|
||||||
@@ -844,12 +958,17 @@ pub async fn refresh_feeds(
|
|||||||
done += 1;
|
done += 1;
|
||||||
match res {
|
match res {
|
||||||
Ok(videos) => {
|
Ok(videos) => {
|
||||||
if !videos.is_empty() {
|
|
||||||
let mut db = state.db.lock().await;
|
let mut db = state.db.lock().await;
|
||||||
|
if !videos.is_empty() {
|
||||||
new_videos += db.upsert_videos(&videos)?;
|
new_videos += db.upsert_videos(&videos)?;
|
||||||
}
|
}
|
||||||
|
// A channel that recovers should stop being flagged.
|
||||||
|
db.set_channel_result(&cid, None)?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
state.db.lock().await.set_channel_result(&cid, Some(&e))?;
|
||||||
|
failures.push(format!("{cid}: {e}"));
|
||||||
}
|
}
|
||||||
Err(e) => failures.push(format!("{cid}: {e}")),
|
|
||||||
}
|
}
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"refresh:progress",
|
"refresh:progress",
|
||||||
@@ -1331,6 +1450,8 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
.build()
|
.build()
|
||||||
.map_err(|e| format!("Cannot build HTTP client: {e}"))?;
|
.map_err(|e| format!("Cannot build HTTP client: {e}"))?;
|
||||||
|
|
||||||
|
let yt_dlp_argv = resolve_yt_dlp(app, &app_data);
|
||||||
|
|
||||||
Ok(AppState {
|
Ok(AppState {
|
||||||
db: Arc::new(Mutex::new(db)),
|
db: Arc::new(Mutex::new(db)),
|
||||||
http,
|
http,
|
||||||
@@ -1341,7 +1462,7 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
|
|||||||
playlists: PlaylistServer::start()?,
|
playlists: PlaylistServer::start()?,
|
||||||
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_argv: Arc::new(Mutex::new(yt_dlp_argv)),
|
||||||
cookies_from: Arc::new(Mutex::new(None)),
|
cookies_from: Arc::new(Mutex::new(None)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-1
@@ -90,9 +90,22 @@ impl Db {
|
|||||||
// columns need adding explicitly. The error when it already exists is
|
// columns need adding explicitly. The error when it already exists is
|
||||||
// the expected case, not a failure.
|
// the expected case, not a failure.
|
||||||
let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []);
|
let _ = conn.execute("ALTER TABLE videos ADD COLUMN duration INTEGER", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_error TEXT", []);
|
||||||
|
let _ = conn.execute("ALTER TABLE channels ADD COLUMN last_checked INTEGER", []);
|
||||||
Ok(Db { conn })
|
Ok(Db { conn })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notes how a channel's last refresh went. `error` of None clears it.
|
||||||
|
pub fn set_channel_result(&self, id: &str, error: Option<&str>) -> Result<(), String> {
|
||||||
|
self.conn
|
||||||
|
.execute(
|
||||||
|
"UPDATE channels SET last_error = ?2, last_checked = ?3 WHERE id = ?1",
|
||||||
|
params![id, error, now()],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Records a video's length in seconds.
|
/// Records a video's length in seconds.
|
||||||
pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> {
|
pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> {
|
||||||
if seconds <= 0 {
|
if seconds <= 0 {
|
||||||
@@ -107,6 +120,32 @@ impl Db {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Of the given videos, those whose length is still unknown, in the order
|
||||||
|
/// they were given so what is on screen first is filled first.
|
||||||
|
pub fn filter_missing_duration(
|
||||||
|
&self,
|
||||||
|
ids: &[String],
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let mut stmt = self
|
||||||
|
.conn
|
||||||
|
.prepare("SELECT duration FROM videos WHERE id = ?1")
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
if out.len() as i64 >= limit {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let known: Option<Option<i64>> = stmt
|
||||||
|
.query_row(params![id], |r| r.get::<_, Option<i64>>(0))
|
||||||
|
.ok();
|
||||||
|
if matches!(known, Some(None)) {
|
||||||
|
out.push(id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
/// Videos whose length is still unknown, newest first.
|
/// Videos whose length is still unknown, newest first.
|
||||||
pub fn videos_missing_duration(&self, limit: i64) -> Result<Vec<String>, String> {
|
pub fn videos_missing_duration(&self, limit: i64) -> Result<Vec<String>, String> {
|
||||||
let mut stmt = self
|
let mut stmt = self
|
||||||
@@ -275,7 +314,8 @@ impl Db {
|
|||||||
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id),
|
(SELECT COUNT(*) FROM videos v WHERE v.channel_id = c.id),
|
||||||
(SELECT COUNT(*) FROM videos v
|
(SELECT COUNT(*) FROM videos v
|
||||||
JOIN downloads d ON d.video_id = v.id
|
JOIN downloads d ON d.video_id = v.id
|
||||||
WHERE v.channel_id = c.id AND d.state = 'done')
|
WHERE v.channel_id = c.id AND d.state = 'done'),
|
||||||
|
c.last_error
|
||||||
FROM channels c
|
FROM channels c
|
||||||
ORDER BY c.title COLLATE NOCASE ASC",
|
ORDER BY c.title COLLATE NOCASE ASC",
|
||||||
)
|
)
|
||||||
@@ -289,6 +329,7 @@ impl Db {
|
|||||||
url: r.get(2)?,
|
url: r.get(2)?,
|
||||||
video_count: r.get(3)?,
|
video_count: r.get(3)?,
|
||||||
downloaded_count: r.get(4)?,
|
downloaded_count: r.get(4)?,
|
||||||
|
last_error: r.get(5)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@@ -793,6 +834,44 @@ mod tests {
|
|||||||
assert_eq!(feed[0].id, "b");
|
assert_eq!(feed[0].id, "b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_the_visible_videos_without_a_length_are_queued() {
|
||||||
|
let db = seeded();
|
||||||
|
db.set_duration("b", 120).unwrap();
|
||||||
|
// Order follows what was asked for, so the top of the screen fills first.
|
||||||
|
let want = vec!["c".to_string(), "b".to_string(), "a".to_string()];
|
||||||
|
assert_eq!(
|
||||||
|
db.filter_missing_duration(&want, 10).unwrap(),
|
||||||
|
vec!["c".to_string(), "a".to_string()]
|
||||||
|
);
|
||||||
|
// Unknown ids are simply skipped rather than queued forever.
|
||||||
|
assert!(db
|
||||||
|
.filter_missing_duration(&["nope".to_string()], 10)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty());
|
||||||
|
assert_eq!(db.filter_missing_duration(&want, 1).unwrap().len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_channel_failure_is_remembered_and_can_be_cleared() {
|
||||||
|
let db = seeded();
|
||||||
|
assert!(db.list_channels().unwrap().iter().all(|c| c.last_error.is_none()));
|
||||||
|
|
||||||
|
db.set_channel_result("UC1", Some("Feed returned HTTP 404")).unwrap();
|
||||||
|
let failed = db.list_channels().unwrap();
|
||||||
|
let alpha = failed.iter().find(|c| c.id == "UC1").unwrap();
|
||||||
|
assert_eq!(alpha.last_error.as_deref(), Some("Feed returned HTTP 404"));
|
||||||
|
// Other channels are untouched.
|
||||||
|
assert!(failed.iter().find(|c| c.id == "UC2").unwrap().last_error.is_none());
|
||||||
|
|
||||||
|
db.set_channel_result("UC1", None).unwrap();
|
||||||
|
assert!(db
|
||||||
|
.list_channels()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.all(|c| c.last_error.is_none()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_recorded_duration_reaches_the_feed() {
|
fn a_recorded_duration_reaches_the_feed() {
|
||||||
let db = seeded();
|
let db = seeded();
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
|
|||||||
commands::list_browsers,
|
commands::list_browsers,
|
||||||
commands::set_cookie_source,
|
commands::set_cookie_source,
|
||||||
commands::test_youtube,
|
commands::test_youtube,
|
||||||
|
commands::check_yt_dlp_update,
|
||||||
|
commands::update_yt_dlp,
|
||||||
commands::get_connectivity,
|
commands::get_connectivity,
|
||||||
commands::set_library_path,
|
commands::set_library_path,
|
||||||
commands::open_external,
|
commands::open_external,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ pub struct ChannelWithCount {
|
|||||||
pub url: String,
|
pub url: String,
|
||||||
pub video_count: i64,
|
pub video_count: i64,
|
||||||
pub downloaded_count: i64,
|
pub downloaded_count: i64,
|
||||||
|
/// Why the last refresh of this channel failed, if it did.
|
||||||
|
pub last_error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"title": "FlightTube",
|
"title": "FlightTube",
|
||||||
"width": 1320,
|
"width": 1320,
|
||||||
"height": 820,
|
"height": 820,
|
||||||
"minWidth": 1260,
|
"minWidth": 1080,
|
||||||
"minHeight": 620,
|
"minHeight": 620,
|
||||||
"center": true,
|
"center": true,
|
||||||
"titleBarStyle": "Overlay",
|
"titleBarStyle": "Overlay",
|
||||||
|
|||||||
+36
-7
@@ -24,11 +24,12 @@ const TOAST_MS = 2400;
|
|||||||
/** How often to pull new videos while online, so the feed stays live. */
|
/** How often to pull new videos while online, so the feed stays live. */
|
||||||
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
const AUTO_REFRESH_MS = 10 * 60 * 1000;
|
||||||
/**
|
/**
|
||||||
* Video lengths trickle in. This was once every 12s and it got the whole IP
|
* Video lengths trickle in, four at a time, for whatever is on screen. An
|
||||||
* challenged by YouTube, which broke playback and downloads too — the feed
|
* early version fetched two pages a second across the whole feed and got the
|
||||||
* being fully annotated is not worth that.
|
* IP challenged by YouTube, breaking playback and downloads too — so this stays
|
||||||
|
* slow on purpose.
|
||||||
*/
|
*/
|
||||||
const DURATION_FILL_MS = 5 * 60 * 1000;
|
const DURATION_FILL_MS = 30 * 1000;
|
||||||
|
|
||||||
function remembered(key: string): boolean {
|
function remembered(key: string): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -241,6 +242,14 @@ export default function App() {
|
|||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [online, refreshing, playingIndex, doRefresh]);
|
}, [online, refreshing, playingIndex, doRefresh]);
|
||||||
|
|
||||||
|
// Ids on screen still lacking a length, newest first. Joined into a string
|
||||||
|
// so the effect below only re-runs when the set actually changes.
|
||||||
|
const missingDurations = useMemo(
|
||||||
|
() => items.filter((i) => i.duration == null).slice(0, 40).map((i) => i.id),
|
||||||
|
[items],
|
||||||
|
);
|
||||||
|
const missingKey = missingDurations.join(",");
|
||||||
|
|
||||||
// The Atom feed carries no duration, so lengths are looked up a batch at a
|
// The Atom feed carries no duration, so lengths are looked up a batch at a
|
||||||
// time in the background and cached. Paused while the player is open.
|
// time in the background and cached. Paused while the player is open.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -252,7 +261,7 @@ export default function App() {
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (stop || refused || playingIndex != null) return;
|
if (stop || refused || playingIndex != null) return;
|
||||||
try {
|
try {
|
||||||
if ((await fetchDurations()) > 0 && !stop) await reload();
|
if ((await fetchDurations(missingDurations)) > 0 && !stop) await reload();
|
||||||
} catch {
|
} catch {
|
||||||
refused = true;
|
refused = true;
|
||||||
}
|
}
|
||||||
@@ -263,7 +272,10 @@ export default function App() {
|
|||||||
stop = true;
|
stop = true;
|
||||||
clearInterval(id);
|
clearInterval(id);
|
||||||
};
|
};
|
||||||
}, [online, playingIndex, reload]);
|
// Re-runs when the visible set changes, so switching channel fills that
|
||||||
|
// channel rather than whatever is newest overall.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [online, playingIndex, missingKey]);
|
||||||
|
|
||||||
// Refresh once on launch, as soon as there is a connection and something to
|
// Refresh once on launch, as soon as there is a connection and something to
|
||||||
// refresh, so the feed is current without anyone pressing anything.
|
// refresh, so the feed is current without anyone pressing anything.
|
||||||
@@ -288,6 +300,11 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [items, clearLive, reload, say]);
|
}, [items, clearLive, reload, say]);
|
||||||
|
|
||||||
|
const activeChannelError =
|
||||||
|
channelId == null
|
||||||
|
? null
|
||||||
|
: (channels.find((c) => c.id === channelId)?.last_error ?? null);
|
||||||
|
|
||||||
const emptyMessage = () => {
|
const emptyMessage = () => {
|
||||||
if (loading) return "Loading…";
|
if (loading) return "Loading…";
|
||||||
if (channels.length === 0)
|
if (channels.length === 0)
|
||||||
@@ -352,7 +369,6 @@ export default function App() {
|
|||||||
<TopBar
|
<TopBar
|
||||||
search={search} onSearch={setSearch}
|
search={search} onSearch={setSearch}
|
||||||
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
|
downloadedOnly={effectiveDownloadedOnly} onDownloadedOnly={setDownloadedOnly}
|
||||||
hideShorts={hideShorts} onHideShorts={setHideShorts}
|
|
||||||
online={online} reachable={reachable} forcedOffline={forcedOffline}
|
online={online} reachable={reachable} forcedOffline={forcedOffline}
|
||||||
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
|
onToggleForcedOffline={() => { setForcedOffline(!forcedOffline); probe(); }}
|
||||||
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
onRefresh={doRefresh} refreshing={refreshing} refreshProgress={refreshProgress}
|
||||||
@@ -364,6 +380,17 @@ export default function App() {
|
|||||||
titleBarInset={titleBarInset}
|
titleBarInset={titleBarInset}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{activeChannelError && (
|
||||||
|
<div
|
||||||
|
className="border-b border-red-500/30 bg-red-500/10 px-4 py-2 text-[11px]
|
||||||
|
leading-snug text-red-700 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<b>This channel failed to refresh.</b>{" "}
|
||||||
|
{activeChannelError.replace(/\.?$/, ".")} Anything listed below is from the
|
||||||
|
last successful check.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{!online && (
|
{!online && (
|
||||||
<div
|
<div
|
||||||
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
|
className="border-b border-amber-500/30 bg-amber-500/10 px-4 py-2 text-[11px]
|
||||||
@@ -476,6 +503,8 @@ export default function App() {
|
|||||||
onStreamQuality={setStreamQuality}
|
onStreamQuality={setStreamQuality}
|
||||||
subLang={subLang}
|
subLang={subLang}
|
||||||
onSubLang={setSubLang}
|
onSubLang={setSubLang}
|
||||||
|
hideShorts={hideShorts}
|
||||||
|
onHideShorts={setHideShorts}
|
||||||
browser={browser}
|
browser={browser}
|
||||||
onBrowser={setBrowser}
|
onBrowser={setBrowser}
|
||||||
onError={setFailure}
|
onError={setFailure}
|
||||||
|
|||||||
+9
-1
@@ -13,6 +13,7 @@ import type {
|
|||||||
RefreshProgress,
|
RefreshProgress,
|
||||||
RefreshSummary,
|
RefreshSummary,
|
||||||
Stream,
|
Stream,
|
||||||
|
UpdateStatus,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
|
export const checkPrereqs = () => invoke<Prereqs>("check_prereqs");
|
||||||
@@ -30,7 +31,9 @@ export const downloadVideo = (videoId: string, quality: Quality, subLangs: strin
|
|||||||
export const deleteAllDownloads = () => invoke<number>("delete_all_downloads");
|
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");
|
/** Fills lengths for the videos on screen first. */
|
||||||
|
export const fetchDurations = (visible: string[]) =>
|
||||||
|
invoke<number>("fetch_durations", { visible });
|
||||||
|
|
||||||
/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */
|
/** Browsers installed here that yt-dlp can read cookies from: [id, label]. */
|
||||||
export const listBrowsers = () => invoke<Array<[string, string]>>("list_browsers");
|
export const listBrowsers = () => invoke<Array<[string, string]>>("list_browsers");
|
||||||
@@ -42,6 +45,11 @@ export const setCookieSource = (browser: string) =>
|
|||||||
/** Resolves a known video to check whether YouTube is currently reachable. */
|
/** Resolves a known video to check whether YouTube is currently reachable. */
|
||||||
export const testYoutube = () => invoke<string>("test_youtube");
|
export const testYoutube = () => invoke<string>("test_youtube");
|
||||||
|
|
||||||
|
export const checkYtDlpUpdate = () => invoke<UpdateStatus>("check_yt_dlp_update");
|
||||||
|
|
||||||
|
/** Downloads the newest yt-dlp and switches to it. Returns its version. */
|
||||||
|
export const updateYtDlp = () => invoke<string>("update_yt_dlp");
|
||||||
|
|
||||||
/** 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 });
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ export default function Player({
|
|||||||
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
const [src, setSrc] = useState<string | null>(path ? fileUrl(path) : null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [buffering, setBuffering] = useState(true);
|
const [buffering, setBuffering] = useState(true);
|
||||||
|
// The height actually being decoded. With an adaptive stream this changes as
|
||||||
|
// the player switches rendition, so it is read from the element rather than
|
||||||
|
// assumed from the setting.
|
||||||
|
const [height, setHeight] = useState(0);
|
||||||
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
|
// WebVTT files yt-dlp saved next to a download, so subtitles work offline.
|
||||||
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
|
const [sidecars, setSidecars] = useState<Array<[string, string]>>([]);
|
||||||
|
|
||||||
@@ -264,7 +268,11 @@ export default function Player({
|
|||||||
|
|
||||||
{streaming && (
|
{streaming && (
|
||||||
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
{error ? "Unavailable" : src && !buffering ? "Streaming" : "Loading…"}
|
{error
|
||||||
|
? "Unavailable"
|
||||||
|
: src && !buffering
|
||||||
|
? `Streaming${height ? ` · ${height}p` : ""}`
|
||||||
|
: "Loading…"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
@@ -330,6 +338,8 @@ export default function Player({
|
|||||||
onCanPlay={() => setBuffering(false)}
|
onCanPlay={() => setBuffering(false)}
|
||||||
onPlaying={() => setBuffering(false)}
|
onPlaying={() => setBuffering(false)}
|
||||||
onSeeked={() => setBuffering(false)}
|
onSeeked={() => setBuffering(false)}
|
||||||
|
onResize={() => setHeight(videoRef.current?.videoHeight ?? 0)}
|
||||||
|
onLoadedData={() => setHeight(videoRef.current?.videoHeight ?? 0)}
|
||||||
className="absolute inset-0 size-full object-contain"
|
className="absolute inset-0 size-full object-contain"
|
||||||
>
|
>
|
||||||
{sidecars.map(([lang, file]) => (
|
{sidecars.map(([lang, file]) => (
|
||||||
@@ -409,10 +419,14 @@ export default function Player({
|
|||||||
<button
|
<button
|
||||||
onClick={onDelete}
|
onClick={onDelete}
|
||||||
title="Delete this download"
|
title="Delete this download"
|
||||||
className={`${navBtn} whitespace-nowrap hover:border-red-500! hover:text-red-600!
|
aria-label="Delete download"
|
||||||
|
className={`${navIcon} hover:border-red-500! hover:text-red-600!
|
||||||
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
dark:hover:border-red-500! dark:hover:text-red-400!`}
|
||||||
>
|
>
|
||||||
Delete
|
<svg viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round"
|
||||||
|
d="M4 7h16M9 7V5h6v2M6 7l1 13h10l1-13M10 11v6M14 11v6" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|||||||
+91
-25
@@ -1,17 +1,17 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
checkPrereqs, importTakeoutCsv, listBrowsers, pickLibraryFolder, pickTakeoutFile,
|
checkPrereqs, checkYtDlpUpdate, importTakeoutCsv, listBrowsers, pickLibraryFolder,
|
||||||
previewTakeoutImport, setCookieSource, testYoutube,
|
pickTakeoutFile, previewTakeoutImport, setCookieSource, testYoutube, updateYtDlp,
|
||||||
} from "../api";
|
} from "../api";
|
||||||
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
import { APPEARANCE_MODES, type Appearance } from "../hooks/useAppearance";
|
||||||
import {
|
import {
|
||||||
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
QUALITIES, STREAM_QUALITIES, SUB_LANGS,
|
||||||
type ImportPreview, type Prereqs, type Quality,
|
type ImportPreview, type Prereqs, type Quality, type UpdateStatus,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import TakeoutGuide from "./TakeoutGuide";
|
import TakeoutGuide from "./TakeoutGuide";
|
||||||
import {
|
import {
|
||||||
BTN, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented,
|
BTN, BTN_PRIMARY, CONTROL_H, Dialog, HELP, ICON_BTN, LABEL, SectionHeading, Segmented,
|
||||||
SUBPANEL,
|
|
||||||
} from "./ui";
|
} from "./ui";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -25,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;
|
||||||
|
hideShorts: boolean;
|
||||||
|
onHideShorts: (v: boolean) => void;
|
||||||
browser: string;
|
browser: string;
|
||||||
onBrowser: (b: string) => void;
|
onBrowser: (b: string) => void;
|
||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
@@ -51,7 +53,8 @@ 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, browser, onBrowser, onError,
|
streamQuality, onStreamQuality, subLang, onSubLang, hideShorts, onHideShorts,
|
||||||
|
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);
|
||||||
@@ -59,6 +62,26 @@ export default function Settings({
|
|||||||
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
|
const [browsers, setBrowsers] = useState<Array<[string, string]>>([]);
|
||||||
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
|
const [check, setCheck] = useState<{ ok: boolean; message: string } | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [guide, setGuide] = useState(false);
|
||||||
|
const [update, setUpdate] = useState<UpdateStatus | null>(null);
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
checkYtDlpUpdate().then(setUpdate).catch(() => setUpdate(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const runUpdate = async () => {
|
||||||
|
setUpdating(true);
|
||||||
|
try {
|
||||||
|
const version = await updateYtDlp();
|
||||||
|
setUpdate({ current: version, latest: update?.latest ?? version, up_to_date: true });
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
onError(String(e));
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
listBrowsers().then(setBrowsers).catch(() => setBrowsers([]));
|
listBrowsers().then(setBrowsers).catch(() => setBrowsers([]));
|
||||||
@@ -156,26 +179,20 @@ export default function Settings({
|
|||||||
|
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||||
<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>Get your subscriptions</SectionHeading>
|
<SectionHeading>Subscriptions</SectionHeading>
|
||||||
<p className={`mt-1.5 ${HELP}`}>
|
<p className={`mt-1.5 ${HELP}`}>
|
||||||
YouTube has no public API for someone else's subscription list, so FlightTube
|
Importing <b>replaces</b> your current list — the CSV becomes the whole truth.
|
||||||
reads the export Google gives you. It takes about two minutes.
|
Channels no longer in it are removed along with their videos and downloads.
|
||||||
|
You'll see exactly what goes before anything is deleted.
|
||||||
</p>
|
</p>
|
||||||
<div className={`mt-3 ${SUBPANEL}`}>
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<TakeoutGuide />
|
<button onClick={startImport} className={`${BTN_PRIMARY} cursor-pointer`}>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
|
||||||
<SectionHeading>Import</SectionHeading>
|
|
||||||
<p className={`mt-1.5 ${HELP}`}>
|
|
||||||
Importing <b>replaces</b> your current subscription list — the CSV becomes the
|
|
||||||
whole truth. Channels no longer in it are removed along with their videos and
|
|
||||||
downloads. You'll see exactly what goes before anything is deleted.
|
|
||||||
</p>
|
|
||||||
<button onClick={startImport} className={`${BTN_PRIMARY} mt-3 cursor-pointer`}>
|
|
||||||
Import subscriptions.csv
|
Import subscriptions.csv
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={() => setGuide(true)} className={`${BTN} cursor-pointer`}>
|
||||||
|
How do I get the file?
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</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">
|
||||||
@@ -241,6 +258,22 @@ export default function Settings({
|
|||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="border-b border-slate-200 px-5 py-4 dark:border-slate-800">
|
||||||
|
<SectionHeading>Feed</SectionHeading>
|
||||||
|
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3">
|
||||||
|
<span className={LABEL}>Hide Shorts</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideShorts}
|
||||||
|
onChange={(e) => onHideShorts(e.target.checked)}
|
||||||
|
className="size-4 cursor-pointer accent-sky-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className={`mt-2 ${HELP}`}>
|
||||||
|
Keeps YouTube Shorts out of the feed entirely.
|
||||||
|
</p>
|
||||||
|
</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>Sign in to YouTube</SectionHeading>
|
<SectionHeading>Sign in to YouTube</SectionHeading>
|
||||||
<p className={`mt-1.5 ${HELP}`}>
|
<p className={`mt-1.5 ${HELP}`}>
|
||||||
@@ -301,7 +334,29 @@ export default function Settings({
|
|||||||
<section className="px-5 py-4">
|
<section className="px-5 py-4">
|
||||||
<SectionHeading>Status</SectionHeading>
|
<SectionHeading>Status</SectionHeading>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<StatusRow label="yt-dlp" value={prereqs?.yt_dlp ?? null} />
|
<div className="flex items-start justify-between gap-4 border-b border-slate-200 py-2 dark:border-slate-800">
|
||||||
|
<span className={LABEL}>yt-dlp</span>
|
||||||
|
<span className="flex items-center gap-2 text-right">
|
||||||
|
<span className="text-[12px] text-slate-600 dark:text-slate-300">
|
||||||
|
{prereqs?.yt_dlp ?? "Not found"}
|
||||||
|
</span>
|
||||||
|
{update && !update.up_to_date && update.latest && (
|
||||||
|
<button
|
||||||
|
onClick={runUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
title={`Update to ${update.latest}`}
|
||||||
|
className={`${BTN} cursor-pointer`}
|
||||||
|
>
|
||||||
|
{updating ? "Updating…" : `Update to ${update.latest}`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{update?.up_to_date && (
|
||||||
|
<span className="text-[11px] text-slate-400 dark:text-slate-500">
|
||||||
|
up to date
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
|
<StatusRow label="ffmpeg" value={prereqs?.ffmpeg ?? null} />
|
||||||
<div className="flex items-start justify-between gap-4 py-2">
|
<div className="flex items-start justify-between gap-4 py-2">
|
||||||
<span className={LABEL}>Library</span>
|
<span className={LABEL}>Library</span>
|
||||||
@@ -316,9 +371,10 @@ export default function Settings({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={`mt-2 ${HELP}`}>
|
<p className={`mt-2 ${HELP}`}>
|
||||||
yt-dlp and ffmpeg ship inside the app, so nothing needs installing. A copy
|
Both ship inside the app, so nothing needs installing. yt-dlp breaks
|
||||||
on your system is used instead if one is present, which is how you can run
|
whenever YouTube changes something, so it can be updated here; ffmpeg is
|
||||||
a newer yt-dlp than the bundled one.
|
stable and comes with each release. A copy on your system takes precedence
|
||||||
|
over either.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{missing && (
|
{missing && (
|
||||||
@@ -335,6 +391,16 @@ export default function Settings({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{guide && (
|
||||||
|
<Dialog title="Getting your subscriptions" onCancel={() => setGuide(false)} wide>
|
||||||
|
<p className={`mb-3 ${HELP}`}>
|
||||||
|
YouTube has no public API for someone else's subscription list, so FlightTube
|
||||||
|
reads the export Google gives you. It takes about two minutes.
|
||||||
|
</p>
|
||||||
|
<TakeoutGuide />
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
|
|
||||||
{pending && p && (
|
{pending && p && (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
|
title={destructive ? "Replace your subscriptions?" : "Import subscriptions"}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export default function Sidebar({
|
|||||||
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
channels, activeChannel, onSelect, onOpenSettings, totalVideos, totalDownloaded,
|
||||||
onHide, floating, titleBarInset,
|
onHide, floating, titleBarInset,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const failing = channels.filter((c) => c.last_error).length;
|
||||||
|
|
||||||
const row =
|
const row =
|
||||||
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
|
"flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-2 py-1.5 " +
|
||||||
"text-[13px] transition-colors";
|
"text-[13px] transition-colors";
|
||||||
@@ -88,7 +90,17 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{channels.length > 0 && (
|
{channels.length > 0 && (
|
||||||
<h2 className={`${HEADING} px-2 pb-1 pt-4`}>Channels</h2>
|
<h2 className={`${HEADING} flex items-center justify-between px-2 pb-1 pt-4`}>
|
||||||
|
<span>Channels</span>
|
||||||
|
{failing > 0 && (
|
||||||
|
<span
|
||||||
|
title={`${failing} channel${failing === 1 ? "" : "s"} failed to refresh`}
|
||||||
|
className="font-mono text-[10px] normal-case tracking-normal text-red-500"
|
||||||
|
>
|
||||||
|
{failing} failing
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
@@ -99,7 +111,15 @@ export default function Sidebar({
|
|||||||
title={c.title}
|
title={c.title}
|
||||||
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
className={`${row} ${activeChannel === c.id ? active : inactive}`}
|
||||||
>
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-1.5">
|
||||||
|
{c.last_error && (
|
||||||
|
<span
|
||||||
|
title={`Last refresh failed: ${c.last_error}`}
|
||||||
|
className="size-1.5 shrink-0 rounded-full bg-red-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<span className="truncate">{c.title}</span>
|
<span className="truncate">{c.title}</span>
|
||||||
|
</span>
|
||||||
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
<span className="shrink-0 font-mono text-[11px] tabular-nums opacity-70">
|
||||||
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
{c.downloaded_count > 0 && `${c.downloaded_count}/`}
|
||||||
{c.video_count}
|
{c.video_count}
|
||||||
|
|||||||
@@ -87,11 +87,11 @@ const STEPS: Step[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Import it below",
|
title: "Import it",
|
||||||
body: (
|
body: (
|
||||||
<>
|
<>
|
||||||
Pick that <code>subscriptions.csv</code> with the Import button, then hit{" "}
|
Close this, then pick that <code>subscriptions.csv</code> with{" "}
|
||||||
<b>Refresh</b> to pull in each channel's latest videos.
|
<b>Import subscriptions.csv</b>. The feed refreshes itself afterwards.
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ interface Props {
|
|||||||
onSearch: (v: string) => void;
|
onSearch: (v: string) => void;
|
||||||
downloadedOnly: boolean;
|
downloadedOnly: boolean;
|
||||||
onDownloadedOnly: (v: boolean) => void;
|
onDownloadedOnly: (v: boolean) => void;
|
||||||
hideShorts: boolean;
|
|
||||||
onHideShorts: (v: boolean) => void;
|
|
||||||
online: boolean;
|
online: boolean;
|
||||||
reachable: boolean;
|
reachable: boolean;
|
||||||
forcedOffline: boolean;
|
forcedOffline: boolean;
|
||||||
@@ -57,7 +55,7 @@ function Toggle({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TopBar({
|
export default function TopBar({
|
||||||
search, onSearch, downloadedOnly, onDownloadedOnly, hideShorts, onHideShorts,
|
search, onSearch, downloadedOnly, onDownloadedOnly,
|
||||||
online, reachable, forcedOffline, onToggleForcedOffline,
|
online, reachable, forcedOffline, onToggleForcedOffline,
|
||||||
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
onRefresh, refreshing, refreshProgress, resultCount, view, onView,
|
||||||
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
sidebarHidden, onShowSidebar, onDeleteAll, titleBarInset,
|
||||||
@@ -92,7 +90,7 @@ export default function TopBar({
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => onSearch(e.target.value)}
|
onChange={(e) => onSearch(e.target.value)}
|
||||||
placeholder="Search videos and channels"
|
placeholder="Search videos and channels"
|
||||||
className={`${INPUT} min-w-[17rem] max-w-md flex-1`}
|
className={`${INPUT} min-w-[17rem] max-w-sm flex-1`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
<span className="shrink-0 font-mono text-[11px] tabular-nums text-slate-400 dark:text-slate-500">
|
||||||
@@ -103,8 +101,8 @@ export default function TopBar({
|
|||||||
|
|
||||||
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
|
<Toggle active={downloadedOnly} onClick={() => onDownloadedOnly(!downloadedOnly)}
|
||||||
disabled={!online}
|
disabled={!online}
|
||||||
title={online ? "Show only downloaded videos" : "Offline: showing downloads only"}>
|
title={online ? "Show only what is on this Mac" : "Offline: showing local videos only"}>
|
||||||
Downloaded only
|
Local
|
||||||
</Toggle>
|
</Toggle>
|
||||||
|
|
||||||
{downloadedOnly && onDeleteAll && (
|
{downloadedOnly && onDeleteAll && (
|
||||||
@@ -121,11 +119,6 @@ export default function TopBar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Toggle active={hideShorts} onClick={() => onHideShorts(!hideShorts)}
|
|
||||||
title="Hide Shorts from the feed">
|
|
||||||
Hide Shorts
|
|
||||||
</Toggle>
|
|
||||||
|
|
||||||
<Segmented
|
<Segmented
|
||||||
value={view}
|
value={view}
|
||||||
onChange={onView}
|
onChange={onView}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface ChannelWithCount {
|
|||||||
url: string;
|
url: string;
|
||||||
video_count: number;
|
video_count: number;
|
||||||
downloaded_count: number;
|
downloaded_count: number;
|
||||||
|
/** Why this channel's last refresh failed, if it did. */
|
||||||
|
last_error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeedItem {
|
export interface FeedItem {
|
||||||
@@ -122,3 +124,9 @@ export const SUB_LANGS: Array<{ value: string; label: string }> = [
|
|||||||
{ value: "it", label: "Italiano" },
|
{ value: "it", label: "Italiano" },
|
||||||
{ value: "pt", label: "Português" },
|
{ value: "pt", label: "Português" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export interface UpdateStatus {
|
||||||
|
current: string | null;
|
||||||
|
latest: string | null;
|
||||||
|
up_to_date: boolean;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user