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:
vincent
2026-08-29 15:17:15 +02:00
parent 923fd3273f
commit 48acaa359f
13 changed files with 416 additions and 74 deletions
+140 -19
View File
@@ -44,8 +44,9 @@ pub struct AppState {
/// 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)>>>,
/// argv prefix that runs yt-dlp: either a system binary, or the bundled
/// Python interpreter followed by the zipapp.
pub yt_dlp: Vec<String>,
/// Python interpreter followed by the zipapp. Mutable so an in-app update
/// takes effect without a restart.
pub yt_dlp_argv: Arc<Mutex<Vec<String>>>,
/// Value for yt-dlp's --cookies-from-browser, when signed in.
pub cookies_from: Arc<Mutex<Option<String>>>,
}
@@ -53,8 +54,9 @@ pub struct AppState {
impl AppState {
/// A ready-to-configure yt-dlp process, carrying cookies when configured.
async fn yt_dlp(&self) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(&self.yt_dlp[0]);
cmd.args(&self.yt_dlp[1..]);
let argv = self.yt_dlp_argv.lock().await.clone();
let mut cmd = tokio::process::Command::new(&argv[0]);
cmd.args(&argv[1..]);
if let Some(from) = self.cookies_from.lock().await.clone() {
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
/// plus a portable Python starts in about half a second; the official
/// 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"] {
let candidate = format!("{prefix}/yt-dlp");
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
// does not depend on symlinks surviving the copy.
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() {
return vec![
python.to_string_lossy().to_string(),
@@ -326,10 +330,19 @@ pub async fn check_prereqs(state: State<'_, AppState>) -> Result<Prereqs, String
}
let fresh = Prereqs {
yt_dlp: version_of(&state.yt_dlp, "--version").await.map(|v| {
let origin = if yt_dlp_is_bundled(&state.yt_dlp) { "bundled" } else { "system" };
format!("{v} ({origin})")
}),
yt_dlp: {
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})")
})
},
ffmpeg: version_of(&[bin("ffmpeg")], "-version").await.map(|v| {
// ffmpeg's first line is long; keep the useful head of it.
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
/// 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]
pub async fn fetch_durations(state: State<'_, AppState>) -> Result<usize, String> {
let ids = state
.db
.lock()
.await
.videos_missing_duration(DURATION_BATCH as i64)?;
pub async fn fetch_durations(
visible: Vec<String>,
state: State<'_, AppState>,
) -> Result<usize, String> {
let ids = {
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() {
return Ok(0);
}
@@ -793,6 +816,97 @@ pub fn parse_length_seconds(body: &str) -> Option<i64> {
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 (&current, &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]
pub async fn list_channels(state: State<'_, AppState>) -> Result<Vec<ChannelWithCount>, String> {
state.db.lock().await.list_channels()
@@ -844,12 +958,17 @@ pub async fn refresh_feeds(
done += 1;
match res {
Ok(videos) => {
let mut db = state.db.lock().await;
if !videos.is_empty() {
let mut db = state.db.lock().await;
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(
"refresh:progress",
@@ -1331,6 +1450,8 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
.build()
.map_err(|e| format!("Cannot build HTTP client: {e}"))?;
let yt_dlp_argv = resolve_yt_dlp(app, &app_data);
Ok(AppState {
db: Arc::new(Mutex::new(db)),
http,
@@ -1341,7 +1462,7 @@ pub fn build_state(app: &AppHandle) -> Result<AppState, String> {
playlists: PlaylistServer::start()?,
prereqs: Arc::new(Mutex::new(None)),
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)),
})
}
+80 -1
View File
@@ -90,9 +90,22 @@ impl Db {
// columns need adding explicitly. The error when it already exists is
// the expected case, not a failure.
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 })
}
/// 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.
pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> {
if seconds <= 0 {
@@ -107,6 +120,32 @@ impl Db {
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.
pub fn videos_missing_duration(&self, limit: i64) -> Result<Vec<String>, String> {
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
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
ORDER BY c.title COLLATE NOCASE ASC",
)
@@ -289,6 +329,7 @@ impl Db {
url: r.get(2)?,
video_count: r.get(3)?,
downloaded_count: r.get(4)?,
last_error: r.get(5)?,
})
})
.map_err(|e| e.to_string())?;
@@ -793,6 +834,44 @@ mod tests {
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]
fn a_recorded_duration_reaches_the_feed() {
let db = seeded();
+2
View File
@@ -164,6 +164,8 @@ fn build_menu(app: &tauri::AppHandle) -> tauri::Result<Menu<tauri::Wry>> {
commands::list_browsers,
commands::set_cookie_source,
commands::test_youtube,
commands::check_yt_dlp_update,
commands::update_yt_dlp,
commands::get_connectivity,
commands::set_library_path,
commands::open_external,
+2
View File
@@ -30,6 +30,8 @@ pub struct ChannelWithCount {
pub url: String,
pub video_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)]