Compare commits
3 Commits
19267d169b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a8a3451c4 | |||
| 65200626d2 | |||
| 6cc4e18f72 |
Generated
+1
@@ -1517,6 +1517,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.23.1",
|
"base64 0.23.1",
|
||||||
|
"flate2",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ toml_edit = "0.25.17"
|
|||||||
time = "0.3.55"
|
time = "0.3.55"
|
||||||
unicode-normalization = "0.1.25"
|
unicode-normalization = "0.1.25"
|
||||||
unicode-segmentation = "1.13.3"
|
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]
|
[target."cfg(unix)".dependencies]
|
||||||
libc = "0.2.190"
|
libc = "0.2.190"
|
||||||
|
|||||||
@@ -80,7 +80,9 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it
|
|||||||
|
|
||||||
### AniList catalog and on-demand sub playback
|
### 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.
|
**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 |
|
| `c` | Browse saved categories |
|
||||||
| `u` | Browse an individual saved channel |
|
| `u` | Browse an individual saved channel |
|
||||||
| `U` (Shift-U) | Open the selected video's 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 the selected channel's tags |
|
||||||
| `S` | Edit tags for all saved channels in the current category |
|
| `S` | Edit tags for all saved channels in the current category |
|
||||||
| `R` (Shift-R) | Rename the current category, including category-scoped collection views |
|
| `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.
|
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)
|
### 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.
|
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 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
|
||||||
|
|
||||||
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.
|
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
@@ -88,11 +88,7 @@ impl Client {
|
|||||||
if response.body().mime_type() != Some("application/json") {
|
if response.body().mime_type() != Some("application/json") {
|
||||||
return Err(invalid("AniList returned non-JSON content"));
|
return Err(invalid("AniList returned non-JSON content"));
|
||||||
}
|
}
|
||||||
let body = response
|
let body = crate::read_catalog_body(response.body_mut(), BODY_LIMIT)
|
||||||
.body_mut()
|
|
||||||
.with_config()
|
|
||||||
.limit(BODY_LIMIT)
|
|
||||||
.read_to_string()
|
|
||||||
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
.map_err(|_| invalid("AniList body unreadable, timed out, or exceeded 2 MiB"))?;
|
||||||
decode(&body)
|
decode(&body)
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-6
@@ -135,6 +135,7 @@ actions! {
|
|||||||
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
|
||||||
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
|
||||||
VideoChannel, "video.channel", "Open the selected video's channel", 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"];
|
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"];
|
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"];
|
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"];
|
WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"];
|
||||||
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"];
|
||||||
WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", 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"];
|
SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"];
|
||||||
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
|
SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[Context::SelectorNormal], ["k", "up"];
|
||||||
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
|
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
|
||||||
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
|
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"];
|
SelectorEdit, "selector.edit", "Edit the category, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"];
|
||||||
SelectorOpen, "selector.open", "Open the selected category, channel, or collection", &[Context::SelectorNormal], ["enter"];
|
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"];
|
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"];
|
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"];
|
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"];
|
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
|
||||||
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"];
|
||||||
@@ -519,6 +520,10 @@ impl Bindings {
|
|||||||
),
|
),
|
||||||
(&[VideoChannel], "Browse the selected video's channel"),
|
(&[VideoChannel], "Browse the selected video's channel"),
|
||||||
(&[ChannelBrowse], "Choose a saved channel"),
|
(&[ChannelBrowse], "Choose a saved channel"),
|
||||||
|
(
|
||||||
|
&[ChannelPlaylists],
|
||||||
|
"Browse the current channel's playlists",
|
||||||
|
),
|
||||||
(&[CategoryBrowse], "Choose a saved category"),
|
(&[CategoryBrowse], "Choose a saved category"),
|
||||||
(&[ChannelTags], "Edit the selected channel's tags"),
|
(&[ChannelTags], "Edit the selected channel's tags"),
|
||||||
(&[CategoryTags], "Edit all channels in a category"),
|
(&[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"),
|
(&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"),
|
||||||
(&[SelectorEdit], "Enter filter INSERT mode"),
|
(&[SelectorEdit], "Enter filter INSERT mode"),
|
||||||
|
|||||||
+73
@@ -1,3 +1,76 @@
|
|||||||
pub mod anilist;
|
pub mod anilist;
|
||||||
pub mod anime;
|
pub mod anime;
|
||||||
pub mod miruro;
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+106
-20
@@ -83,8 +83,10 @@
|
|||||||
//! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?.
|
//! 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 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 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.
|
//! 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.
|
//! 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.
|
//! 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.
|
//! 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),
|
Category(String),
|
||||||
CategoryGroup(Vec<String>),
|
CategoryGroup(Vec<String>),
|
||||||
Channel(String),
|
Channel(String),
|
||||||
|
Playlist(String),
|
||||||
WatchLater(Option<String>),
|
WatchLater(Option<String>),
|
||||||
SavedVideos(Option<String>),
|
SavedVideos(Option<String>),
|
||||||
Watching,
|
Watching,
|
||||||
@@ -225,7 +228,11 @@ impl ViewSource {
|
|||||||
group: &'a [String],
|
group: &'a [String],
|
||||||
) -> &'a [String] {
|
) -> &'a [String] {
|
||||||
match self {
|
match self {
|
||||||
Self::Search | Self::WatchLater(_) | Self::SavedVideos(_) | Self::Watching => &[],
|
Self::Search
|
||||||
|
| Self::Playlist(_)
|
||||||
|
| Self::WatchLater(_)
|
||||||
|
| Self::SavedVideos(_)
|
||||||
|
| Self::Watching => &[],
|
||||||
Self::Feed => feed,
|
Self::Feed => feed,
|
||||||
Self::Category(name) => &config.categories[name],
|
Self::Category(name) => &config.categories[name],
|
||||||
Self::CategoryGroup(_) => group,
|
Self::CategoryGroup(_) => group,
|
||||||
@@ -581,6 +588,7 @@ fn filter_choices(
|
|||||||
enum BrowserKind {
|
enum BrowserKind {
|
||||||
Categories,
|
Categories,
|
||||||
Channels,
|
Channels,
|
||||||
|
Playlists,
|
||||||
WatchLater,
|
WatchLater,
|
||||||
SavedVideos,
|
SavedVideos,
|
||||||
}
|
}
|
||||||
@@ -595,6 +603,7 @@ struct Browser {
|
|||||||
matches: Vec<usize>,
|
matches: Vec<usize>,
|
||||||
selection: ListState,
|
selection: ListState,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
|
job: Option<Job>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Browser {
|
impl Browser {
|
||||||
@@ -605,7 +614,9 @@ impl Browser {
|
|||||||
&mut self.matches,
|
&mut self.matches,
|
||||||
&mut self.selection,
|
&mut self.selection,
|
||||||
);
|
);
|
||||||
self.error = None;
|
if self.kind != BrowserKind::Playlists {
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
|
fn selected_choice(&self) -> Option<&(String, ViewSource)> {
|
||||||
@@ -645,6 +656,8 @@ struct KeyHints {
|
|||||||
saved_videos: String,
|
saved_videos: String,
|
||||||
watching: String,
|
watching: String,
|
||||||
group_save: String,
|
group_save: String,
|
||||||
|
channel_playlists: String,
|
||||||
|
playlists: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl KeyHints {
|
impl KeyHints {
|
||||||
@@ -872,6 +885,12 @@ impl KeyHints {
|
|||||||
key(Action::InfoHide)
|
key(Action::InfoHide)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
channel_playlists: format!("· {} playlists ", key(Action::ChannelPlaylists)),
|
||||||
|
playlists: format!(
|
||||||
|
" Playlists · {} filter · {} browse ",
|
||||||
|
key(Action::SelectorEdit),
|
||||||
|
key(Action::SelectorOpen)
|
||||||
|
),
|
||||||
browser: [
|
browser: [
|
||||||
format!(
|
format!(
|
||||||
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
|
" Categories · {} filter · {} check · {} browse · {} pin · {} rename ",
|
||||||
@@ -1079,7 +1098,7 @@ impl App {
|
|||||||
.filter(|(_, view)| match &view.source {
|
.filter(|(_, view)| match &view.source {
|
||||||
ViewSource::Search | ViewSource::Feed => true,
|
ViewSource::Search | ViewSource::Feed => true,
|
||||||
ViewSource::Category(name) => self.config.pinned_categories.contains(name),
|
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,
|
| ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) | ViewSource::Watching => false,
|
||||||
})
|
})
|
||||||
.chain(self.temporary_view.as_ref().and_then(|source| {
|
.chain(self.temporary_view.as_ref().and_then(|source| {
|
||||||
@@ -1109,8 +1128,15 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn open_browser(&mut self, kind: BrowserKind) {
|
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();
|
self.close_preview();
|
||||||
let choices: Vec<_> = match kind {
|
let choices: Vec<_> = match kind {
|
||||||
|
BrowserKind::Playlists => Vec::new(),
|
||||||
BrowserKind::Categories => self
|
BrowserKind::Categories => self
|
||||||
.config
|
.config
|
||||||
.categories
|
.categories
|
||||||
@@ -1168,12 +1194,43 @@ impl App {
|
|||||||
choices,
|
choices,
|
||||||
selection: ListState::default(),
|
selection: ListState::default(),
|
||||||
error: None,
|
error: None,
|
||||||
|
job: None,
|
||||||
};
|
};
|
||||||
browser.refilter();
|
browser.refilter();
|
||||||
browser.selection.select(selected);
|
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);
|
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>) {
|
fn browse_key(&mut self, key: KeyEvent, action: Option<Action>) {
|
||||||
let mut browser = self.browser.take().expect("browser is open");
|
let mut browser = self.browser.take().expect("browser is open");
|
||||||
let action = if browser.mode == Mode::Insert {
|
let action = if browser.mode == Mode::Insert {
|
||||||
@@ -1457,6 +1514,7 @@ impl App {
|
|||||||
source,
|
source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
| ViewSource::Channel(_)
|
| ViewSource::Channel(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -1515,6 +1573,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
|
self.status = format!("Searching for {} (page {page})...", view.submitted_query);
|
||||||
Job::search(&view.submitted_query, view.next_start)
|
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 {
|
} else {
|
||||||
let channels =
|
let channels =
|
||||||
view.source
|
view.source
|
||||||
@@ -1586,7 +1647,7 @@ impl App {
|
|||||||
(view.next_start - 1) / PAGE_SIZE + 1
|
(view.next_start - 1) / PAGE_SIZE + 1
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if view.source != ViewSource::Search
|
if !matches!(view.source, ViewSource::Search | ViewSource::Playlist(_))
|
||||||
&& view
|
&& view
|
||||||
.source
|
.source
|
||||||
.channels(&self.config, &self.feed_channels, &view.group_channels)
|
.channels(&self.config, &self.feed_channels, &view.group_channels)
|
||||||
@@ -1612,14 +1673,15 @@ impl App {
|
|||||||
|
|
||||||
fn finish_page(&mut self, result: Result<String, String>) {
|
fn finish_page(&mut self, result: Result<String, String>) {
|
||||||
self.job = None;
|
self.job = None;
|
||||||
let parsed = result.and_then(|output| {
|
let parsed = result.and_then(|output| match &self.views[self.view_index].source {
|
||||||
if self.views[self.view_index].source == ViewSource::Search {
|
ViewSource::Search => youtube::search_results(&output),
|
||||||
youtube::search_results(&output)
|
ViewSource::Playlist(_) => youtube::playlist_results(&output),
|
||||||
} else {
|
_ => youtube::feed_results(&output, &self.views[self.view_index].feed_requests),
|
||||||
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;
|
let replacing = !self.views[self.view_index].loaded;
|
||||||
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
|
match parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed)) {
|
||||||
Ok(()) if replacing => self.close_preview(),
|
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::Channel(identity) if !self.feed_channels.contains(identity))
|
||||||
|| matches!(&view.source, ViewSource::Watching)
|
|| 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))))
|
|| 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)
|
.then_some(view)
|
||||||
}));
|
}));
|
||||||
@@ -1972,6 +2035,7 @@ impl App {
|
|||||||
if matches!(
|
if matches!(
|
||||||
&self.views[self.view_index].source,
|
&self.views[self.view_index].source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -2259,6 +2323,7 @@ impl App {
|
|||||||
view.source,
|
view.source,
|
||||||
ViewSource::CategoryGroup(_)
|
ViewSource::CategoryGroup(_)
|
||||||
| ViewSource::Channel(_)
|
| ViewSource::Channel(_)
|
||||||
|
| ViewSource::Playlist(_)
|
||||||
| ViewSource::WatchLater(_)
|
| ViewSource::WatchLater(_)
|
||||||
| ViewSource::SavedVideos(_)
|
| ViewSource::SavedVideos(_)
|
||||||
| ViewSource::Watching
|
| ViewSource::Watching
|
||||||
@@ -2633,6 +2698,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self) {
|
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) {
|
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
||||||
self.finish_page(result);
|
self.finish_page(result);
|
||||||
}
|
}
|
||||||
@@ -2927,6 +3000,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
|
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
|
||||||
Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels),
|
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::VideoChannel) => self.open_video_channel(),
|
||||||
Some(Action::ChannelTags) => self.open_category_picker(),
|
Some(Action::ChannelTags) => self.open_category_picker(),
|
||||||
Some(Action::CategoryTags) => self.open_bulk_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::SavedVideos(_) => self.key_hints.saved_videos.as_str(),
|
||||||
ViewSource::Watching => self.key_hints.watching.as_str(),
|
ViewSource::Watching => self.key_hints.watching.as_str(),
|
||||||
ViewSource::CategoryGroup(_) => self.key_hints.group_save.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),
|
self.job.is_some().then_some(spinner),
|
||||||
@@ -3279,7 +3354,7 @@ impl App {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
self.draw_category_dialog(frame);
|
self.draw_category_dialog(frame);
|
||||||
self.draw_browser(frame);
|
self.draw_browser(frame, elapsed);
|
||||||
self.draw_category_name_input(frame);
|
self.draw_category_name_input(frame);
|
||||||
self.draw_help(frame);
|
self.draw_help(frame);
|
||||||
self.draw_collection_removal(frame);
|
self.draw_collection_removal(frame);
|
||||||
@@ -3331,19 +3406,24 @@ impl App {
|
|||||||
frame.render_widget(block, popup);
|
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 {
|
let Some(browser) = &mut self.browser else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let popup = preview_area(frame.area());
|
let popup = preview_area(frame.area());
|
||||||
frame.render_widget(Clear, popup);
|
frame.render_widget(Clear, popup);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(match browser.kind {
|
.title(loading_title(
|
||||||
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
|
match browser.kind {
|
||||||
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
|
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
|
||||||
BrowserKind::WatchLater => self.key_hints.browser[2].as_str(),
|
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
|
||||||
BrowserKind::SavedVideos => self.key_hints.browser[3].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);
|
.border_style(ui::ACCENT);
|
||||||
let [input, list, error] = Layout::vertical([
|
let [input, list, error] = Layout::vertical([
|
||||||
Constraint::Length(3),
|
Constraint::Length(3),
|
||||||
@@ -3375,6 +3455,11 @@ impl App {
|
|||||||
if browser.choices.is_empty() {
|
if browser.choices.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(match browser.kind {
|
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 => {
|
BrowserKind::Categories => {
|
||||||
"No categories yet. Use s on a video to save its channel."
|
"No categories yet. Use s on a video to save its channel."
|
||||||
}
|
}
|
||||||
@@ -3394,6 +3479,7 @@ impl App {
|
|||||||
Paragraph::new(match browser.kind {
|
Paragraph::new(match browser.kind {
|
||||||
BrowserKind::Categories => "No matching categories.",
|
BrowserKind::Categories => "No matching categories.",
|
||||||
BrowserKind::Channels => "No matching channels.",
|
BrowserKind::Channels => "No matching channels.",
|
||||||
|
BrowserKind::Playlists => "No matching playlists.",
|
||||||
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
|
BrowserKind::WatchLater | BrowserKind::SavedVideos => {
|
||||||
"No matching categories or combined collection."
|
"No matching categories or combined collection."
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-6
@@ -145,12 +145,7 @@ impl Client {
|
|||||||
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
last_error = invalid(format!("{base}: unexpected Content-Type {mime}"));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match response
|
match crate::read_catalog_body(response.body_mut(), BODY_LIMIT) {
|
||||||
.body_mut()
|
|
||||||
.with_config()
|
|
||||||
.limit(BODY_LIMIT)
|
|
||||||
.read_to_string()
|
|
||||||
{
|
|
||||||
Ok(body) => {
|
Ok(body) => {
|
||||||
self.preferred.store(index, Ordering::Relaxed);
|
self.preferred.store(index, Ordering::Relaxed);
|
||||||
return Ok(body);
|
return Ok(body);
|
||||||
|
|||||||
+15
-2
@@ -734,8 +734,21 @@ impl Browser {
|
|||||||
if let Some(media) = self.screen().selected_media().cloned()
|
if let Some(media) = self.screen().selected_media().cloned()
|
||||||
&& !matches!(self.screen().content, Content::Series { .. })
|
&& !matches!(self.screen().content, Content::Series { .. })
|
||||||
{
|
{
|
||||||
self.push(Screen::new(Content::Series { media, data: None }));
|
if self.provider == Provider::AniList
|
||||||
self.load_series();
|
&& 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 {
|
} else {
|
||||||
self.play(false);
|
self.play(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-8
@@ -1,7 +1,7 @@
|
|||||||
use std::{
|
use std::{
|
||||||
collections::BTreeSet,
|
collections::BTreeSet,
|
||||||
fs::{self, File, OpenOptions, ReadDir},
|
fs::{self, File, OpenOptions, ReadDir},
|
||||||
io::{self, Read, Write},
|
io::{self, Read, Seek, SeekFrom, Write},
|
||||||
path::{Path, PathBuf},
|
path::{Path, PathBuf},
|
||||||
sync::atomic::{AtomicU64, Ordering},
|
sync::atomic::{AtomicU64, Ordering},
|
||||||
};
|
};
|
||||||
@@ -1051,7 +1051,7 @@ struct ProgressStream {
|
|||||||
seen: bool,
|
seen: bool,
|
||||||
status_edited: bool,
|
status_edited: bool,
|
||||||
started: bool,
|
started: bool,
|
||||||
file: Option<File>,
|
offset: u64,
|
||||||
partial: Vec<u8>,
|
partial: Vec<u8>,
|
||||||
discarding: bool,
|
discarding: bool,
|
||||||
failed: bool,
|
failed: bool,
|
||||||
@@ -1068,7 +1068,7 @@ impl ProgressStream {
|
|||||||
seen: false,
|
seen: false,
|
||||||
status_edited: false,
|
status_edited: false,
|
||||||
started: false,
|
started: false,
|
||||||
file: None,
|
offset: 0,
|
||||||
partial: Vec::new(),
|
partial: Vec::new(),
|
||||||
discarding: false,
|
discarding: false,
|
||||||
failed: false,
|
failed: false,
|
||||||
@@ -1184,12 +1184,13 @@ impl ProgressStream {
|
|||||||
pending: &mut BTreeSet<String>,
|
pending: &mut BTreeSet<String>,
|
||||||
watched: &BTreeSet<String>,
|
watched: &BTreeSet<String>,
|
||||||
) -> io::Result<Option<io::Error>> {
|
) -> io::Result<Option<io::Error>> {
|
||||||
if self.file.is_none() {
|
self.prepare()?;
|
||||||
self.prepare()?;
|
// Keep a cursor, not a descriptor, for each retained playback session.
|
||||||
self.file = Some(regular_file(&self.path, true)?);
|
let mut file = regular_file(&self.path, true)?;
|
||||||
}
|
file.seek(SeekFrom::Start(self.offset))?;
|
||||||
let mut buffer = [0; STREAM_BYTES];
|
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;
|
let mut error = None;
|
||||||
for &byte in &buffer[..size] {
|
for &byte in &buffer[..size] {
|
||||||
if byte == b'\n' {
|
if byte == b'\n' {
|
||||||
|
|||||||
+127
@@ -132,6 +132,12 @@ pub struct Page {
|
|||||||
pub feed_progress: Vec<FeedProgress>,
|
pub feed_progress: Vec<FeedProgress>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Playlist {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Deserialize, Serialize)]
|
#[derive(Clone, Deserialize, Serialize)]
|
||||||
pub struct Video {
|
pub struct Video {
|
||||||
pub id: String,
|
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 {
|
fn valid_channel_id(id: &str) -> bool {
|
||||||
id.len() == 24
|
id.len() == 24
|
||||||
&& id.starts_with("UC")
|
&& 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> {
|
pub fn feed(channels: &[String], requests: &[FeedRequest], query: &str) -> io::Result<Self> {
|
||||||
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
|
let mut job = Self::spawn_all(feed_commands(channels, requests, query)?)?;
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
|
|||||||
@@ -3749,6 +3749,78 @@ fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
|
|||||||
assert!(app.job.is_none());
|
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]
|
#[test]
|
||||||
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
|
||||||
let mut app = app(&["tubular"]);
|
let mut app = app(&["tubular"]);
|
||||||
|
|||||||
+46
-1
@@ -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]
|
#[test]
|
||||||
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
fn only_section_headers_are_bold_in_the_grouped_overview() {
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
@@ -426,6 +467,9 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
|||||||
(Context::Description, Context::CollectionDescription),
|
(Context::Description, Context::CollectionDescription),
|
||||||
] {
|
] {
|
||||||
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
for &(key, action) in &bindings.contexts[ordinary as usize] {
|
||||||
|
if action == Action::ChannelPlaylists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
|
||||||
Some(action),
|
Some(action),
|
||||||
@@ -434,7 +478,8 @@ fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
|
|||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
bindings.contexts[collection as usize].len(),
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
|
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
|
||||||
let directory = Directory::new();
|
let directory = Directory::new();
|
||||||
|
|||||||
@@ -580,6 +580,70 @@ fn watched_write_failure_keeps_memory_unchanged_and_retries_consumed_reports() {
|
|||||||
assert!(!state.poll_progress().unwrap());
|
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]
|
#[test]
|
||||||
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
|
fn partial_reports_survive_restart_and_bad_siblings_do_not_block_completion() {
|
||||||
let directory = TestDirectory::new();
|
let directory = TestDirectory::new();
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn feeds_merge_newest_first_deduplicate_and_inherit_channel_identity() {
|
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}]}
|
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}]}
|
||||||
|
|||||||
Reference in New Issue
Block a user