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:
+80
-1
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user