feat: add saved-video collections and category views

This commit is contained in:
2026-10-11 20:47:24 +02:00
parent 6eed1cdd9e
commit b65834e9d4
6 changed files with 680 additions and 138 deletions
+1 -1
View File
@@ -670,7 +670,7 @@ mod tests {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nvideo.saved.remove = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let sections = [
("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"),
+63 -2
View File
@@ -109,6 +109,9 @@ actions! {
WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal], ["W"];
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal], ["f"];
WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"];
SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"];
SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal], ["V"];
SavedVideoRemove, "video.saved.remove", "Remove the selected saved video (also in previews)", VIEW, ["x"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -477,6 +480,23 @@ impl Bindings {
),
],
),
(
"SAVED VIDEOS",
&[
(
&[SavedVideoSave],
"Save the selected video (also in previews)",
),
(
&[SavedVideoBrowse],
"Browse category saved videos; combined elsewhere",
),
(
&[SavedVideoRemove],
"Remove the selected video (also in previews)",
),
],
),
(
"FULL DESCRIPTION",
&[
@@ -926,8 +946,8 @@ mod tests {
#[test]
fn aliases_replace_defaults_and_can_disable_actions() {
let bindings = configured("[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
let quoted = configured("[keys]\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
let bindings = configured("[keys.video.saved]\nremove = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
let quoted = configured("[keys]\n'video.saved.remove' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
assert_eq!(quoted, bindings);
assert_eq!(
press(
@@ -1047,6 +1067,47 @@ mod tests {
);
}
#[test]
fn saved_video_overrides_disable_and_respect_contexts() {
let defaults = Bindings::default();
for context in ALL {
for (key, action, enabled) in [
('v', Action::SavedVideoSave, VIEW.contains(context)),
('V', Action::SavedVideoBrowse, *context == Context::Normal),
('x', Action::SavedVideoRemove, VIEW.contains(context)),
] {
assert_eq!(
press(&defaults, *context, KeyCode::Char(key), KeyModifiers::NONE),
enabled.then_some(action),
);
}
}
let bindings = configured(
"[keys.video.saved]\nsave = 'alt+v'\nremove = []\n[keys.view.saved]\nbrowse = 'shift+x'\n",
)
.unwrap();
for context in ALL {
assert_eq!(
press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT),
VIEW.contains(context).then_some(Action::SavedVideoSave),
);
assert_eq!(
press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT),
(*context == Context::Normal).then_some(Action::SavedVideoBrowse),
);
for key in ['v', 'V', 'x'] {
assert_eq!(
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
None,
);
}
}
let collision = configured("[keys]\nview.saved.browse = 'v'")
.unwrap_err()
.to_string();
assert!(collision.contains("view.saved.browse") && collision.contains("video.saved.save"));
}
#[test]
fn invalid_entries_and_collisions_have_actionable_errors() {
for (source, path) in [
+472 -83
View File
File diff suppressed because one or more lines are too long
+125 -49
View File
@@ -55,11 +55,57 @@ impl StoredFile {
}
}
pub struct State {
collection: StoredFile,
watch_state: StoredFile,
#[derive(Clone, Copy)]
pub enum CollectionKind {
WatchLater,
Saved,
}
struct Collection {
file: StoredFile,
videos: Vec<Video>,
saved: BTreeSet<String>,
ids: BTreeSet<String>,
}
impl Collection {
fn open(path: PathBuf) -> io::Result<Self> {
let (file, document) = StoredFile::open(path)?;
let (videos, ids) =
load_videos(&document).map_err(|e| invalid(format!("{}: {e}", file.path.display())))?;
Ok(Self { file, videos, ids })
}
fn save(&mut self, video: &Video) -> io::Result<()> {
if self.ids.contains(&video.id) {
return Ok(());
}
let mut video = video.clone();
validate_saved(&mut video)?;
self.file.save(video_document(
self.videos.iter().chain(std::iter::once(&video)),
))?;
self.ids.insert(video.id.clone());
self.videos.push(video);
Ok(())
}
fn remove(&mut self, ids: &[String]) -> io::Result<()> {
if !ids.iter().any(|id| self.ids.contains(id)) {
return Ok(());
}
self.file.save(video_document(
self.videos.iter().filter(|v| !ids.contains(&v.id)),
))?;
self.videos.retain(|v| !ids.contains(&v.id));
self.ids.retain(|id| !ids.contains(id));
Ok(())
}
}
pub struct State {
watch_later: Collection,
saved_videos: Collection,
watch_state: StoredFile,
watched: BTreeSet<String>,
pending: BTreeSet<String>,
sessions: PathBuf,
@@ -75,17 +121,15 @@ impl State {
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let (collection, document) = StoredFile::open(parent.join("watch-later.toml"))?;
let (videos, saved) = load_videos(&document)
.map_err(|e| invalid(format!("{}: {e}", collection.path.display())))?;
let watch_later = Collection::open(parent.join("watch-later.toml"))?;
let saved_videos = Collection::open(parent.join("saved-videos.toml"))?;
let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?;
let watched = load_watched(&document)
.map_err(|e| invalid(format!("{}: {e}", watch_state.path.display())))?;
Ok(Self {
collection,
watch_later,
saved_videos,
watch_state,
videos,
saved,
watched,
pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"),
@@ -96,40 +140,36 @@ impl State {
})
}
pub fn videos(&self) -> &[Video] {
&self.videos
fn collection(&self, kind: CollectionKind) -> &Collection {
match kind {
CollectionKind::WatchLater => &self.watch_later,
CollectionKind::Saved => &self.saved_videos,
}
}
pub fn contains(&self, id: &str) -> bool {
self.saved.contains(id)
fn collection_mut(&mut self, kind: CollectionKind) -> &mut Collection {
match kind {
CollectionKind::WatchLater => &mut self.watch_later,
CollectionKind::Saved => &mut self.saved_videos,
}
}
pub fn videos(&self, kind: CollectionKind) -> &[Video] {
&self.collection(kind).videos
}
pub fn contains(&self, kind: CollectionKind, id: &str) -> bool {
self.collection(kind).ids.contains(id)
}
pub fn is_watched(&self, id: &str) -> bool {
self.watched.contains(id)
}
pub fn save(&mut self, video: &Video) -> io::Result<()> {
if self.contains(&video.id) {
return Ok(());
}
let mut video = video.clone();
validate_saved(&mut video)?;
self.collection.save(video_document(
self.videos.iter().chain(std::iter::once(&video)),
))?;
self.saved.insert(video.id.clone());
self.videos.push(video);
Ok(())
pub fn save(&mut self, kind: CollectionKind, video: &Video) -> io::Result<()> {
self.collection_mut(kind).save(video)
}
pub fn remove(&mut self, ids: &[String]) -> io::Result<()> {
if !ids.iter().any(|id| self.saved.contains(id)) {
return Ok(());
}
self.collection.save(video_document(
self.videos.iter().filter(|v| !ids.contains(&v.id)),
))?;
self.videos.retain(|v| !ids.contains(&v.id));
self.saved.retain(|id| !ids.contains(id));
Ok(())
pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> {
self.collection_mut(kind).remove(ids)
}
pub fn progress_file(&mut self) -> io::Result<PathBuf> {
@@ -757,13 +797,16 @@ mod tests {
}
#[test]
fn collection_is_idempotent_and_removal_preserves_global_watched_on_restart() {
fn collections_are_independent_and_preserve_metadata_and_watched_on_restart() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
state.save(&video).unwrap();
state.save(&video).unwrap();
assert_eq!(state.videos().len(), 1);
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
state.save(kind, &video).unwrap();
state.save(kind, &video).unwrap();
assert_eq!(state.videos(kind).len(), 1);
assert!(state.contains(kind, &video.id));
}
let report = state.progress_file().unwrap();
register_identity(&report, &video.id).unwrap();
append(
@@ -773,15 +816,34 @@ mod tests {
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
let mut restarted = State::open(&directory.config()).unwrap();
assert_eq!(
restarted.videos()[0].publication_date,
video.publication_date
);
for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
let stored = &restarted.videos(kind)[0];
assert_eq!(stored.id, video.id);
assert_eq!(stored.title, video.title);
assert_eq!(stored.channel, video.channel);
assert_eq!(stored.channel_id, video.channel_id);
assert_eq!(stored.uploader_id, video.uploader_id);
assert_eq!(stored.timestamp, video.timestamp);
assert_eq!(stored.publication_date, video.publication_date);
}
assert!(restarted.is_watched(&video.id));
restarted.remove(std::slice::from_ref(&video.id)).unwrap();
restarted.remove(std::slice::from_ref(&video.id)).unwrap();
restarted
.remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id))
.unwrap();
restarted
.remove(CollectionKind::WatchLater, std::slice::from_ref(&video.id))
.unwrap();
let mut restarted = State::open(&directory.config()).unwrap();
assert!(restarted.videos().is_empty());
assert!(restarted.videos(CollectionKind::WatchLater).is_empty());
assert!(restarted.contains(CollectionKind::Saved, &video.id));
assert!(restarted.is_watched(&video.id));
restarted.save(CollectionKind::WatchLater, &video).unwrap();
restarted
.remove(CollectionKind::Saved, std::slice::from_ref(&video.id))
.unwrap();
let mut restarted = State::open(&directory.config()).unwrap();
assert!(restarted.videos(CollectionKind::Saved).is_empty());
assert!(restarted.contains(CollectionKind::WatchLater, &video.id));
assert!(restarted.is_watched(&video.id));
assert!(!restarted.poll_progress().unwrap());
}
@@ -792,11 +854,25 @@ mod tests {
let path = directory.0.join("watch-later.toml");
let mut state = State::open(&directory.config()).unwrap();
fs::write(&path, "not = [valid").unwrap();
assert!(state.save(&video()).is_err());
assert!(state.videos().is_empty());
assert!(state.save(CollectionKind::WatchLater, &video()).is_err());
assert!(state.videos(CollectionKind::WatchLater).is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid");
assert!(State::open(&directory.config()).is_err());
fs::remove_file(&path).unwrap();
let saved_path = directory.0.join("saved-videos.toml");
fs::write(&saved_path, "not = [valid").unwrap();
assert!(state.save(CollectionKind::Saved, &video()).is_err());
assert!(state.videos(CollectionKind::Saved).is_empty());
assert_eq!(fs::read_to_string(&saved_path).unwrap(), "not = [valid");
assert!(State::open(&directory.config()).is_err());
fs::remove_file(&saved_path).unwrap();
fs::write(&saved_path, "version = 1\n[[videos]]\nid = '--unsafe'\n").unwrap();
assert!(State::open(&directory.config()).is_err());
assert_eq!(
fs::read_to_string(&saved_path).unwrap(),
"version = 1\n[[videos]]\nid = '--unsafe'\n"
);
fs::remove_file(&saved_path).unwrap();
fs::write(
directory.0.join("watch-state.toml"),
"version = 1\nwatched = [\"--unsafe\"]\n",
+1 -1
View File
@@ -471,7 +471,7 @@ impl PlayerLaunch {
"Invalid YouTube video ID",
));
}
crate::watch_later::register_identity(progress_file, id)?;
crate::video_state::register_identity(progress_file, id)?;
let mut player = isolated_command(player)
.args([
"--new-instance",