feat: durations, clean subtitles, icon buttons
Subtitles rendered pinned to the left edge and clipped, in half-grey karaoke text: YouTube's auto-captions carry align:start position:0% on every cue plus inline <00:00:12.480><c>word</c> timing tags. Both are now stripped after download, and existing downloads are tidied the first time they are listed. Thumbnails show video length. The Atom feed carries no duration, so it is read from the watch page and cached — but only a trickle. The first version fetched 24 pages every 12 seconds at six concurrent, roughly two requests a second sustained, and YouTube answered by challenging the whole IP: 'Sign in to confirm you are not a bot', which broke streaming and downloads too. It is now four pages every five minutes, one at a time, and stops for the session the moment a batch is refused. A subtitle chosen in the player becomes the stored preference, so the next video matches without going back to Settings. The back and download buttons are square icon buttons; the back glyph was off-centre because px-2 beat the p-0 meant to clear it.
This commit is contained in:
+62
-1
@@ -86,9 +86,42 @@ impl Db {
|
||||
fn init(conn: Connection) -> Result<Db, String> {
|
||||
conn.execute_batch(SCHEMA)
|
||||
.map_err(|e| format!("Cannot create schema: {e}"))?;
|
||||
// CREATE TABLE IF NOT EXISTS leaves existing databases alone, so new
|
||||
// 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", []);
|
||||
Ok(Db { conn })
|
||||
}
|
||||
|
||||
/// Records a video's length in seconds.
|
||||
pub fn set_duration(&self, video_id: &str, seconds: i64) -> Result<(), String> {
|
||||
if seconds <= 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE videos SET duration = ?2 WHERE id = ?1",
|
||||
params![video_id, seconds],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Videos whose length is still unknown, newest first.
|
||||
pub fn videos_missing_duration(&self, limit: i64) -> Result<Vec<String>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT id FROM videos WHERE duration IS NULL
|
||||
ORDER BY published DESC LIMIT ?1",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map(params![limit], |r| r.get::<_, String>(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.collect::<Result<Vec<_>, _>>().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// What a replacing import would destroy. Callers show this before asking
|
||||
/// the user to confirm, so nothing is deleted without being named first.
|
||||
pub fn preview_replace(&self, incoming: &[Channel]) -> Result<ImportPreview, String> {
|
||||
@@ -349,7 +382,8 @@ impl Db {
|
||||
let mut sql = String::from(
|
||||
"SELECT v.id, v.channel_id, COALESCE(c.title, ''), v.title, v.description,
|
||||
v.published, v.thumb_url, v.thumb_path, v.views, v.is_short,
|
||||
d.state, d.path, d.pct, d.error, p.position, p.duration
|
||||
d.state, d.path, d.pct, d.error, p.position,
|
||||
COALESCE(v.duration, p.duration)
|
||||
FROM videos v
|
||||
LEFT JOIN channels c ON c.id = v.channel_id
|
||||
LEFT JOIN downloads d ON d.video_id = v.id
|
||||
@@ -759,6 +793,33 @@ mod tests {
|
||||
assert_eq!(feed[0].id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_recorded_duration_reaches_the_feed() {
|
||||
let db = seeded();
|
||||
db.set_duration("a", 754).unwrap();
|
||||
let feed = db.list_feed(&FeedFilter::default()).unwrap();
|
||||
assert_eq!(feed.iter().find(|f| f.id == "a").unwrap().duration, Some(754.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn playback_duration_fills_in_when_the_video_length_is_unknown() {
|
||||
let db = seeded();
|
||||
db.save_playback("b", 10.0, 300.0).unwrap();
|
||||
let feed = db.list_feed(&FeedFilter::default()).unwrap();
|
||||
assert_eq!(feed.iter().find(|f| f.id == "b").unwrap().duration, Some(300.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn videos_without_a_duration_are_reported_for_lookup() {
|
||||
let db = seeded();
|
||||
assert_eq!(db.videos_missing_duration(10).unwrap().len(), 3);
|
||||
db.set_duration("a", 100).unwrap();
|
||||
assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2);
|
||||
// A nonsense length is ignored rather than stored.
|
||||
db.set_duration("b", 0).unwrap();
|
||||
assert_eq!(db.videos_missing_duration(10).unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_in_progress_still_show_in_the_downloaded_filter() {
|
||||
let db = seeded();
|
||||
|
||||
Reference in New Issue
Block a user