feat: add saved-video collections and category views
This commit is contained in:
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user