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
+18 -2
View File
@@ -74,6 +74,9 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `W` | Browse watch later for the current category, or the combined collection elsewhere | | `W` | Browse watch later for the current category, or the combined collection elsewhere |
| `f` | In watch later, cycle All → Unwatched → Watched | | `f` | In watch later, cycle All → Unwatched → Watched |
| `D` | In watch later, confirm removal of all videos matching the current list/filter | | `D` | In watch later, confirm removal of all videos matching the current list/filter |
| `v` | Save the selected video to saved videos (also works in previews) |
| `V` | Browse saved videos for the current category, or the combined collection elsewhere |
| `x` | Remove only the selected saved video (also works in previews) |
| Tab / Shift-Tab | Switch between visible views | | Tab / Shift-Tab | Switch between visible views |
| `r` | Reload configuration and refresh the current view | | `r` | Reload configuration and refresh the current view |
| `?` | Open / close the scrollable keybinding reference | | `?` | Open / close the scrollable keybinding reference |
@@ -162,7 +165,7 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC
Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config. Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config.
- **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates. - **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates.
- Browsing an unpinned category, channel, or watch-later list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`. - Browsing an unpinned category, channel, watch-later list, or saved-video list uses one shared temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`.
- Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`. - Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session. - Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
@@ -174,7 +177,7 @@ Watch later is **one collection**, deduplicated by YouTube video ID. Category li
Browse a category with `c`, then use `W` for its watch-later list. From Search, Feed, or a channel view, `W` opens the combined collection. Watch-later views use saved title, creator, and publication metadata without fetching video lists, so they also work offline. Their filters are independent and retained during the session; each pane's border title shows the active All, Unwatched, or Watched filter. Browse a category with `c`, then use `W` for its watch-later list. From Search, Feed, or a channel view, `W` opens the combined collection. Watch-later views use saved title, creator, and publication metadata without fetching video lists, so they also work offline. Their filters are independent and retained during the session; each pane's border title shows the active All, Unwatched, or Watched filter.
Saved video titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight. Watch-later-only titles are **magenta** in all video tables; categorized channel names remain cyan. A `✓` marks watched videos, including outside watch-later views. The selected row retains its usual highlight.
### Watched status ### Watched status
@@ -189,6 +192,7 @@ Terebi reports acknowledged playback positions. A video becomes watched when an
Files are siblings of `config.toml`: Files are siblings of `config.toml`:
- `watch-later.toml`: versioned saved-video metadata. - `watch-later.toml`: versioned saved-video metadata.
- `saved-videos.toml`: independent versioned saved-video metadata.
- `watch-state.toml`: versioned global watched video IDs. - `watch-state.toml`: versioned global watched video IDs.
- `playback-sessions/`: private per-launch progress streams and their expected video identities. - `playback-sessions/`: private per-launch progress streams and their expected video identities.
@@ -196,6 +200,18 @@ State writes are atomic and owner-only. Invalid state files or detected external
Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. They are retained because the GUI may still be writing; up to 4,096 sessions are monitored. If that limit is reached, close the corresponding players, remove their inactive session directories, and restart Tubular. Never remove an active player's reporting directory. Progress sessions survive Tubular exit so later player reports can be recovered on the next launch. They are retained because the GUI may still be writing; up to 4,096 sessions are monitored. If that limit is reached, close the corresponding players, remove their inactive session directories, and restart Tubular. Never remove an active player's reporting directory.
## Saved videos
Saved videos is a separate collection from watch later, deduplicated by YouTube video ID. It uses the same offline title, creator, and publication metadata and follows **current channel category memberships**, not categories stored on individual videos.
Browse a category with `c`, then use `V` for its saved-video list. From Search, Feed, or a channel view, `V` opens the combined collection. Switching between a category's saved videos and watch later with `V` / `W` retains that category. Both lists share the existing temporary-tab and back/forward history behavior.
`x` removes **only the selected video** from saved videos, whether in a list or a preview. Removing it never changes watch-later membership or global watched status. There is no watched filter or bulk removal for saved videos.
Saved titles are **green**, taking precedence over watch-later magenta when a video belongs to both collections. Removing it from saved videos restores magenta if it remains in watch later. Channel styling, selected-row highlighting, and watched markers are unchanged.
The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure the actions with `video.saved.save`, `view.saved.browse`, and `video.saved.remove`; the live hints and `?` reference reflect overrides.
## Configurable keybindings ## Configurable keybindings
Print a complete default configuration, including every action and its default bindings: Print a complete default configuration, including every action and its default bindings:
+1 -1
View File
@@ -670,7 +670,7 @@ mod tests {
let directory = TestDirectory::new(); let directory = TestDirectory::new();
let path = directory.0.join("config.toml"); 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 = [ let sections = [
("keys", key_section), ("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"), ("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"]; 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"]; 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"]; 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"]; 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"]; 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"]; 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", "FULL DESCRIPTION",
&[ &[
@@ -926,8 +946,8 @@ mod tests {
#[test] #[test]
fn aliases_replace_defaults_and_can_disable_actions() { 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 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'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = '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!(quoted, bindings);
assert_eq!( assert_eq!(
press( 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] #[test]
fn invalid_entries_and_collisions_have_actionable_errors() { fn invalid_entries_and_collisions_have_actionable_errors() {
for (source, path) in [ for (source, path) in [
+469 -80
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 { #[derive(Clone, Copy)]
collection: StoredFile, pub enum CollectionKind {
watch_state: StoredFile, WatchLater,
Saved,
}
struct Collection {
file: StoredFile,
videos: Vec<Video>, 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>, watched: BTreeSet<String>,
pending: BTreeSet<String>, pending: BTreeSet<String>,
sessions: PathBuf, sessions: PathBuf,
@@ -75,17 +121,15 @@ impl State {
.parent() .parent()
.filter(|p| !p.as_os_str().is_empty()) .filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new(".")); .unwrap_or(Path::new("."));
let (collection, document) = StoredFile::open(parent.join("watch-later.toml"))?; let watch_later = Collection::open(parent.join("watch-later.toml"))?;
let (videos, saved) = load_videos(&document) let saved_videos = Collection::open(parent.join("saved-videos.toml"))?;
.map_err(|e| invalid(format!("{}: {e}", collection.path.display())))?;
let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?; let (watch_state, document) = StoredFile::open(parent.join("watch-state.toml"))?;
let watched = load_watched(&document) let watched = load_watched(&document)
.map_err(|e| invalid(format!("{}: {e}", watch_state.path.display())))?; .map_err(|e| invalid(format!("{}: {e}", watch_state.path.display())))?;
Ok(Self { Ok(Self {
collection, watch_later,
saved_videos,
watch_state, watch_state,
videos,
saved,
watched, watched,
pending: BTreeSet::new(), pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"), sessions: parent.join("playback-sessions"),
@@ -96,40 +140,36 @@ impl State {
}) })
} }
pub fn videos(&self) -> &[Video] { fn collection(&self, kind: CollectionKind) -> &Collection {
&self.videos 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 { pub fn is_watched(&self, id: &str) -> bool {
self.watched.contains(id) self.watched.contains(id)
} }
pub fn save(&mut self, video: &Video) -> io::Result<()> { pub fn save(&mut self, kind: CollectionKind, video: &Video) -> io::Result<()> {
if self.contains(&video.id) { self.collection_mut(kind).save(video)
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 remove(&mut self, ids: &[String]) -> io::Result<()> { pub fn remove(&mut self, kind: CollectionKind, ids: &[String]) -> io::Result<()> {
if !ids.iter().any(|id| self.saved.contains(id)) { self.collection_mut(kind).remove(ids)
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 progress_file(&mut self) -> io::Result<PathBuf> { pub fn progress_file(&mut self) -> io::Result<PathBuf> {
@@ -757,13 +797,16 @@ mod tests {
} }
#[test] #[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 directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap(); let mut state = State::open(&directory.config()).unwrap();
let video = video(); let video = video();
state.save(&video).unwrap(); for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
state.save(&video).unwrap(); state.save(kind, &video).unwrap();
assert_eq!(state.videos().len(), 1); state.save(kind, &video).unwrap();
assert_eq!(state.videos(kind).len(), 1);
assert!(state.contains(kind, &video.id));
}
let report = state.progress_file().unwrap(); let report = state.progress_file().unwrap();
register_identity(&report, &video.id).unwrap(); register_identity(&report, &video.id).unwrap();
append( append(
@@ -773,15 +816,34 @@ mod tests {
assert!(state.poll_progress().unwrap()); assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap()); assert!(!state.poll_progress().unwrap());
let mut restarted = State::open(&directory.config()).unwrap(); let mut restarted = State::open(&directory.config()).unwrap();
assert_eq!( for kind in [CollectionKind::WatchLater, CollectionKind::Saved] {
restarted.videos()[0].publication_date, let stored = &restarted.videos(kind)[0];
video.publication_date 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)); assert!(restarted.is_watched(&video.id));
restarted.remove(std::slice::from_ref(&video.id)).unwrap(); restarted
restarted.remove(std::slice::from_ref(&video.id)).unwrap(); .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(); 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.is_watched(&video.id));
assert!(!restarted.poll_progress().unwrap()); assert!(!restarted.poll_progress().unwrap());
} }
@@ -792,11 +854,25 @@ mod tests {
let path = directory.0.join("watch-later.toml"); let path = directory.0.join("watch-later.toml");
let mut state = State::open(&directory.config()).unwrap(); let mut state = State::open(&directory.config()).unwrap();
fs::write(&path, "not = [valid").unwrap(); fs::write(&path, "not = [valid").unwrap();
assert!(state.save(&video()).is_err()); assert!(state.save(CollectionKind::WatchLater, &video()).is_err());
assert!(state.videos().is_empty()); assert!(state.videos(CollectionKind::WatchLater).is_empty());
assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid"); assert_eq!(fs::read_to_string(&path).unwrap(), "not = [valid");
assert!(State::open(&directory.config()).is_err()); assert!(State::open(&directory.config()).is_err());
fs::remove_file(&path).unwrap(); 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( fs::write(
directory.0.join("watch-state.toml"), directory.0.join("watch-state.toml"),
"version = 1\nwatched = [\"--unsafe\"]\n", "version = 1\nwatched = [\"--unsafe\"]\n",
+1 -1
View File
@@ -471,7 +471,7 @@ impl PlayerLaunch {
"Invalid YouTube video ID", "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) let mut player = isolated_command(player)
.args([ .args([
"--new-instance", "--new-instance",