Compare commits

..

3 Commits

16 changed files with 699 additions and 50 deletions
Generated
+1
View File
@@ -1517,6 +1517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
dependencies = [
"base64 0.23.1",
"flate2",
"log",
"percent-encoding",
"rustls",
+1 -1
View File
@@ -16,7 +16,7 @@ toml_edit = "0.25.17"
time = "0.3.55"
unicode-normalization = "0.1.25"
unicode-segmentation = "1.13.3"
ureq = { version = "3.4.2", default-features = false, features = ["rustls"] }
ureq = { version = "3.4.2", default-features = false, features = ["rustls", "gzip"] }
[target."cfg(unix)".dependencies]
libc = "0.2.190"
+12 -1
View File
@@ -80,7 +80,9 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it
### AniList catalog and on-demand sub playback
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: a 20-second timeout, no redirects, 2 MiB response limit, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: gzip response compression, a 20-second timeout, no redirects, 2 MiB limits on both wire and decompressed response bodies, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
Opening an AniList search result builds catalog episode rows immediately from the metadata already fetched, without a second detail request. `r` still fetches fresh metadata; opening a saved-library show also fetches current detail. Back/forward retains the loaded screen.
**Catalog episode numbers are not source availability.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons.
@@ -176,6 +178,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `c` | Browse saved categories |
| `u` | Browse an individual saved channel |
| `U` (Shift-U) | Open the selected video's channel |
| `L` (Shift-L, in a channel view) | Browse that channel's public playlists |
| `s` | Edit the selected channel's tags |
| `S` | Edit tags for all saved channels in the current category |
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
@@ -259,6 +262,12 @@ Combined feeds are not individual categories: `S`, `R`, and `Alt-w` / `Alt-v` re
Press **`U` (Shift-U)** on a video to browse its creator directly, including channels you have not saved. This replaces the current temporary browsing tab without adding a subscription. Channel IDs are preferred; a valid handle is used when an ID is unavailable. Missing channel metadata leaves the current view unchanged. Unsaved channel views survive configuration changes for the session; explicitly removing a saved subscription still removes its channel view.
### Channel playlists (`L`)
Open a channel with `u` or `U`, then press **`L` (Shift-L)** in normal mode. Its public playlists load into a searchable selector: `j` / `k` or arrows move, `i` filters by title, and Enter opens the selected playlist. In filter mode, arrows or Ctrl-J / Ctrl-K move; Esc returns to normal mode, then closes. Closing cancels an in-flight playlist lookup; close and reopen to retry a failed lookup. Configure the shortcut with `channel.playlists`.
Playlist videos use the ordinary video table, 60-row pagination, playback checks, previews, and collection controls. They retain playlist order rather than sorting newest-first; repeated video IDs appear once, like other video views. Playlist views share the temporary tab without changing subscriptions. `-` returns to the channel and `+` restores the playlist's cached rows and selection. `r` refreshes the playlist from page one.
### Channel tags (`s`, or `S` for bulk editing)
When search results omit a channel handle, `s` reuses its saved mapping or looks up the handle from the channel's canonical ID before opening the editor. Esc cancels a pending lookup without saving. Failed lookups or mismatched channel metadata leave the config unchanged.
@@ -354,6 +363,8 @@ 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. Session directories are `0700`; snapshots, identity markers and Terebi-created reports are owner-only. New Miruro launches contain no resolved-source documents or CDN credentials. Existing sessions from earlier versions remain readable and are not automatically removed. Sessions are retained because CLI exit does not prove the independent GUI has finished; up to 4,096 are monitored. Close the corresponding players before removing their inactive session directories, then restart Tubular. Never remove an active player's directory.
Progress polling keeps byte offsets and partial records in memory, opening and closing each report for each poll. Historical sessions do not retain open report descriptors; late writes from running players are still consumed.
## Saved videos
Saved videos is a separate stored collection from watch later, deduplicated by YouTube video ID or stable Miruro episode identity, with provider-specific views. It uses offline saved metadata; YouTube entries follow **current channel category memberships**, not categories stored on individual videos. Its All categories (combined) list contains only YouTube videos; Miruro opens a flat saved-episode list.
+1 -5
View File
@@ -88,11 +88,7 @@ impl Client {
if response.body().mime_type() != Some("application/json") {
return Err(invalid("AniList returned non-JSON content"));
}
let body = response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT)
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
decode(&body)
}
+11 -6
View File
@@ -135,6 +135,7 @@ actions! {
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"];
ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"];
@@ -151,15 +152,15 @@ actions! {
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"];
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"];
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
SelectorEdit, "selector.edit", "Edit the category, channel, or collection filter", &[Context::SelectorNormal], ["i"];
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"];
SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"];
SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"];
SelectorClose, "selector.close", "Close the category, channel, or collection picker", &[Context::SelectorNormal], ["escape"];
SelectorClose, "selector.close", "Close the category, channel, playlist, or collection picker", &[Context::SelectorNormal], ["escape"];
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
@@ -519,6 +520,10 @@ impl Bindings {
),
(&[VideoChannel], "Browse the selected video's channel"),
(&[ChannelBrowse], "Choose a saved channel"),
(
&[ChannelPlaylists],
"Browse the current channel's playlists",
),
(&[CategoryBrowse], "Choose a saved category"),
(&[ChannelTags], "Edit the selected channel's tags"),
(&[CategoryTags], "Edit all channels in a category"),
@@ -615,7 +620,7 @@ impl Bindings {
],
),
(
"CHANNEL / CATEGORY / COLLECTION SELECTOR",
"CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR",
&[
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
(&[SelectorEdit], "Enter filter INSERT mode"),
+73
View File
@@ -1,3 +1,76 @@
pub mod anilist;
pub mod anime;
pub mod miruro;
/// Bound both wire bytes and decoded text: ureq's limit precedes decompression.
fn read_catalog_body(body: &mut ureq::Body, limit: u64) -> std::io::Result<String> {
use std::io::Read;
let mut text = String::new();
body.with_config()
.limit(limit)
.reader()
.take(limit + 1)
.read_to_string(&mut text)?;
if text.len() as u64 > limit {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Catalog response exceeded decoded body limit",
));
}
Ok(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compressed_catalog_bodies_keep_the_decoded_size_limit() {
use std::{io::Write, net::TcpListener, thread};
// Gzip of 64 spaces: fits the 32-byte wire limit, exceeds the decoded limit.
const GZIP: &[u8] = &[
31, 139, 8, 0, 0, 0, 0, 0, 2, 255, 83, 80, 160, 12, 0, 0, 95, 100, 117, 189, 64, 0, 0,
0,
];
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
use std::io::{BufRead, BufReader};
for _ in 0..2 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut request = BufReader::new(&mut stream);
let mut line = String::new();
while request.read_line(&mut line).unwrap() > 0 {
if line == "\r\n" {
break;
}
line.clear();
}
write!(stream, "HTTP/1.1 200 OK\r\nContent-Encoding: gzip\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", GZIP.len()).unwrap();
stream.write_all(GZIP).unwrap();
}
});
let agent = ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(5)))
.build()
.new_agent();
let mut response = agent.get(&url).call().unwrap();
assert_eq!(
read_catalog_body(response.body_mut(), 64).unwrap(),
" ".repeat(64)
);
let mut response = agent.get(&url).call().unwrap();
assert_eq!(
read_catalog_body(response.body_mut(), 32)
.unwrap_err()
.kind(),
std::io::ErrorKind::InvalidData
);
server.join().unwrap();
}
}
+101 -15
View File
@@ -83,8 +83,10 @@
//! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?.
//! Normal-mode u opens a searchable saved-channel selector with the category browser's modal controls.
//! Normal-mode U opens the selected video's creator directly, without saving a subscription.
//! Normal-mode L in a channel opens its public playlists in the searchable selector.
//! Enter browses playlist videos in provider order with normal pagination, playback and view history.
//! Choices include untagged registrations and category-only channels, deduplicated with readable handle labels.
//! Channels, combined feeds and unpinned categories share one temporary tab; hidden views retain history.
//! Channels, playlists, combined feeds and unpinned categories share one temporary tab; hidden views retain history.
//! Channel views use the same pagination but extract only their stored channel identity; S stays category-only.
//! Unfiltered channel/feed/tag catalogs traverse uploads, then flatten Releases album tracks.
//! Release tracks share the raw-row budget; absent Releases tabs are normal, other failures are page errors.
@@ -212,6 +214,7 @@ enum ViewSource {
Category(String),
CategoryGroup(Vec<String>),
Channel(String),
Playlist(String),
WatchLater(Option<String>),
SavedVideos(Option<String>),
Watching,
@@ -225,7 +228,11 @@ impl ViewSource {
group: &'a [String],
) -> &'a [String] {
match self {
Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[],
Self::Search
| Self::Playlist(_)
| Self::WatchLater(_)
| Self::SavedVideos(_)
| Self::Watching => &[],
Self::Feed => feed,
Self::Category(name) => &config.categories[name],
Self::CategoryGroup(_) => group,
@@ -581,6 +588,7 @@ fn filter_choices(
enum BrowserKind {
Categories,
Channels,
Playlists,
WatchLater,
SavedVideos,
}
@@ -595,6 +603,7 @@ struct Browser {
matches: Vec<usize>,
selection: ListState,
error: Option<String>,
job: Option<Job>,
}
impl Browser {
@@ -605,8 +614,10 @@ impl Browser {
&mut self.matches,
&mut self.selection,
);
if self.kind != BrowserKind::Playlists {
self.error = None;
}
}
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
self.selection
@@ -645,6 +656,8 @@ struct KeyHints {
saved_videos: String,
watching: String,
group_save: String,
channel_playlists: String,
playlists: String,
}
impl KeyHints {
@@ -872,6 +885,12 @@ impl KeyHints {
key(Action::InfoHide)
),
],
channel_playlists: format!("· {} playlists ", key(Action::ChannelPlaylists)),
playlists: format!(
" Playlists · {} filter · {} browse ",
key(Action::SelectorEdit),
key(Action::SelectorOpen)
),
browser: [
format!(
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
@@ -1079,7 +1098,7 @@ impl App {
.filter(|(_, view)| match &view.source {
ViewSource::Search | ViewSource::Feed => true,
ViewSource::Category(name) => self.config.pinned_categories.contains(name),
ViewSource::CategoryGroup(_) | ViewSource::Channel(_)
ViewSource::CategoryGroup(_) | ViewSource::Channel(_) | ViewSource::Playlist(_)
| ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false,
})
.chain(self.temporary_view.as_ref().and_then(|source| {
@@ -1109,8 +1128,15 @@ impl App {
}
fn open_browser(&mut self, kind: BrowserKind) {
if kind == BrowserKind::Playlists
&& !matches!(self.views[self.view_index].source, ViewSource::Channel(_))
{
self.status = "Open a channel first to browse its playlists.".into();
return;
}
self.close_preview();
let choices: Vec<_> = match kind {
BrowserKind::Playlists => Vec::new(),
BrowserKind::Categories => self
.config
.categories
@@ -1168,12 +1194,43 @@ impl App {
choices,
selection: ListState::default(),
error: None,
job: None,
};
browser.refilter();
browser.selection.select(selected);
if kind == BrowserKind::Playlists
&& let ViewSource::Channel(channel) = &self.views[self.view_index].source
{
match Job::channel_playlists(channel) {
Ok(job) => browser.job = Some(job),
Err(error) => browser.error = Some(error.to_string()),
}
}
self.browser = Some(browser);
}
fn finish_playlists(&mut self, result: Result<String, String>) {
let Some(browser) = &mut self.browser else {
return;
};
browser.job = None;
match result.and_then(|output| youtube::channel_playlists(&output)) {
Ok(playlists) => {
browser.choices = playlists
.into_iter()
.map(|playlist| (playlist.title, ViewSource::Playlist(playlist.id)))
.collect();
browser.search_names = browser
.choices
.iter()
.map(|(title, _)| title.to_lowercase())
.collect();
browser.refilter();
}
Err(error) => browser.error = Some(error),
}
}
fn browse_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut browser = self.browser.take().expect("browser is open");
let action = if browser.mode == Mode::Insert {
@@ -1457,6 +1514,7 @@ impl App {
source,
ViewSource::CategoryGroup(_)
| ViewSource::Channel(_)
| ViewSource::Playlist(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
@@ -1515,6 +1573,9 @@ impl App {
}
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
Job::search(&view.submitted_query, view.next_start)
} else if let ViewSource::Playlist(id) = &view.source {
self.status = format!("Loading {} page {page}...", view.name);
Job::playlist(id, view.next_start)
} else {
let channels =
view.source
@@ -1586,7 +1647,7 @@ impl App {
(view.next_start - 1) / PAGE_SIZE + 1
);
}
if view.source != ViewSource::Search
if !matches!(view.source, ViewSource::Search | ViewSource::Playlist(_))
&& view
.source
.channels(&self.config, &self.feed_channels, &view.group_channels)
@@ -1612,14 +1673,15 @@ impl App {
fn finish_page(&mut self, result: Result<String, String>) {
self.job = None;
let parsed = result.and_then(|output| {
if self.views[self.view_index].source == ViewSource::Search {
youtube::search_results(&output)
} else {
youtube::feed_results(&output, &self.views[self.view_index].feed_requests)
}
let parsed = result.and_then(|output| match &self.views[self.view_index].source {
ViewSource::Search => youtube::search_results(&output),
ViewSource::Playlist(_) => youtube::playlist_results(&output),
_ => youtube::feed_results(&output, &self.views[self.view_index].feed_requests),
});
let feed = self.views[self.view_index].source != ViewSource::Search;
let feed = !matches!(
self.views[self.view_index].source,
ViewSource::Search | ViewSource::Playlist(_)
);
let replacing = !self.views[self.view_index].loaded;
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
Ok(()) if replacing => self.close_preview(),
@@ -1952,6 +2014,7 @@ impl App {
}
(matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity))
|| matches!(&view.source, ViewSource::Watching)
|| matches!(&view.source, ViewSource::Playlist(_))
|| matches!(&view.source, ViewSource::WatchLater(category) | ViewSource::SavedVideos(category) if category.as_ref().is_none_or(|name| config.categories.contains_key(name))))
.then_some(view)
}));
@@ -1972,6 +2035,7 @@ impl App {
if matches!(
&self.views[self.view_index].source,
ViewSource::CategoryGroup(_)
| ViewSource::Playlist(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
@@ -2259,6 +2323,7 @@ impl App {
view.source,
ViewSource::CategoryGroup(_)
| ViewSource::Channel(_)
| ViewSource::Playlist(_)
| ViewSource::WatchLater(_)
| ViewSource::SavedVideos(_)
| ViewSource::Watching
@@ -2633,6 +2698,14 @@ impl App {
}
fn update(&mut self) {
if let Some(result) = self
.browser
.as_mut()
.and_then(|browser| browser.job.as_mut())
.and_then(Job::poll)
{
self.finish_playlists(result);
}
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.finish_page(result);
}
@@ -2927,6 +3000,7 @@ impl App {
}
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels),
Some(Action::ChannelPlaylists) => self.open_browser(BrowserKind::Playlists),
Some(Action::VideoChannel) => self.open_video_channel(),
Some(Action::ChannelTags) => self.open_category_picker(),
Some(Action::CategoryTags) => self.open_bulk_category_picker(),
@@ -3166,6 +3240,7 @@ impl App {
ViewSource::SavedVideos(_) => self.key_hints.saved_videos.as_str(),
ViewSource::Watching => self.key_hints.watching.as_str(),
ViewSource::CategoryGroup(_) => self.key_hints.group_save.as_str(),
ViewSource::Channel(_) => self.key_hints.channel_playlists.as_str(),
_ => "",
},
self.job.is_some().then_some(spinner),
@@ -3279,7 +3354,7 @@ impl App {
);
}
self.draw_category_dialog(frame);
self.draw_browser(frame);
self.draw_browser(frame, elapsed);
self.draw_category_name_input(frame);
self.draw_help(frame);
self.draw_collection_removal(frame);
@@ -3331,19 +3406,24 @@ impl App {
frame.render_widget(block, popup);
}
fn draw_browser(&mut self, frame: &mut Frame) {
fn draw_browser(&mut self, frame: &mut Frame, elapsed: Duration) {
let Some(browser) = &mut self.browser else {
return;
};
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(match browser.kind {
.title(loading_title(
match browser.kind {
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
BrowserKind::SavedVideos => self.key_hints.browser[3].as_str(),
})
BrowserKind::Playlists => self.key_hints.playlists.as_str(),
},
"",
browser.job.is_some().then_some(ui::spinner(elapsed)),
))
.border_style(ui::ACCENT);
let [input, list, error] = Layout::vertical([
Constraint::Length(3),
@@ -3375,6 +3455,11 @@ impl App {
if browser.choices.is_empty() {
frame.render_widget(
Paragraph::new(match browser.kind {
BrowserKind::Playlists if browser.job.is_some() => "Loading playlists...",
BrowserKind::Playlists if browser.error.is_some() => {
"Could not load playlists. Close and reopen to retry."
}
BrowserKind::Playlists => "This channel has no public playlists.",
BrowserKind::Categories => {
"No categories yet. Use s on a video to save its channel."
}
@@ -3394,6 +3479,7 @@ impl App {
Paragraph::new(match browser.kind {
BrowserKind::Categories => "No matching categories.",
BrowserKind::Channels => "No matching channels.",
BrowserKind::Playlists => "No matching playlists.",
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
"No matching categories or combined collection."
}
+1 -6
View File
@@ -145,12 +145,7 @@ impl Client {
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
continue;
}
match response
.body_mut()
.with_config()
.limit(BODY_LIMIT)
.read_to_string()
{
match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) {
Ok(body) => {
self.preferred.store(index, Ordering::Relaxed);
return Ok(body);
+13
View File
@@ -734,8 +734,21 @@ impl Browser {
if let Some(media) = self.screen().selected_media().cloned()
&& !matches!(self.screen().content, Content::Series { .. })
{
if self.provider == Provider::AniList
&& matches!(self.screen().content, Content::Catalog { .. })
{
// AniList search already returns the same metadata as detail.
match miruro_browser::anilist_series(media.metadata) {
Ok(series) => self.push(Screen::new(Content::Series {
media: series.media,
data: Some(series.episodes),
})),
Err(error) => self.fail(error),
}
} else {
self.push(Screen::new(Content::Series { media, data: None }));
self.load_series();
}
} else {
self.play(false);
}
+8 -7
View File
@@ -1,7 +1,7 @@
use std::{
collections::BTreeSet,
fs::{self, File, OpenOptions, ReadDir},
io::{self, Read, Write},
io::{self, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
sync::atomic::{AtomicU64, Ordering},
};
@@ -1051,7 +1051,7 @@ struct ProgressStream {
seen: bool,
status_edited: bool,
started: bool,
file: Option<File>,
offset: u64,
partial: Vec<u8>,
discarding: bool,
failed: bool,
@@ -1068,7 +1068,7 @@ impl ProgressStream {
seen: false,
status_edited: false,
started: false,
file: None,
offset: 0,
partial: Vec::new(),
discarding: false,
failed: false,
@@ -1184,12 +1184,13 @@ impl ProgressStream {
pending: &mut BTreeSet<String>,
watched: &BTreeSet<String>,
) -> io::Result<Option<io::Error>> {
if self.file.is_none() {
self.prepare()?;
self.file = Some(regular_file(&self.path, true)?);
}
// Keep a cursor, not a descriptor, for each retained playback session.
let mut file = regular_file(&self.path, true)?;
file.seek(SeekFrom::Start(self.offset))?;
let mut buffer = [0; STREAM_BYTES];
let size = self.file.as_mut().unwrap().read(&mut buffer)?;
let size = file.read(&mut buffer)?;
self.offset += size as u64;
let mut error = None;
for &byte in &buffer[..size] {
if byte == b'\n' {
+127
View File
@@ -132,6 +132,12 @@ pub struct Page {
pub feed_progress: Vec<FeedProgress>,
}
#[derive(Deserialize)]
pub struct Playlist {
pub id: String,
pub title: String,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct Video {
pub id: String,
@@ -192,6 +198,97 @@ pub fn search_results(output: &str) -> Result<Page, String> {
})
}
pub fn channel_playlists(output: &str) -> Result<Vec<Playlist>, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid channel playlists response: {error}"))?;
let mut ids = BTreeSet::new();
Ok(results
.entries
.into_iter()
.filter_map(|entry| {
let mut playlist: Playlist = serde_json::from_str(entry.get()).ok()?;
if !valid_playlist_id(&playlist.id) {
return None;
}
playlist.title.retain(|c| !c.is_control());
if playlist.title.trim().is_empty() || !ids.insert(playlist.id.clone()) {
return None;
}
Some(playlist)
})
.collect())
}
pub fn playlist_results(output: &str) -> Result<Page, String> {
let results: SearchResults = serde_json::from_str(output)
.map_err(|error| format!("Invalid playlist response: {error}"))?;
if results
.channel_id
.as_deref()
.is_some_and(|id| !valid_channel_id(id))
|| results
.uploader_id
.as_deref()
.is_some_and(|handle| handle.chars().any(char::is_control))
{
return Err("Playlist returned invalid YouTube creator metadata".into());
}
let has_more = results.entries.len() >= PAGE_SIZE;
let videos = results
.entries
.into_iter()
.filter_map(|entry| {
let mut video = listed_video(entry)?;
// A playlist owner is not necessarily the creator of each video.
let same_creator = video
.channel_id
.as_ref()
.is_none_or(|id| Some(id) == results.channel_id.as_ref())
&& video
.uploader_id
.as_ref()
.is_none_or(|id| Some(id) == results.uploader_id.as_ref());
if same_creator && (video.channel_id.is_some() || video.uploader_id.is_some()) {
if video.channel_id.is_none() {
video.channel_id = results.channel_id.clone();
}
if video.uploader_id.is_none() {
video.uploader_id = results.uploader_id.clone();
}
if video.channel.is_none() {
video.channel = results.channel.clone();
}
}
validate_video(&mut video).ok()?;
Some(video)
})
.collect();
Ok(Page {
videos,
has_more,
feed_progress: Vec::new(),
})
}
fn valid_playlist_id(id: &str) -> bool {
if !id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
{
return false;
}
// Match yt-dlp's YouTube playlist families, including music releases.
matches!(id, "RDMM" | "WL" | "LL" | "LM")
|| [
"PL", "LL", "EC", "UU", "FL", "RD", "UL", "TL", "PU", "OLAK5uy_",
]
.iter()
.any(|prefix| {
id.strip_prefix(*prefix)
.is_some_and(|suffix| suffix.len() >= 10)
})
}
fn valid_channel_id(id: &str) -> bool {
id.len() == 24
&& id.starts_with("UC")
@@ -489,6 +586,36 @@ impl Job {
]))
}
pub fn channel_playlists(channel: &str) -> io::Result<Self> {
let channel = crate::config::normalize_channel(channel)?;
Self::spawn(extractor().args([
"--flat-playlist",
"--dump-single-json",
&format!("{channel}/playlists"),
]))
}
pub fn playlist(id: &str, start: usize) -> io::Result<Self> {
if !valid_playlist_id(id) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Invalid YouTube playlist ID",
));
}
let end = page_end(start)?;
Self::spawn(extractor().args([
"--flat-playlist",
"--playlist-start",
&start.to_string(),
"--playlist-end",
&end.to_string(),
"--extractor-args",
"youtubetab:approximate_date",
"--dump-single-json",
&format!("https://www.youtube.com/playlist?list={id}"),
]))
}
pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
if query.is_empty() {
+72
View File
@@ -3749,6 +3749,78 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
assert!(app.job.is_none());
}
#[test]
fn channel_playlists_filter_open_in_provider_order_and_retain_history_and_config() {
let mut app = app(&["tubular"]);
let press = |app: &mut App, code| {
app.key(KeyEvent::new(code, KeyModifiers::NONE));
};
press(&mut app, KeyCode::Char('L'));
assert!(app.browser.is_none());
let channel = ViewSource::Channel("https://www.youtube.com/@RustVideos".into());
let mut view = View::new("@RustVideos".into(), channel.clone());
view.loaded = true;
app.views.push(view);
app.switch_view(app.views.len() - 1);
press(&mut app, KeyCode::Char('L'));
assert_eq!(app.browser.as_ref().unwrap().kind, BrowserKind::Playlists);
app.finish_playlists(Ok(r#"{"entries":[{"id":"PLJ5MFiMfElFA","title":"Conference"},{"id":"PLJ5MFiMfElFB","title":"Tutorials"}]}"#.into()));
press(&mut app, KeyCode::Char('i'));
app.paste("tutor");
assert_eq!(
app.browser.as_ref().unwrap().selected_choice().unwrap().0,
"Tutorials"
);
press(&mut app, KeyCode::Enter);
assert!(app.browser.is_none());
let playlist = ViewSource::Playlist("PLJ5MFiMfElFB".into());
assert_eq!(app.views[app.view_index].source, playlist);
app.finish_page(Ok(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#.into()));
let view = &app.views[app.view_index];
assert_eq!(
view.videos
.iter()
.map(|video| video.title.as_str())
.collect::<Vec<_>>(),
["First", "Second"]
);
assert!(!view.has_more);
press(&mut app, KeyCode::Char('j'));
press(&mut app, KeyCode::Char(' '));
assert_eq!(
app.views[app.view_index].playback_videos()[0].id,
"rQ_J9WH6CGk"
);
press(&mut app, KeyCode::Char('-'));
assert_eq!(app.views[app.view_index].source, channel);
press(&mut app, KeyCode::Char('+'));
assert_eq!(app.views[app.view_index].source, playlist);
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
app.apply_config(config::Config {
categories: [("Unrelated".into(), Vec::new())].into(),
..Default::default()
});
assert_eq!(app.views[app.view_index].source, playlist);
assert_eq!(app.temporary_view, Some(playlist));
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
assert!(app.config.subscriptions().is_empty());
press(&mut app, KeyCode::Char('-'));
press(&mut app, KeyCode::Char('L'));
app.finish_playlists(Err("Extractor failed".into()));
assert_eq!(
app.browser.as_ref().unwrap().error.as_deref(),
Some("Extractor failed")
);
press(&mut app, KeyCode::Esc);
assert!(app.browser.is_none());
press(&mut app, KeyCode::Char('L'));
app.finish_playlists(Ok(r#"{"entries":[]}"#.into()));
assert!(app.browser.as_ref().unwrap().choices.is_empty());
assert!(app.browser.as_ref().unwrap().error.is_none());
press(&mut app, KeyCode::Esc);
assert_eq!(app.views[app.view_index].source, channel);
}
#[test]
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
let mut app = app(&["tubular"]);
+46 -1
View File
@@ -120,6 +120,47 @@ fn provider_choice_is_configurable_and_normal_only() {
}
}
#[test]
fn channel_playlists_are_configurable_and_normal_only() {
let defaults = Bindings::default();
let overrides = configured("[keys.channel]\nplaylists = 'alt+l'\n").unwrap();
for &context in ALL {
let expected = (context == Context::Normal).then_some(Action::ChannelPlaylists);
for (code, modifiers) in [
(KeyCode::Char('L'), KeyModifiers::NONE),
(KeyCode::Char('L'), KeyModifiers::SHIFT),
(KeyCode::Char('l'), KeyModifiers::SHIFT),
] {
assert_eq!(press(&defaults, context, code, modifiers), expected);
assert_ne!(
press(&overrides, context, code, modifiers),
Some(Action::ChannelPlaylists),
);
}
assert_eq!(
press(&defaults, context, KeyCode::Char('l'), KeyModifiers::NONE),
None,
);
assert_eq!(
press(&overrides, context, KeyCode::Char('l'), KeyModifiers::ALT),
expected,
);
}
assert!(default_config().contains("channel.playlists = [\"L\"]"));
assert!(defaults.help_text().to_string().lines().any(|line| {
line.trim_start().starts_with("L ")
&& line.contains("Browse the current channel's playlists")
}));
assert!(overrides.help_text().to_string().lines().any(|line| {
line.trim_start().starts_with("Alt+l ")
&& line.contains("Browse the current channel's playlists")
}));
let collision = configured("[keys.channel]\nplaylists = 'u'\n")
.unwrap_err()
.to_string();
assert!(collision.contains("channel.playlists") && collision.contains("channel.browse"));
}
#[test]
fn only_section_headers_are_bold_in_the_grouped_overview() {
use ratatui::{
@@ -426,6 +467,9 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
(Context::Description, Context::CollectionDescription),
] {
for &(key, action) in &bindings.contexts[ordinary as usize] {
if action == Action::ChannelPlaylists {
continue;
}
assert_eq!(
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
Some(action),
@@ -434,7 +478,8 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
}
assert_eq!(
bindings.contexts[collection as usize].len(),
bindings.contexts[ordinary as usize].len() + 2,
bindings.contexts[ordinary as usize].len() + 2
- usize::from(ordinary == Context::Normal),
);
}
}
+46
View File
@@ -767,6 +767,52 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
}
}
#[test]
fn anilist_search_results_open_without_waiting_for_another_catalog_request() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
fs::write(&path, "").unwrap();
let mut browser = Browser::new(
Options::parse_from(["tubular", "--anilist", "Cowboy Bebop"]),
path.clone(),
config::Config::default(),
State::open(&path).unwrap(),
);
let media = miruro_browser::catalog(vec![serde_json::json!({
"id":"1", "catalog_provider":"anilist",
"title":{"romaji":"Cowboy Bebop"}, "status":"FINISHED",
"episode_count":26, "catalog_count":26, "description":"Search synopsis"
})])
.unwrap();
browser.accept(browser.generation, Ok(Response::Catalog(media)));
browser.pending = None;
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
browser.screen().selected_video().map(|video| video.id),
Some("anilist:1:1".into()),
"opening a complete search result must expose episodes immediately"
);
assert!(browser.pending.is_none());
assert!(browser.screen().overview.contains("Search synopsis"));
browser.select(Action::VideoLast);
assert_eq!(
browser.screen().selected_video().unwrap().id,
"anilist:1:26"
);
browser.refresh();
assert!(
matches!(&browser.pending, Some(Request::Episodes { media_id, .. }) if media_id == "1")
);
let refreshed = miruro_browser::anilist_series(serde_json::json!({
"id":"1", "catalog_provider":"anilist", "title":{"romaji":"Cowboy Bebop"},
"status":"FINISHED", "episode_count":27
}))
.unwrap();
browser.accept(browser.generation, Ok(Response::Series(refreshed)));
assert_eq!(browser.screen().len(), 27);
}
#[test]
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
let directory = Directory::new();
+64
View File
@@ -580,6 +580,70 @@ fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() {
assert!(!state.poll_progress().unwrap());
}
#[cfg(unix)]
#[test]
fn historical_progress_sessions_leave_descriptors_available_for_subprocesses() {
const CHILD: &str = "TUBULAR_PROGRESS_DESCRIPTOR_TEST";
if std::env::var_os(CHILD).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"video_state::tests::historical_progress_sessions_leave_descriptors_available_for_subprocesses",
"--test-threads=1",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
return;
}
// Isolate the process-wide limit from the rest of the test suite.
let mut limit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
// SAFETY: limit is valid writable storage; only this child process is affected.
unsafe {
assert_eq!(libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit), 0);
limit.rlim_cur = limit.rlim_cur.min(64);
assert_eq!(libc::setrlimit(libc::RLIMIT_NOFILE, &limit), 0);
}
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let video = video();
let reports = state.progress_files(&vec![video.clone(); 96]).unwrap();
for report in &reports {
register_identity(report, &video.id).unwrap();
append(report, record(&video.id, "progress", false, 0.0).as_bytes());
}
let last = reports.last().unwrap();
let completion = record(&video.id, "closed", true, 95.0);
append(last, &completion.as_bytes()[..completion.len() - 1]);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
for _ in 0..12 {
state.poll_progress().unwrap();
}
assert!(!state.is_watched(&video.id));
append(last, b"\n");
for _ in 0..6 {
state.poll_progress().unwrap();
}
assert!(state.is_watched(&video.id));
let output = std::process::Command::new("/usr/bin/true")
.output()
.expect("progress monitoring must leave descriptors available for subprocess pipes");
assert!(output.status.success());
}
#[test]
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
let directory = TestDirectory::new();
+114
View File
@@ -787,6 +787,120 @@ fn search_sanitizes_display_metadata_and_rejects_invalid_envelopes() {
}
}
#[test]
fn channel_playlist_listing_uses_flat_ids_sanitizes_and_deduplicates() {
// yt-dlp's /playlists tab returns URL rows, not nested playlist contents.
let playlists = channel_playlists(
r#"{"_type":"playlist","entries":[
{"_type":"url","ie_key":"YoutubeTab","id":"PLJ5MFiMfElFA","title":"Rust\u001b"},
null,42,{},{"id":"PLJ5MFiMfElFA","title":"Duplicate"},
{"id":"PLabcdefghij&list=other","title":"Unsafe"},
{"id":"rQ_J9WH6CGk","title":"Video"},
{"id":"PLabcdefghij","title":null},
{"id":"PLabcdefghij","title":"\u001b\n"},
{"id":"OLAK5uy_abcdefghij","title":"Release"}
]}"#,
)
.unwrap();
assert_eq!(
playlists
.iter()
.map(|playlist| (playlist.id.as_str(), playlist.title.as_str()))
.collect::<Vec<_>>(),
[("PLJ5MFiMfElFA", "Rust"), ("OLAK5uy_abcdefghij", "Release")]
);
assert!(channel_playlists(r#"{"entries":[]}"#).unwrap().is_empty());
for output in ["not JSON", "{}", r#"{"entries":null}"#, r#"{"entries":{}}"#] {
assert!(channel_playlists(output).is_err());
assert!(playlist_results(output).is_err());
}
}
#[test]
fn playlist_ids_are_safe_and_support_music_and_legacy_families() {
for id in [
"PLJ5MFiMfElFA",
"OLAK5uy_abcdefghij-_",
"UUabcdefghij",
"RDabcdefghij",
"RDMM",
"WL",
"LL",
"LM",
] {
assert!(valid_playlist_id(id), "{id}");
assert!(matches!(
Job::playlist(id, 0),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
for id in [
"",
"--bad",
"PLshort",
"PLabcdefghij&x=1",
"PLabcdefghij#x",
"PLabcdefghij/",
] {
assert!(!valid_playlist_id(id), "{id}");
assert!(matches!(
Job::playlist(id, 1),
Err(error) if error.kind() == io::ErrorKind::InvalidInput
));
}
assert!(Job::channel_playlists("https://example.com/@channel").is_err());
}
#[test]
fn playlists_preserve_order_repeats_and_creator_identity_with_raw_pagination() {
let mut entries = vec![serde_json::Value::Null; PAGE_SIZE];
entries[0] = serde_json::json!({
"id":"l-GaaivVJCE","title":"First\u{1b}","timestamp":100
});
entries[1] = serde_json::json!({
"id":"SLV9AJxnb1U","title":"Second","timestamp":300,
"channel":"Another creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A"
});
entries[2] = serde_json::json!({"id":"bad","title":"Invalid"});
entries[3] = serde_json::json!({"id":"jNQXAC9IVRw","title":null});
entries[4] = serde_json::json!({
"id":"jNQXAC9IVRw","title":"Members only","availability":"subscriber_only"
});
entries[5] = entries[0].clone();
entries[6] = serde_json::json!({
"id":"rQ_J9WH6CGk","title":"Owner upload","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA"
});
let output = serde_json::json!({
"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos",
"channel":"Rust\u{1b}","entries":entries
});
let page = playlist_results(&output.to_string()).unwrap();
assert!(page.has_more);
assert!(page.feed_progress.is_empty());
assert_eq!(
page.videos
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["l-GaaivVJCE", "SLV9AJxnb1U", "l-GaaivVJCE", "rQ_J9WH6CGk"]
);
assert_eq!(page.videos[0].title, "First");
assert_eq!(page.videos[0].channel, None);
assert_eq!(page.videos[0].channel_id, None);
assert_eq!(page.videos[0].uploader_id, None);
assert_eq!(page.videos[3].channel.as_deref(), Some("Rust"));
assert_eq!(page.videos[3].uploader_id.as_deref(), Some("@RustVideos"));
assert_eq!(page.videos[1].channel.as_deref(), Some("Another creator"));
assert_eq!(
page.videos[1].channel_id.as_deref(),
Some("UC4QobU6STFB0P71PMvOGN5A")
);
assert_eq!(page.videos[1].uploader_id, None);
assert!(!playlist_results(r#"{"entries":[]}"#).unwrap().has_more);
assert!(playlist_results(r#"{"channel_id":"invalid","entries":[]}"#).is_err());
assert!(playlist_results(r#"{"uploader_id":"@bad\u001b","entries":[]}"#).is_err());
}
#[test]
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
let output = r#"{"channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos","channel":"Rust\u001b","entries":[null,{"id":"l-GaaivVJCE","title":"Older","timestamp":100},{"id":"jNQXAC9IVRw","title":"Unknown","timestamp":null}]}