Compare commits

..

18 Commits

Author SHA1 Message Date
hl-valdemar 36afc157d1 feat: choose collection scope through searchable selectors 2026-10-11 22:02:01 +02:00
hl-valdemar d70a077076 feat: open combined collections directly from any video view 2026-10-11 21:38:37 +02:00
hl-valdemar a9fef33e51 feat: scope individual and bulk removal to collection views 2026-10-11 21:38:37 +02:00
hl-valdemar 343010ce5c feat: remove individual watch-later videos 2026-10-11 21:38:37 +02:00
hl-valdemar b65834e9d4 feat: add saved-video collections and category views 2026-10-11 20:57:35 +02:00
hl-valdemar 6eed1cdd9e fix: show active watch filter in pane title 2026-10-11 20:57:35 +02:00
hl-valdemar cf4941c879 feat: add watch-later views and Terebi watched-state tracking 2026-10-11 20:19:50 +02:00
hl-valdemar af8032f12f fix: insert bracketed paste as one input event 2026-10-11 20:08:52 +02:00
hl-valdemar 2f11bdd40a fix: discard malformed video entries without failing pages 2026-10-11 20:04:17 +02:00
hl-valdemar 6afe229406 feat: filter channel category tagging 2026-10-11 20:04:17 +02:00
hl-valdemar 766fef625c feat: color category-saved channel names 2026-10-11 20:04:17 +02:00
hl-valdemar 8dee7eb382 style: add space below keybinding section headers 2026-10-11 20:04:17 +02:00
hl-valdemar c3f713001d fix: restore grouped keybinding overview with bold section headers 2026-10-11 18:29:20 +02:00
hl-valdemar df63eb0d85 feat: emphasize keybinding overview headings 2026-10-11 18:19:33 +02:00
hl-valdemar aaa13ab905 feat: configure keybindings and print default config 2026-10-11 18:05:59 +02:00
hl-valdemar eee610105e feat: group global configuration under settings 2026-10-11 17:35:11 +02:00
hl-valdemar f5c1386315 feat: add keybinding help popup and concise status hints 2026-10-11 17:35:11 +02:00
hl-valdemar 600ccd3f87 feat: navigate forward through views with plus 2026-10-11 17:35:11 +02:00
7 changed files with 6012 additions and 363 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ image = { version = "0.25.10", default-features = false, features = ["jpeg"] }
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
ratatui-image = { version = "11.1.0", default-features = false, features = ["crossterm"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
serde_json = { version = "1.0.151", features = ["raw_value"] }
toml_edit = "0.25.17"
time = "0.3.55"
+122 -11
View File
@@ -7,7 +7,7 @@ A modal terminal YouTube browser built with Rust and [Ratatui](https://ratatui.r
- A recent stable Rust toolchain, including Cargo, to build and install.
- [`yt-dlp`](https://github.com/yt-dlp/yt-dlp) on `PATH` for searches, feeds, and full descriptions.
- `curl` on `PATH` for thumbnails.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback.
- The **Terebi CLI** installed on `PATH`, together with its app bundle and streaming dependencies, for playback. It must support `--new-instance --url … --progress-file PATH`.
The default playback setup requires **macOS 15+**, since Terebi is a native macOS player. Install Terebi separately and follow its dependency/setup instructions; installing Tubular does not install the player.
@@ -43,13 +43,16 @@ Tubular starts in **normal mode**, with or without a command-line query. Press `
Selecting a video launches:
```sh
terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID'
terebi --new-instance --url 'https://www.youtube.com/watch?v=VIDEO_ID' \
--progress-file '/private/session/progress.jsonl'
```
Each selection opens an independent player instance without replacing existing playback. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
Each selection opens an independent player instance without replacing existing playback. Tubular creates a private reporting session and consumes Terebi's progress records independently of the launcher. `--player` overrides the Terebi CLI executable; it is not a generic mpv/IINA adapter. Tubular reports launcher failures, while later streaming/playback errors appear in Terebi. Quitting Tubular leaves launched players running.
## Controls
These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys.
### Normal mode
| Key | Action |
@@ -61,28 +64,42 @@ Each selection opens an independent player instance without replacing existing p
| `d` | Open video info; press again for the full description, again to return to info |
| Esc | Close the preview; otherwise return to the previous view |
| `-` | Return to the previous view |
| `+` | Go forward after navigating back |
| `c` | Browse saved categories |
| `u` | Browse an individual saved channel |
| `U` (Shift-U) | Open the selected video's channel |
| `s` | Edit the selected channel's tags |
| `S` | Edit tags for all saved channels in the current category |
| `w` | Save the selected video to watch later (also works in previews) |
| `W` | Choose combined or category watch later from any video view or preview |
| `Alt-w` | Open the current category's watch later |
| `f` | In watch later, cycle All → Unwatched → Watched |
| `x` | In watch-later or saved-video lists, remove the selected video |
| `X` (Shift-X) | In either collection list, confirm bulk removal of the current category/filter subset |
| `v` | Save the selected video to saved videos (also works in previews) |
| `V` | Choose combined or category saved videos from any video view or preview |
| `Alt-v` | Open the current category's saved videos |
| Tab / Shift-Tab | Switch between visible views |
| `r` | Reload configuration and refresh the current view |
| `?` | Open / close the scrollable keybinding reference |
| `q` | Quit |
**Ctrl-C quits from any mode or popup.**
Back navigation restores cached videos, selection, and pagination, including replaced temporary tabs. Removed views are skipped; with no history, the current view stays put. Esc first closes a dialog or leaves insert mode without navigating. Minus remains ordinary text inside input fields.
The bottom bar shows essential actions for the current context. Press `?` in normal mode—including selectors and previews—for the full reference. Scroll help with `j` / `k`, arrows, Ctrl-J / Ctrl-K, or PageDown / PageUp; `?` or Esc closes it without changing the underlying view or dialog. In text fields, `?` remains ordinary input.
The overview groups shortcut/action rows under bold section headers such as `VIEWS AND VIDEOS`, with an empty line beneath each header. Shortcuts and action descriptions remain normal weight; the rows reflect configured bindings rather than showing internal action paths.
Back and forward navigation restore cached videos, selection, and pagination, including replaced temporary tabs. Removed views are skipped; empty history leaves the current view unchanged. Opening a different view after going back discards the old forward branch. Esc first closes a dialog or leaves insert mode without navigating. Minus and plus remain ordinary text inside input fields.
The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown / PageUp. Plain `j` / `k` still changes the selected video. The bold title, dimmed author/publication row, and description scroll together.
### Text input
These shortcuts work in search, category/channel-filter, and new-tag fields:
These shortcuts work in search, category/channel/tag-filter, and new-tag fields:
| Key | Action |
| --- | --- |
| Enter | Submit the search, browse the selected category/channel, or stage the new tag |
| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, or stage the new tag |
| Esc | Leave insert mode or abandon the new-tag name |
| Backspace | Delete one character |
| Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word |
@@ -90,6 +107,8 @@ These shortcuts work in search, category/channel-filter, and new-tag fields:
If the terminal intercepts modified Backspace rather than transmitting it, use Ctrl-W / Ctrl-U or configure equivalent terminal key mappings. Input fields show a block cursor.
In terminals supporting bracketed paste, clipboard text is inserted in one operation into the active search, filter, or new-tag field; filters update once for the whole paste. Pasting never submits input or triggers shortcuts. Line breaks and tabs become spaces, other control characters are removed, and pastes outside text-entry mode are ignored. Bracketed-paste mode is disabled again when Tubular exits. Terminals without bracketed-paste support still deliver pasted text as ordinary keystrokes.
### Category browser (`c`)
- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes.
@@ -108,9 +127,12 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha
### Channel tags (`s`, or `S` for bulk editing)
- `j` / `k` or arrow keys navigate; Space toggles a tag.
- `n` prompts for a new tag name. Enter stages it checked and returns to the list.
- Enter **in the main tag list** saves the staged memberships. Esc cancels without writing.
- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter.
- **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter.
- Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter.
- `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name.
- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels without writing. These controls apply to both single-channel and bulk tagging.
- Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions.
Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved.
@@ -125,7 +147,7 @@ $XDG_CONFIG_HOME/tubular/config.toml
If `XDG_CONFIG_HOME` is unset, empty, or relative, the fallback is `~/.config/tubular/config.toml`. Edit the file directly and press `r` to reload, or save memberships from the tag dialog.
```toml
# Top-level setting: place this before the table headers.
[settings]
pinned_categories = ["Favorites"]
[categories]
@@ -138,19 +160,108 @@ Favorites = ["@jawed"]
`[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available.
`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, `[categories]`, and `[keys]` sections can appear in any order; pin/unpin and tag edits preserve the existing section order, key overrides, and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted.
Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly.
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.
- Browsing an unpinned category opens one 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`.
- Temporary tabs do not survive restarting; saved categories and pins do. Hidden views retain loaded rows, selection, and pagination during the session.
Bulk editing refuses to clear all tags when a target has no `[channels]` registration, preventing accidental unsubscription of category-only channels. If source memberships change while a bulk dialog is open, close and reopen it before saving.
## Watch later
Watch later is **one collection**, deduplicated by YouTube video ID. Category lists are derived from the saved videos' channel identities and the **current** subscription categories: adding or removing a channel from a category changes its watch-later list after saving tags or reloading config with `r`. No category memberships are stored on individual videos.
`W` opens a searchable collection selector from any video view or preview. The bold **All categories (combined)** entry comes first, followed by configured categories; it remains available even when there are no categories. Navigate with `j` / `k` or arrows in normal mode, or press `i` to filter by name and use arrows / `Ctrl-j` / `Ctrl-k` while typing. `Enter` browses the selected match; with no matches it does nothing. `Esc` returns to normal mode, then closes the selector without navigating. Reopening clears the search. `Alt-w` still opens the current category's watch later directly.
Moving between watch-later scopes carries the current All/Unwatched/Watched filter to the destination, including cached lists; back navigation restores the originating category list. Choosing the current list does not add history. The border title shows the active filter. Lists use saved title, creator, and publication metadata without fetching video lists, so they also work offline.
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
Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later.
### Storage
Files are siblings of `config.toml`:
- `watch-later.toml`: versioned saved-video metadata.
- `saved-videos.toml`: independent versioned saved-video metadata.
- `watch-state.toml`: versioned global watched video IDs.
- `playback-sessions/`: private per-launch progress streams and their expected video identities.
State writes are atomic and owner-only. Invalid state files or detected external edits are reported rather than overwritten; repair the file or restart before retrying. Each state file is limited to 8 MiB.
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.
`V` opens the same searchable selector for saved videos, with **All categories (combined)** first and configured categories beneath it. `Alt-v` opens the current category's saved videos directly. `Alt-w` / `Alt-v` retain the explicit category when switching between category-scoped collection lists; without a current category, they leave the view unchanged. All four shortcuts work in normal mode and previews, not text-entry fields or modal selectors. Both collections share the existing temporary-tab and back/forward history behavior.
Saved lists have no watched filter. Individual and bulk removal use the shared collection-list controls below.
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 saving and the collection selector with `video.saved.save` and `view.saved.browse`; direct category browsing uses `view.saved.category`. The corresponding watch-later navigation actions are `view.watch_later.browse` and `view.watch_later.category`. Selectors use the shared `selector.*` and `input.*` bindings. The live hints and `?` reference reflect overrides. Alt shortcuts require the terminal to transmit Alt/Meta; if macOS Option inserts symbols, enable Option-as-Meta or remap the actions.
## Collection removal
Removal controls apply **only in watch-later and saved-video views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list. An empty list does not open confirmation.
The prompt names the collection, category, count, and applicable filter. **`y` confirms; `n` or Esc cancels.** Enter never confirms. Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open.
Removal changes only the active collection. Matching category views update, while the other collection and global watched status remain untouched.
The old collection-specific removal settings are no longer accepted. Replace them with:
```toml
[keys]
view.collection.remove.selected = "x"
view.collection.remove.bulk = "X"
```
## Configurable keybindings
Print a complete default configuration, including every action and its default bindings:
```sh
tubular --default-config
```
This prints TOML to stdout and exits without reading or writing config, starting the TUI, or needing `yt-dlp`, Terebi, or a terminal. Copy the settings you want into your config; `r` reloads them.
```toml
[keys]
video.info.overview = "I"
video.info.description = "d"
video.info.hide = ["escape", "backspace"]
view.history.previous = ["-", "escape"]
view.history.next = "+"
video.play = ["enter", "ctrl+p"]
```
Each string or array **replaces** that action's default aliases; omitted actions keep their defaults, and `[]` disables an action. Dotted assignments, corresponding subtables such as `[keys.video.info]`, and quoted action names are supported. Unknown actions, invalid chords, duplicate action declarations, and ambiguous bindings in the same context are rejected before saving.
Characters are case-sensitive (`i` differs from `I`). Named keys are case-insensitive: `escape`, `enter`, `tab`, `space`, arrows, `home`, `end`, `pageup`, `pagedown`, `backspace`, `delete`, and `f1`–`f24`. Combine modifiers with `+`, for example `ctrl+j`, `alt+backspace`, `cmd+backspace`, or `shift+tab`. `control`, `option`, and `super` are accepted aliases; `+` itself, or `ctrl++`, can be bound. The terminal must transmit the chord for it to work.
Using the same binding for overview and description preserves the default cycle. Distinct bindings, as above, open either view directly. Preview close takes precedence over history back; description scrolling takes precedence over video selection for shared bindings. View commands do not run inside text inputs or selectors; those have their own actions listed by `--default-config`.
## Loading and previews
Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Individual channel views request only that channel's uploads, using the same pagination. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
Malformed individual entries—including invalid video IDs, missing required fields, and wrong field types—are discarded without losing valid videos from the same page. Skipped entries still count toward pagination, so a full page of bad or unavailable entries does not hide later results. Invalid JSON, malformed playlist-level metadata, and extractor failures still fail the page.
Feed views merge newest-first using available upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted. Publication labels use `YYYY-MM-DD`; `~` marks an estimated UTC date, and `Unknown` means unavailable metadata.
Pane titles show animated spinners while content loads. Errors retain loaded rows and stop automatic retries; `r` refreshes the current view.
+251 -15
View File
@@ -9,11 +9,14 @@ use std::{
use toml_edit::{Array, DocumentMut, Item, Table, value};
use crate::keys;
#[derive(Default, Debug)]
pub struct Config {
pub channels: BTreeMap<String, String>,
pub categories: BTreeMap<String, Vec<String>>,
pub pinned_categories: Vec<String>,
pub keys: keys::Bindings,
}
impl Config {
@@ -70,10 +73,12 @@ pub fn load(path: &Path) -> io::Result<Config> {
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
@@ -96,6 +101,7 @@ pub fn set_channel_tags(
// Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
match mappings.get(&handle) {
Some(existing) if existing != &channel => {
return Err(invalid(format!(
@@ -162,6 +168,7 @@ pub fn set_channel_tags(
channels: mappings,
categories,
pinned_categories,
keys,
})
}
@@ -177,6 +184,7 @@ pub fn set_category_tags(
let mappings = parse_channels(&document)?;
let parsed_categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
let targets = parsed_categories
.get(source)
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
@@ -272,6 +280,7 @@ pub fn set_category_tags(
channels: mappings,
categories,
pinned_categories,
keys,
})
}
@@ -341,14 +350,22 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pins = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if !categories.contains_key(name) {
return Err(invalid(format!("Unknown category: {name:?}")));
}
if !document.contains_key("pinned_categories") {
document.insert("pinned_categories", value(Array::new()));
if !document.contains_key("settings") {
document.insert("settings", Item::Table(Table::new()));
}
let array = document["pinned_categories"]
.as_array_mut()
let settings = document["settings"]
.as_table_like_mut()
.expect("settings were validated");
if !settings.contains_key("pinned_categories") {
settings.insert("pinned_categories", value(Array::new()));
}
let array = settings
.get_mut("pinned_categories")
.and_then(Item::as_array_mut)
.expect("pins were validated");
if let Some(index) = pins.iter().position(|pin| pin == name) {
if !pinned {
@@ -363,11 +380,21 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
channels,
categories,
pinned_categories,
keys,
})
}
fn parse_pins(document: &DocumentMut) -> io::Result<Vec<String>> {
let Some(item) = document.get("pinned_categories") else {
if document.contains_key("pinned_categories") {
return Err(invalid("Move top-level pinned_categories into [settings]"));
}
let Some(settings) = document.get("settings") else {
return Ok(Vec::new());
};
let settings = settings
.as_table_like()
.ok_or_else(|| invalid("settings must be a TOML table"))?;
let Some(item) = settings.get("pinned_categories") else {
return Ok(Vec::new());
};
let array = item
@@ -636,11 +663,218 @@ mod tests {
}
}
#[test]
fn key_remaps_survive_all_saves_and_section_orders() {
use crate::keys::{Action, Context};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
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 = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let sections = [
("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"),
(
"channels",
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
),
("categories", "[categories]\nA = ['@example']\nB = []\n"),
];
for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] {
let original = order
.iter()
.map(|index| sections[*index].1)
.collect::<String>();
fs::write(&path, original).unwrap();
let loaded = load(&path).unwrap();
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), modifiers)
),
Some(Action::Quit),
);
}
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
),
None,
);
assert_eq!(
loaded.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin),
);
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let channel_saved =
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]);
let category_saved =
set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap();
let pin_saved = set_category_pinned(&path, "B", true).unwrap();
for config in [
channel_saved,
category_saved,
pin_saved,
load(&path).unwrap(),
] {
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
),
Some(Action::Quit)
);
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
assert_eq!(
config.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin)
);
}
let saved = fs::read_to_string(&path).unwrap();
assert!(
saved.contains(key_section),
"key formatting changed: {saved}"
);
let document = saved.parse::<DocumentMut>().unwrap();
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
order
.iter()
.map(|index| sections[*index].0)
.collect::<Vec<_>>(),
);
}
}
#[test]
fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()];
for invalid_keys in [
"[keys]\napp.quit = 42\n",
"[keys]\nunknown.action = 'x'\n",
"[keys]\napp.quit = 'not-a-key'\n",
"[keys]\nview.refresh = 'q'\n",
"keys = 'invalid'\n",
] {
let original = format!(
"{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n"
);
fs::write(&path, &original).unwrap();
assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
let channel_error =
set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err();
assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err();
assert_eq!(category_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
}
#[test]
fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let sections = [
(
"settings",
"[settings]\npinned_categories = ['A']\ncustom = 42\n",
),
(
"channels",
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
),
("categories", "[categories]\nA = []\nB = ['@example']\n"),
];
for order in [
[0, 1, 2],
[0, 2, 1],
[1, 0, 2],
[1, 2, 0],
[2, 0, 1],
[2, 1, 0],
] {
let original = order
.iter()
.map(|index| sections[*index].1)
.collect::<String>();
fs::write(&path, original).unwrap();
let config = load(&path).unwrap();
assert_eq!(config.pinned_categories, ["A"]);
assert_eq!(
config.categories["B"],
["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"]
);
assert_eq!(
set_category_pinned(&path, "B", true)
.unwrap()
.pinned_categories,
["A", "B"]
);
assert_eq!(
set_category_pinned(&path, "A", false)
.unwrap()
.pinned_categories,
["B"]
);
let saved = load(&path).unwrap();
assert_eq!(saved.pinned_categories, ["B"]);
assert_eq!(saved.categories, config.categories);
assert_eq!(saved.channels, config.channels);
let contents = fs::read_to_string(&path).unwrap();
let document = contents.parse::<DocumentMut>().unwrap();
assert_eq!(document["settings"]["custom"].as_integer(), Some(42));
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
order
.iter()
.map(|index| sections[*index].0)
.collect::<Vec<_>>(),
);
}
for original in [
"[categories]\nA = []\n",
"[settings]\ncustom = 42\n[categories]\nA = []\n",
] {
fs::write(&path, original).unwrap();
set_category_pinned(&path, "A", true).unwrap();
assert_eq!(load(&path).unwrap().pinned_categories, ["A"]);
}
}
#[test]
fn category_pins_persist_without_changing_memberships_or_manual_preferences() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
fs::write(&path, "# favorites\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
fs::write(&path, "# favorites\n[settings]\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap();
let pinned = set_category_pinned(&path, "Chinese", true).unwrap();
assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]);
let tags = BTreeMap::from([("Japanese".into(), true)]);
@@ -662,9 +896,11 @@ mod tests {
assert!(set_category_pinned(&path, "Missing", true).is_err());
assert_eq!(fs::read_to_string(&path).unwrap(), content);
for invalid in [
"pinned_categories = true",
"pinned_categories = [4]",
"pinned_categories = ['A', ' A ']",
"[settings]\npinned_categories = true",
"[settings]\npinned_categories = [4]",
"[settings]\npinned_categories = ['A', ' A ']",
"settings = []",
"pinned_categories = ['A']",
] {
fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap();
let before = fs::read(&path).unwrap();
@@ -688,7 +924,7 @@ mod tests {
let direct_url = format!("https://www.youtube.com/channel/{direct}");
let manual_url = "https://www.youtube.com/@manual".to_owned();
let original = format!(
"# manual preferences\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
"# manual preferences\n[settings]\npinned_categories = ['Source'] # pin note\nplayer = 'mpv'\n[channels]\n'@alias' = '{first}' # alias mapping\n'@second' = '{second_url}'\n[other]\nvalue = 42\n[categories]\n' Source ' = [\n '@alias', # source channel\n '{first_url}',\n '{second}',\n 'https://youtube.com/@manual',\n '@manual',\n '{direct}', # direct channel\n] # source note\nKeep = ['@alias', '@unrelated'] # keep note\nDrop = [\n '@manual', # removed channel note\n 'https://youtube.com/@alias',\n '@unrelated', # unrelated note\n]\nUntouched = ['@unrelated']\n"
);
fs::write(&path, &original).unwrap();
let opened = load(&path).unwrap();
@@ -737,11 +973,11 @@ mod tests {
before["channels"].to_string()
);
assert_eq!(
document["pinned_categories"].to_string(),
before["pinned_categories"].to_string()
document["settings"]["pinned_categories"].to_string(),
before["settings"]["pinned_categories"].to_string()
);
assert_eq!(document["other"]["value"].as_integer(), Some(42));
assert_eq!(document["player"].as_str(), Some("mpv"));
assert_eq!(document["settings"]["player"].as_str(), Some("mpv"));
let new = document["categories"]["New"].as_array().unwrap();
let raw: Vec<_> = new.iter().map(|entry| entry.as_str().unwrap()).collect();
assert_eq!(raw, ["@manual", direct, "@alias", "@second"]);
@@ -828,7 +1064,7 @@ mod tests {
"[categories]\nSource = ['@manual']\n' Source ' = []\n",
"channels = true\n[categories]\nSource = ['@manual']\n",
"[channels]\n'@manual' = '@different'\n[categories]\nSource = ['@manual']\n",
"pinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
"[settings]\npinned_categories = [7]\n[categories]\nSource = ['@manual']\n",
] {
fs::write(&path, contents).unwrap();
assert!(set_category_tags(&path, "Source", &expected, &tags).is_err());
@@ -861,7 +1097,7 @@ mod tests {
let path = directory.0.join("config.toml");
let id = "UC4QobU6STFB0P71PMvOGN5A";
let registry = format!(
"pinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
"[settings]\npinned_categories = ['Source']\n[channels]\n'@mapped' = '{id}'\n[categories]\n"
);
for source in ["'@mapped', '@manual'", "'UCabcdefghijklmnopqrstuv'"] {
let original = format!("{registry}Source = [{source}]\n");
+1313
View File
File diff suppressed because it is too large Load Diff
+3225 -275
View File
File diff suppressed because one or more lines are too long
+974
View File
@@ -0,0 +1,974 @@
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions, ReadDir},
io::{self, Read, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
use serde::Deserialize;
use toml_edit::{Array, ArrayOfTables, DocumentMut, Table, value};
use crate::youtube::{Video, valid_video_id, validate_video};
const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
const MAX_LINE: usize = 16 * 1024;
const STREAM_BYTES: usize = 16 * 1024;
const MAX_STREAMS: usize = 4096;
static NEXT_NAME: AtomicU64 = AtomicU64::new(0);
fn invalid(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
struct StoredFile {
path: PathBuf,
original: Option<Vec<u8>>,
}
impl StoredFile {
fn open(path: PathBuf) -> io::Result<(Self, DocumentMut)> {
let original = read_bytes(&path, MAX_STATE_BYTES)?;
let document = match &original {
Some(bytes) => std::str::from_utf8(bytes)
.map_err(|e| invalid(format!("{} is not UTF-8: {e}", path.display())))?
.parse::<DocumentMut>()
.map_err(|e| {
invalid(format!(
"Invalid {}: {e}; repair this file before retrying",
path.display()
))
})?,
None => DocumentMut::new(),
};
if original.is_some() && document.get("version").and_then(|v| v.as_integer()) != Some(1) {
return Err(invalid(format!("{} requires version = 1", path.display())));
}
Ok((Self { path, original }, document))
}
fn save(&mut self, document: DocumentMut) -> io::Result<()> {
let contents = document.to_string().into_bytes();
atomic_write(&self.path, self.original.as_deref(), &contents)?;
self.original = Some(contents);
Ok(())
}
}
#[derive(Clone, Copy)]
pub enum CollectionKind {
WatchLater,
Saved,
}
struct Collection {
file: StoredFile,
videos: Vec<Video>,
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,
streams: Vec<ProgressStream>,
discovery: Option<ReadDir>,
next_stream: usize,
notify_changed: bool,
}
impl State {
pub fn open(config_path: &Path) -> io::Result<Self> {
let parent = config_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
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 {
watch_later,
saved_videos,
watch_state,
watched,
pending: BTreeSet::new(),
sessions: parent.join("playback-sessions"),
streams: Vec::new(),
discovery: None,
next_stream: 0,
notify_changed: false,
})
}
fn collection(&self, kind: CollectionKind) -> &Collection {
match kind {
CollectionKind::WatchLater => &self.watch_later,
CollectionKind::Saved => &self.saved_videos,
}
}
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, kind: CollectionKind, video: &Video) -> io::Result<()> {
self.collection_mut(kind).save(video)
}
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> {
if self.streams.len() >= MAX_STREAMS {
return Err(invalid(
"Playback session limit reached; remove inactive playback-sessions directories and restart",
));
}
fs::create_dir_all(self.sessions.parent().unwrap_or(Path::new(".")))?;
private_directory(&self.sessions, true)?;
let directory = loop {
let path = self.sessions.join(format!(
"session-{}-{}",
std::process::id(),
NEXT_NAME.fetch_add(1, Ordering::Relaxed)
));
match create_private_directory(&path) {
Ok(()) => break path,
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
};
let path = directory.join("progress.jsonl");
self.streams.push(ProgressStream::new(path.clone()));
Ok(path)
}
pub fn poll_progress(&mut self) -> io::Result<bool> {
let mut error = self.discover().err();
let count = self.streams.len().min(16);
for _ in 0..count {
if self.next_stream >= self.streams.len() {
self.next_stream = 0;
}
let stream = &mut self.streams[self.next_stream];
self.next_stream += 1;
if let Err(e) = stream.poll(&mut self.pending, &self.watched)
&& error.is_none()
{
error = Some(e);
}
}
self.pending.retain(|id| !self.watched.contains(id));
if !self.pending.is_empty() {
self.watch_state.save(watched_document(
self.watched.iter().chain(self.pending.iter()),
))?;
self.watched.append(&mut self.pending);
self.notify_changed = true;
}
// Preserve notification after reporting a bad record; siblings were still processed.
if let Some(error) = error {
return Err(error);
}
Ok(std::mem::take(&mut self.notify_changed))
}
fn discover(&mut self) -> io::Result<()> {
match private_directory(&self.sessions, false) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
}
if self.discovery.is_none() {
self.discovery = Some(fs::read_dir(&self.sessions)?);
}
for _ in 0..32 {
let Some(entry) = self.discovery.as_mut().unwrap().next() else {
self.discovery = None;
break;
};
let entry = entry?;
if !entry.file_name().to_string_lossy().starts_with("session-") {
continue;
}
let path = entry.path().join("progress.jsonl");
if self.streams.iter().any(|stream| stream.path == path) {
continue;
}
if private_directory(&entry.path(), false).is_err() {
continue;
}
// Failed launchers leave empty private directories; only bound identities are discoverable.
if !matches!(fs::symlink_metadata(entry.path().join("video-id")), Ok(m) if m.is_file())
{
continue;
}
if self.streams.len() >= MAX_STREAMS {
break;
}
self.streams.push(ProgressStream::new(path));
}
// ponytail: retain reports for live GUI writers, capped at 4096 monitored sessions;
// add explicit inactive-session archival if long-lived installations hit this ceiling.
Ok(())
}
}
fn validate_saved(video: &mut Video) -> io::Result<()> {
let date = video.publication_date.clone();
if let Some(date) = &date {
let raw = date.strip_prefix('~').unwrap_or(date);
let compact = raw.replace('-', "");
if !(raw.len() == 10 && raw.as_bytes()[4] == b'-' && raw.as_bytes()[7] == b'-')
|| compact.len() != 8
|| !compact.bytes().all(|b| b.is_ascii_digit())
|| time::Date::from_calendar_date(
compact[..4]
.parse()
.map_err(|_| invalid("Invalid saved publication date"))?,
compact[4..6]
.parse::<u8>()
.ok()
.and_then(|m| m.try_into().ok())
.ok_or_else(|| invalid("Invalid saved publication month"))?,
compact[6..]
.parse()
.map_err(|_| invalid("Invalid saved publication day"))?,
)
.is_err()
{
return Err(invalid(
"Saved publication date must be a valid YYYY-MM-DD or ~YYYY-MM-DD",
));
}
}
validate_video(video).map_err(invalid)?;
video.publication_date = date;
if let Some(handle) = &video.uploader_id
&& (!handle.starts_with('@') || crate::config::normalize_channel(handle).is_err())
{
return Err(invalid("Saved uploader_id must be a valid YouTube @handle"));
}
if video.channel_id.is_none() && video.uploader_id.is_none() {
return Err(invalid(
"Video has no channel identity; refresh its metadata before saving",
));
}
Ok(())
}
fn load_videos(document: &DocumentMut) -> io::Result<(Vec<Video>, BTreeSet<String>)> {
if document
.iter()
.any(|(key, _)| !matches!(key, "version" | "videos"))
{
return Err(invalid("Unknown watch-later field"));
}
let Some(item) = document.get("videos") else {
return Ok((Vec::new(), BTreeSet::new()));
};
if item.as_array().is_some_and(|a| a.is_empty()) {
return Ok((Vec::new(), BTreeSet::new()));
}
let tables = item
.as_array_of_tables()
.ok_or_else(|| invalid("videos must be an array of tables"))?;
let mut videos = Vec::new();
let mut ids = BTreeSet::new();
for table in tables {
let mut fields = serde_json::Map::new();
for (key, item) in table.iter() {
let field = match key {
"id" | "title" | "channel" | "channel_id" | "uploader_id" | "upload_date" => {
serde_json::Value::String(
item.as_str()
.ok_or_else(|| invalid(format!("Video {key} must be a string")))?
.into(),
)
}
"timestamp" => serde_json::Value::from(
item.as_integer()
.ok_or_else(|| invalid("Video timestamp must be an integer"))?,
),
_ => return Err(invalid(format!("Unknown saved video field {key:?}"))),
};
fields.insert(key.into(), field);
}
let mut video: Video = serde_json::from_value(serde_json::Value::Object(fields))
.map_err(|e| invalid(format!("Invalid saved video: {e}")))?;
validate_saved(&mut video)?;
if ids.insert(video.id.clone()) {
videos.push(video);
}
}
Ok((videos, ids))
}
fn load_watched(document: &DocumentMut) -> io::Result<BTreeSet<String>> {
if document
.iter()
.any(|(key, _)| !matches!(key, "version" | "watched"))
{
return Err(invalid("Unknown watch-state field"));
}
let Some(item) = document.get("watched") else {
return Ok(BTreeSet::new());
};
item.as_array()
.ok_or_else(|| invalid("watched must be an array of YouTube IDs"))?
.iter()
.map(|v| {
let id = v
.as_str()
.filter(|id| valid_video_id(id))
.ok_or_else(|| invalid("Invalid watched YouTube ID"))?;
Ok(id.to_owned())
})
.collect()
}
fn video_document<'a>(videos: impl Iterator<Item = &'a Video>) -> DocumentMut {
let mut document = DocumentMut::new();
document["version"] = value(1);
let mut tables = ArrayOfTables::new();
for video in videos {
let mut table = Table::new();
table["id"] = value(video.id.as_str());
table["title"] = value(video.title.as_str());
for (key, field) in [
("channel", &video.channel),
("channel_id", &video.channel_id),
("uploader_id", &video.uploader_id),
("upload_date", &video.publication_date),
] {
if let Some(field) = field {
table[key] = value(field.as_str());
}
}
if let Some(timestamp) = video.timestamp {
table["timestamp"] = value(timestamp);
}
tables.push(table);
}
document["videos"] = if tables.is_empty() {
value(Array::new())
} else {
toml_edit::Item::ArrayOfTables(tables)
};
document
}
fn watched_document<'a>(watched: impl Iterator<Item = &'a String>) -> DocumentMut {
let mut document = DocumentMut::new();
document["version"] = value(1);
let mut ids = Array::new();
for id in watched {
ids.push(id.as_str());
}
document["watched"] = value(ids);
document
}
fn regular_file(path: &Path, private: bool) -> io::Result<File> {
let mut options = OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
}
let file = options.open(path)?;
let metadata = file.metadata()?;
if !metadata.is_file() {
return Err(invalid(format!(
"{} must be a regular file",
path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// SAFETY: geteuid has no preconditions.
if metadata.uid() != unsafe { libc::geteuid() } || (private && metadata.mode() & 0o077 != 0)
{
return Err(invalid(format!(
"{} must be owned by you with owner-only permissions",
path.display()
)));
}
}
Ok(file)
}
fn read_bytes(path: &Path, limit: u64) -> io::Result<Option<Vec<u8>>> {
let file = match regular_file(path, false) {
Ok(file) => file,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
let mut bytes = Vec::new();
file.take(limit + 1).read_to_end(&mut bytes)?;
if bytes.len() as u64 > limit {
return Err(invalid(format!(
"{} exceeds the {} byte limit",
path.display(),
limit
)));
}
Ok(Some(bytes))
}
fn atomic_write(path: &Path, original: Option<&[u8]>, contents: &[u8]) -> io::Result<()> {
if contents.len() as u64 > MAX_STATE_BYTES {
return Err(invalid("Watch state exceeds the 8 MiB limit"));
}
let parent = path.parent().unwrap_or(Path::new("."));
fs::create_dir_all(parent)?;
let (temporary, mut file) = loop {
let path = parent.join(format!(
".watch-{}-{}.tmp",
std::process::id(),
NEXT_NAME.fetch_add(1, Ordering::Relaxed)
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
match options.open(&path) {
Ok(file) => break (path, file),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
};
let result = (|| {
file.write_all(contents)?;
file.sync_all()?;
if read_bytes(path, MAX_STATE_BYTES)?.as_deref() != original {
return Err(io::Error::other(format!(
"{} changed on disk; restart Tubular before retrying (no changes written)",
path.display()
)));
}
if original.is_none() {
fs::hard_link(&temporary, path)?;
} else {
fs::rename(&temporary, path)?;
}
Ok(())
})();
drop(file);
let _ = fs::remove_file(temporary);
result
}
fn create_private_directory(path: &Path) -> io::Result<()> {
let mut builder = fs::DirBuilder::new();
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
builder.mode(0o700);
}
builder.create(path)
}
fn private_directory(path: &Path, create: bool) -> io::Result<()> {
if create {
match create_private_directory(path) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e),
}
}
let metadata = fs::symlink_metadata(path)?;
if !metadata.is_dir() {
return Err(invalid(format!(
"{} must be a plain private directory, not a symlink",
path.display()
)));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// SAFETY: geteuid has no preconditions.
if metadata.uid() != unsafe { libc::geteuid() } || metadata.mode() & 0o077 != 0 {
return Err(invalid(format!(
"{} must be owned by you with permissions 0700",
path.display()
)));
}
}
Ok(())
}
pub(crate) fn register_identity(progress_file: &Path, id: &str) -> io::Result<()> {
if !valid_video_id(id)
|| progress_file
.file_name()
.is_none_or(|name| name != "progress.jsonl")
{
return Err(invalid(
"Invalid playback reporting identity or destination",
));
}
let directory = progress_file
.parent()
.ok_or_else(|| invalid("Missing private session directory"))?;
private_directory(directory, false)?;
private_directory(
directory
.parent()
.ok_or_else(|| invalid("Missing playback-sessions directory"))?,
false,
)?;
match fs::symlink_metadata(progress_file) {
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Ok(_) => {
return Err(invalid(
"Playback report destination must not already exist",
));
}
Err(e) => return Err(e),
}
atomic_write(&directory.join("video-id"), None, id.as_bytes())
}
#[derive(Deserialize)]
struct ProgressRecord {
version: u32,
event: String,
media_url: String,
position_seconds: f64,
duration_seconds: Option<f64>,
completion_eligible: bool,
}
struct ProgressStream {
path: PathBuf,
id: Option<String>,
file: Option<File>,
partial: Vec<u8>,
discarding: bool,
failed: bool,
line: u64,
}
impl ProgressStream {
fn new(path: PathBuf) -> Self {
Self {
path,
id: None,
file: None,
partial: Vec::new(),
discarding: false,
failed: false,
line: 0,
}
}
fn poll(
&mut self,
pending: &mut BTreeSet<String>,
watched: &BTreeSet<String>,
) -> io::Result<()> {
if self.failed {
return Ok(());
}
match self.read(pending, watched) {
Ok(error) => match error {
Some(e) => Err(e),
None => Ok(()),
},
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) => {
self.failed = true;
Err(invalid(format!("Cannot read {}: {e}", self.path.display())))
}
}
}
fn read(
&mut self,
pending: &mut BTreeSet<String>,
watched: &BTreeSet<String>,
) -> io::Result<Option<io::Error>> {
if self.file.is_none() {
private_directory(self.path.parent().unwrap(), false)?;
let marker = self.path.with_file_name("video-id");
let mut bytes = Vec::new();
regular_file(&marker, true)?
.take(65)
.read_to_end(&mut bytes)?;
let id = std::str::from_utf8(&bytes)
.ok()
.filter(|id| valid_video_id(id))
.ok_or_else(|| invalid("Invalid video-id session marker"))?;
self.id = Some(id.into());
self.file = Some(regular_file(&self.path, true)?);
}
let mut buffer = [0; STREAM_BYTES];
let size = self.file.as_mut().unwrap().read(&mut buffer)?;
let mut error = None;
for &byte in &buffer[..size] {
if byte == b'\n' {
self.line += 1;
if !self.discarding
&& let Err(e) = self.record(pending, watched)
&& error.is_none()
{
error = Some(e);
}
self.partial.clear();
self.discarding = false;
} else if !self.discarding {
if self.partial.len() == MAX_LINE {
self.partial.clear();
self.discarding = true;
if error.is_none() {
error = Some(self.record_error("line exceeds 16 KiB"));
}
} else {
self.partial.push(byte);
}
}
}
Ok(error)
}
fn record_error(&self, detail: impl std::fmt::Display) -> io::Error {
invalid(format!(
"Invalid playback report {} line {}: {detail}; inspect this record (other sessions continue)",
self.path.display(),
self.line.max(1)
))
}
fn record(&self, pending: &mut BTreeSet<String>, watched: &BTreeSet<String>) -> io::Result<()> {
let record: ProgressRecord =
serde_json::from_slice(&self.partial).map_err(|e| self.record_error(e))?;
if record.version != 1 || !matches!(record.event.as_str(), "progress" | "ended" | "closed")
{
return Err(
self.record_error("expected version 1 and progress, ended, or closed event")
);
}
let id = record
.media_url
.strip_prefix("https://www.youtube.com/watch?v=")
.filter(|id| valid_video_id(id))
.ok_or_else(|| self.record_error("expected canonical YouTube video URL"))?;
if !record.position_seconds.is_finite()
|| record.position_seconds < 0.0
|| record
.duration_seconds
.is_some_and(|d| !d.is_finite() || d <= 0.0)
{
return Err(self.record_error(
"position must be finite/nonnegative and duration positive/finite or null",
));
}
if self.id.as_deref() == Some(id)
&& record.completion_eligible
&& record
.duration_seconds
.is_some_and(|d| record.position_seconds / d >= 0.95)
&& !watched.contains(id)
&& !pending.contains(id)
{
pending.insert(id.into());
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDirectory(PathBuf);
impl TestDirectory {
fn new() -> Self {
loop {
let path = std::env::temp_dir().join(format!(
"tubular-watch-test-{}-{}",
std::process::id(),
NEXT_NAME.fetch_add(1, Ordering::Relaxed)
));
match create_private_directory(&path) {
Ok(()) => return Self(path),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
Err(e) => panic!("{e}"),
}
}
}
fn config(&self) -> PathBuf {
self.0.join("config.toml")
}
}
impl Drop for TestDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn video() -> Video {
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: Some("jawed".into()),
channel_id: Some("UC4QobU6STFB0P71PMvOGN5A".into()),
uploader_id: Some("@jawed".into()),
timestamp: Some(1_114_300_000),
publication_date: Some("2005-04-23".into()),
}
}
fn append(path: &Path, bytes: &[u8]) {
let mut options = OpenOptions::new();
options.write(true).append(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
options.open(path).unwrap().write_all(bytes).unwrap();
}
fn record(id: &str, event: &str, eligible: bool, position: f64) -> String {
format!(
"{{\"version\":1,\"event\":\"{event}\",\"media_url\":\"https://www.youtube.com/watch?v={id}\",\"position_seconds\":{position},\"duration_seconds\":100,\"completion_eligible\":{eligible}}}\n"
)
}
#[test]
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();
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(
&report,
record(&video.id, "progress", true, 95.0).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
let mut restarted = State::open(&directory.config()).unwrap();
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(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(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());
}
#[test]
fn malformed_and_externally_changed_state_is_never_overwritten() {
let directory = TestDirectory::new();
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(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",
)
.unwrap();
assert!(State::open(&directory.config()).is_err());
assert_eq!(
fs::read_to_string(directory.0.join("watch-state.toml")).unwrap(),
"version = 1\nwatched = [\"--unsafe\"]\n"
);
}
#[test]
fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file().unwrap();
register_identity(&report, &video().id).unwrap();
append(
&report,
record(&video().id, "progress", true, 95.0).as_bytes(),
);
let path = directory.0.join("watch-state.toml");
fs::write(&path, "malformed state").unwrap();
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert_eq!(fs::read_to_string(&path).unwrap(), "malformed state");
fs::remove_file(&path).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
#[test]
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file().unwrap();
register_identity(&report, &video().id).unwrap();
let line = record(&video().id, "ended", true, 95.0);
append(&report, &line.as_bytes()[..line.len() - 1]);
assert!(!state.poll_progress().unwrap());
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(!state.poll_progress().unwrap());
let bad = state.progress_file().unwrap();
register_identity(&bad, "rQ_J9WH6CGk").unwrap();
append(&bad, b"not json\n");
append(&report, b"\n");
assert!(state.poll_progress().is_err());
assert!(state.is_watched(&video().id));
assert!(state.poll_progress().unwrap());
assert!(!state.poll_progress().unwrap());
}
#[test]
fn wrong_identity_ineligible_positions_and_untrusted_reports_do_not_mark_watched() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let report = state.progress_file().unwrap();
register_identity(&report, &video().id).unwrap();
for line in [
record("rQ_J9WH6CGk", "progress", true, 100.0),
record(&video().id, "progress", false, 100.0),
record(&video().id, "closed", true, 94.9),
] {
append(&report, line.as_bytes());
}
assert!(!state.poll_progress().unwrap());
assert!(!state.is_watched(&video().id));
assert!(!state.is_watched("rQ_J9WH6CGk"));
for line in [
record(&video().id, "unknown", true, 100.0),
record(&video().id, "progress", true, -1.0),
record(&video().id, "progress", true, 100.0).replace("\"version\":1", "\"version\":2"),
record(&video().id, "progress", true, 100.0)
.replace("\"duration_seconds\":100", "\"duration_seconds\":0"),
] {
append(&report, line.as_bytes());
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
#[cfg(unix)]
{
let symlink = state.progress_file().unwrap();
register_identity(&symlink, &video().id).unwrap();
let external = directory.0.join("external.jsonl");
append(
&external,
record(&video().id, "progress", true, 100.0).as_bytes(),
);
std::os::unix::fs::symlink(&external, &symlink).unwrap();
assert!(state.poll_progress().is_err());
assert!(!state.is_watched(&video().id));
assert!(!state.poll_progress().unwrap());
}
}
}
+110 -45
View File
@@ -1,12 +1,13 @@
use std::{
ffi::OsStr,
io::{self, Read},
path::Path,
process::{Child, Command, Stdio},
sync::mpsc::{self, Receiver, TryRecvError},
thread,
};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
pub const PAGE_SIZE: usize = 20;
@@ -16,7 +17,7 @@ pub struct Page {
pub cutoff: Option<i64>,
}
#[derive(Deserialize)]
#[derive(Clone, Deserialize, Serialize)]
pub struct Video {
pub id: String,
pub title: String,
@@ -30,8 +31,10 @@ pub struct Video {
}
#[derive(Deserialize)]
struct SearchResults {
entries: Vec<Option<Video>>,
struct SearchResults<'a> {
// Borrow raw rows so one schema error cannot abort siblings or copy unused metadata.
#[serde(borrow)]
entries: Vec<&'a serde_json::value::RawValue>,
channel: Option<String>,
channel_id: Option<String>,
uploader_id: Option<String>,
@@ -41,10 +44,15 @@ pub fn search_results(output: &str) -> Result<Page, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid search response: {error}"))?;
let has_more = results.entries.len() >= PAGE_SIZE;
let mut videos: Vec<Video> = results.entries.into_iter().flatten().collect();
for video in &mut videos {
validate_video(video).map_err(|error| format!("Search returned {error}"))?;
}
let videos = results
.entries
.into_iter()
.filter_map(|entry| {
let mut video = serde_json::from_str(entry.get()).ok()?;
validate_video(&mut video).ok()?;
Some(video)
})
.collect();
Ok(Page {
videos,
has_more,
@@ -60,14 +68,14 @@ fn valid_channel_id(id: &str) -> bool {
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}
fn valid_video_id(id: &str) -> bool {
pub(crate) fn valid_video_id(id: &str) -> bool {
id.len() == 11
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
}
fn validate_video(video: &mut Video) -> Result<(), String> {
pub(crate) fn validate_video(video: &mut Video) -> Result<(), String> {
if !valid_video_id(&video.id) {
return Err("an invalid YouTube video ID".into());
}
@@ -128,19 +136,12 @@ pub fn feed_results(output: &str) -> Result<Page, String> {
return Err("Feed returned an invalid YouTube channel ID".into());
}
// Raw rows include unavailable videos; filtering them must not end pagination early.
if playlist.entries.len() >= PAGE_SIZE {
has_more = true;
let oldest = playlist
.entries
.iter()
.flatten()
.filter_map(|video| video.timestamp)
.min();
// A full undated/unavailable page may hide newer uploads on its next page.
unknown_frontier |= oldest.is_none();
cutoff = cutoff.max(oldest);
}
for mut video in playlist.entries.into_iter().flatten() {
let full_page = playlist.entries.len() >= PAGE_SIZE;
let first_video = videos.len();
for entry in playlist.entries {
let Ok(mut video) = serde_json::from_str::<Video>(entry.get()) else {
continue;
};
if video.channel_id.is_none() {
video.channel_id = playlist.channel_id.clone();
}
@@ -150,10 +151,21 @@ pub fn feed_results(output: &str) -> Result<Page, String> {
if video.channel.is_none() {
video.channel = playlist.channel.clone();
}
validate_video(&mut video).map_err(|error| format!("Feed returned {error}"))?;
if validate_video(&mut video).is_ok() {
videos.push(video);
}
}
if full_page {
has_more = true;
let oldest = videos[first_video..]
.iter()
.filter_map(|video| video.timestamp)
.min();
// A full undated/unavailable page may hide newer uploads on its next page.
unknown_frontier |= oldest.is_none();
cutoff = cutoff.max(oldest);
}
}
if playlists == 0 {
return Err("Feed returned no playlist data".into());
}
@@ -452,19 +464,22 @@ pub struct PlayerLaunch {
}
impl PlayerLaunch {
pub fn start(id: &str, player: &OsStr) -> io::Result<Self> {
pub fn start(id: &str, player: &OsStr, progress_file: &Path) -> io::Result<Self> {
if !valid_video_id(id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid YouTube video ID",
));
}
crate::video_state::register_identity(progress_file, id)?;
let mut player = isolated_command(player)
.args([
"--new-instance",
"--url",
&format!("https://www.youtube.com/watch?v={id}"),
])
.arg("--progress-file")
.arg(progress_file)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
@@ -508,7 +523,7 @@ mod tests {
fn player_launch_rejects_unsafe_video_ids_before_spawning() {
for id in ["", "--help", "jNQXAC9IVRw&list=other", "日本語"] {
assert!(matches!(
PlayerLaunch::start(id, OsStr::new("terebi")),
PlayerLaunch::start(id, OsStr::new("terebi"), Path::new("progress.jsonl")),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
@@ -561,7 +576,66 @@ mod tests {
}
#[test]
fn search_handles_unavailable_entries_and_rejects_invalid_ids() {
fn bad_video_entries_do_not_discard_valid_results() {
for parse in [search_results, feed_results] {
for bad in [
r#"{"id":"UC4QobU6STFB0P71PMvOGN5A","title":"Channel result"}"#,
r#"{"id":"--bad","title":"Unsafe ID"}"#,
r#"{"id":"jNQXAC9IVRw","title":"Bad channel","channel_id":"malformed"}"#,
r#"{"id":"jNQXAC9IVRw","title":"Bad handle","uploader_id":"@jawed\u001b"}"#,
r#"{"id":42,"title":"Wrong ID type"}"#,
r#"{"id":"jNQXAC9IVRw","title":null}"#,
r#"{"id":"jNQXAC9IVRw","title":"Bad date type","timestamp":"yesterday"}"#,
r#"{"id":"jNQXAC9IVRw","title":"Bad channel type","channel":[]}"#,
r#"{"id":"jNQXAC9IVRw"}"#,
"null",
"42",
"[]",
"{}",
] {
let output = format!(
r#"{{"entries":[{{"id":"jNQXAC9IVRw","title":"Before"}},{bad},{{"id":"rQ_J9WH6CGk","title":"After"}}]}}"#,
);
let page = parse(&output).unwrap_or_else(|error| {
panic!("bad entry {bad} discarded the entire page: {error}")
});
assert_eq!(
page.videos
.iter()
.map(|video| video.title.as_str())
.collect::<Vec<_>>(),
["Before", "After"]
);
}
}
}
#[test]
fn discarded_entries_still_count_for_pagination_but_not_feed_frontiers() {
let bad = serde_json::json!({"id":"invalid", "title":"Bad", "timestamp":-500});
let mut entries = vec![bad; PAGE_SIZE];
for parse in [search_results, feed_results] {
let page = parse(&serde_json::json!({"entries":entries}).to_string()).unwrap();
assert!(page.videos.is_empty());
assert!(
page.has_more,
"an all-invalid full page must not stop pagination"
);
assert_eq!(page.cutoff, None);
}
entries[0] = serde_json::json!({"id":"jNQXAC9IVRw","title":"Valid","timestamp":100});
let page = feed_results(&serde_json::json!({"entries":entries}).to_string()).unwrap();
assert!(page.has_more);
assert_eq!(
page.cutoff,
Some(100),
"invalid dates must not release old feed rows"
);
assert_eq!(page.videos[0].id, "jNQXAC9IVRw");
}
#[test]
fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() {
let videos = search_results(
r#"{"entries":[null,{"id":"rQ_J9WH6CGk","title":"Rust\u001b","channel":null,"uploader_id":"@RustVideos"}]}"#,
)
@@ -571,17 +645,15 @@ mod tests {
assert_eq!(videos[0].channel, None);
assert_eq!(videos[0].channel_id, None);
assert_eq!(videos[0].uploader_id.as_deref(), Some("@RustVideos"));
assert!(
search_results(
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Bad","channel_id":"not-a-channel"}]}"#
)
.is_err()
);
assert!(search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Unsafe","uploader_id":"@jawed\u001b"}]}"#
).is_err());
assert!(search_results(r#"{"entries":[{"id":"--bad","title":"Bad"}]}"#).is_err());
assert!(search_results("not JSON").is_err());
for output in [
"not JSON",
"{}",
r#"{"entries":null}"#,
r#"{"entries":{}}"#,
r#"{"entries":[{"id":} ]}"#,
] {
assert!(search_results(output).is_err());
}
}
#[test]
@@ -620,13 +692,6 @@ mod tests {
.videos;
assert_eq!(unavailable[0].channel_id, None);
assert!(feed_results(r#"{"channel_id":"malformed","entries":[]}"#).is_err());
assert!(
feed_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Bad","channel_id":"malformed"}]}"#
)
.is_err()
);
assert!(feed_results(r#"{"entries":[{"id":"bad","title":"Bad"}]}"#).is_err());
assert!(feed_results("").is_err());
assert!(feed_results("{}").is_err());
assert!(feed_results("{not JSON}").is_err());