fix: cap feed requests at 60 videos total
This commit is contained in:
+470
-232
@@ -79,16 +79,16 @@
|
||||
//! r reloads config and refreshes the current view.
|
||||
//! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates.
|
||||
//! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe.
|
||||
//! Search pages contain 60 results; Feed/tag pages request 60 uploads per channel's /videos tab.
|
||||
//! Search uses one extractor; feeds batch disjoint channels across up to four concurrent extractors.
|
||||
//! Search pages contain 60 results; Feed/tag pages share a total request budget of 60 uploads.
|
||||
//! Search uses one extractor; feeds batch matching channel ranges across up to four concurrent extractors.
|
||||
//! Invalid individual video entries are discarded; raw entry counts still determine pagination.
|
||||
//! Invalid JSON, playlist-level metadata, and extractor failures remain page errors.
|
||||
//! In unobstructed normal mode, 20 rows before the loaded end fetches the next page without replacing rows/selection.
|
||||
//! Forward video navigation authorizes prefetch within 20 rows of the loaded end in unobstructed normal mode.
|
||||
//! Each accepted page clears that intent, so short or invalid batches never auto-chain on startup.
|
||||
//! Loading result/preview panes animate a spinner after their title, including additional pages.
|
||||
//! Info spinners cover image download/decode/resize and description fetching; errors stop them.
|
||||
//! Feed rows use approximate upload dates; older rows wait until newer channel pages catch up.
|
||||
//! Undated feed rows appear at exhaustion. Cached views retain their page position across switches.
|
||||
//! Switching views cancels in-flight extraction; returning resumes the same pending range.
|
||||
//! Feed rows, including undated rows, are globally newest-first among loaded uploads; selection follows video ID.
|
||||
//! Switching views cancels in-flight extraction; returning retains the unaccepted request and per-channel offsets.
|
||||
//! Errors keep loaded rows and stop automatic retries; r reloads config and restarts the current view.
|
||||
//! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages.
|
||||
//! Video tables show Published as YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata.
|
||||
@@ -309,7 +309,9 @@ struct View {
|
||||
loaded: bool,
|
||||
next_start: usize,
|
||||
has_more: bool,
|
||||
pending: Vec<Video>,
|
||||
feed_cursor: youtube::FeedCursor,
|
||||
feed_requests: Vec<youtube::FeedRequest>,
|
||||
prefetch_requested: bool,
|
||||
page_error: Option<String>,
|
||||
watch_filter: WatchFilter,
|
||||
}
|
||||
@@ -326,7 +328,9 @@ impl View {
|
||||
loaded: false,
|
||||
next_start: 1,
|
||||
has_more: false,
|
||||
pending: Vec::new(),
|
||||
feed_cursor: youtube::FeedCursor::default(),
|
||||
feed_requests: Vec::new(),
|
||||
prefetch_requested: false,
|
||||
page_error: None,
|
||||
watch_filter: WatchFilter::All,
|
||||
}
|
||||
@@ -338,7 +342,9 @@ impl View {
|
||||
self.loaded = false;
|
||||
self.next_start = 1;
|
||||
self.has_more = false;
|
||||
self.pending.clear();
|
||||
self.feed_cursor = youtube::FeedCursor::default();
|
||||
self.feed_requests.clear();
|
||||
self.prefetch_requested = false;
|
||||
self.page_error = None;
|
||||
}
|
||||
|
||||
@@ -347,45 +353,51 @@ impl View {
|
||||
.next_start
|
||||
.checked_add(PAGE_SIZE)
|
||||
.ok_or("Page offset exceeds the supported range")?;
|
||||
let mut videos = if feed {
|
||||
self.pending.append(&mut page.videos);
|
||||
std::mem::take(&mut self.pending)
|
||||
} else {
|
||||
page.videos
|
||||
};
|
||||
if feed {
|
||||
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
self.feed_cursor.accept(&page.feed_progress)?;
|
||||
}
|
||||
if !videos.is_empty() {
|
||||
// Borrow IDs before moving rows, retaining the existing row data.
|
||||
let keep: Vec<_> = {
|
||||
let mut ids = HashSet::with_capacity(self.videos.len() + videos.len());
|
||||
ids.extend(self.videos.iter().map(|video| video.id.as_str()));
|
||||
videos
|
||||
.iter()
|
||||
.map(|video| ids.insert(video.id.as_str()))
|
||||
.collect()
|
||||
if !page.videos.is_empty() {
|
||||
let selected = if feed {
|
||||
self.selection.selected().and_then(|index| {
|
||||
self.videos.get(index).map(|video| {
|
||||
<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID")
|
||||
})
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut keep = keep.into_iter();
|
||||
videos.retain(|_| keep.next().expect("one deduplication flag per video"));
|
||||
}
|
||||
let ready = if feed && page.has_more {
|
||||
page.cutoff.map_or(0, |cutoff| {
|
||||
videos.partition_point(|video| video.timestamp.is_some_and(|date| date >= cutoff))
|
||||
})
|
||||
} else {
|
||||
videos.len()
|
||||
};
|
||||
self.videos.extend(videos.drain(..ready));
|
||||
if feed {
|
||||
self.pending = videos;
|
||||
let mut ids = HashSet::with_capacity(self.videos.len() + page.videos.len());
|
||||
ids.extend(self.videos.iter().map(|video| {
|
||||
<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID")
|
||||
}));
|
||||
page.videos.retain(|video| {
|
||||
ids.insert(<[u8; 11]>::try_from(video.id.as_bytes()).expect("validated video ID"))
|
||||
});
|
||||
self.videos.append(&mut page.videos);
|
||||
if feed {
|
||||
// ponytail: sort loaded metadata only; a globally newest archive merge needs
|
||||
// per-channel frontier lookahead rather than fair quotas.
|
||||
self.videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||
if let Some(id) = selected {
|
||||
self.selection.select(
|
||||
self.videos
|
||||
.iter()
|
||||
.position(|video| video.id.as_bytes() == id.as_slice()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.selection.selected().is_none() && !self.videos.is_empty() {
|
||||
self.selection.select(Some(0));
|
||||
}
|
||||
self.loaded = true;
|
||||
self.next_start = next_start;
|
||||
self.has_more = page.has_more;
|
||||
self.has_more = if feed {
|
||||
self.feed_cursor.has_more()
|
||||
} else {
|
||||
page.has_more
|
||||
};
|
||||
self.prefetch_requested = false;
|
||||
self.page_error = None;
|
||||
Ok(())
|
||||
}
|
||||
@@ -393,6 +405,7 @@ impl View {
|
||||
fn needs_page(&self) -> bool {
|
||||
self.loaded
|
||||
&& self.has_more
|
||||
&& self.prefetch_requested
|
||||
&& self.page_error.is_none()
|
||||
&& (self.videos.is_empty()
|
||||
|| self
|
||||
@@ -890,7 +903,7 @@ struct App {
|
||||
preview: Option<PreviewMode>,
|
||||
help_popup: Option<HelpPopup>,
|
||||
thumbnail: thumbnail::Thumbnail,
|
||||
description_job: Option<(usize, Job)>,
|
||||
description_job: Option<(String, Job)>,
|
||||
description_error: Option<String>,
|
||||
// ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows.
|
||||
descriptions: std::collections::VecDeque<(String, String)>,
|
||||
@@ -924,9 +937,13 @@ impl App {
|
||||
fn with_state(
|
||||
options: Options,
|
||||
config_path: PathBuf,
|
||||
config: config::Config,
|
||||
mut config: config::Config,
|
||||
video_state: video_state::State,
|
||||
) -> Self {
|
||||
// Cursor channel indices must remain stable when a config only reorders memberships.
|
||||
for channels in config.categories.values_mut() {
|
||||
channels.sort_unstable();
|
||||
}
|
||||
let query = options.query.clone().unwrap_or_default();
|
||||
let feed_channels = config.subscriptions();
|
||||
let key_hints = KeyHints::new(&config.keys);
|
||||
@@ -1419,7 +1436,10 @@ impl App {
|
||||
view.name,
|
||||
view.videos.len()
|
||||
);
|
||||
Job::feed(channels, view.next_start)
|
||||
view.feed_cursor.plan(channels.len()).and_then(|requests| {
|
||||
view.feed_requests = requests;
|
||||
Job::feed(channels, &view.feed_requests)
|
||||
})
|
||||
};
|
||||
match result {
|
||||
Ok(job) => self.job = Some(job),
|
||||
@@ -1467,11 +1487,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
let continuation = if view.has_more {
|
||||
if view.videos.is_empty() && !view.pending.is_empty() {
|
||||
"newer pages pending"
|
||||
} else {
|
||||
"more available"
|
||||
}
|
||||
"more available"
|
||||
} else {
|
||||
"end"
|
||||
};
|
||||
@@ -1489,7 +1505,7 @@ impl App {
|
||||
if self.views[self.view_index].source == ViewSource::Search {
|
||||
youtube::search_results(&output)
|
||||
} else {
|
||||
youtube::feed_results(&output)
|
||||
youtube::feed_results(&output, &self.views[self.view_index].feed_requests)
|
||||
}
|
||||
});
|
||||
let feed = self.views[self.view_index].source != ViewSource::Search;
|
||||
@@ -1716,7 +1732,10 @@ impl App {
|
||||
self.close_preview();
|
||||
}
|
||||
|
||||
fn apply_config(&mut self, config: config::Config) {
|
||||
fn apply_config(&mut self, mut config: config::Config) {
|
||||
for channels in config.categories.values_mut() {
|
||||
channels.sort_unstable();
|
||||
}
|
||||
if config.keys != self.config.keys {
|
||||
self.key_hints = KeyHints::new(&config.keys);
|
||||
if let Some(help) = &mut self.help_popup {
|
||||
@@ -1987,7 +2006,9 @@ impl App {
|
||||
view.loaded = source.loaded;
|
||||
view.next_start = source.next_start;
|
||||
view.has_more = source.has_more;
|
||||
view.pending = source.pending.clone();
|
||||
view.feed_cursor = source.feed_cursor.clone();
|
||||
view.feed_requests = source.feed_requests.clone();
|
||||
view.prefetch_requested = source.prefetch_requested;
|
||||
view.page_error = source.page_error.clone();
|
||||
Some(view)
|
||||
} else {
|
||||
@@ -2385,7 +2406,7 @@ impl App {
|
||||
return;
|
||||
}
|
||||
match Job::description(&video.id) {
|
||||
Ok(job) => self.description_job = Some((index, job)),
|
||||
Ok(job) => self.description_job = Some((video.id.clone(), job)),
|
||||
Err(error) => self.description_error = Some(error.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -2434,6 +2455,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_description(&mut self, id: String, result: Result<String, String>) {
|
||||
match result.and_then(|output| youtube::full_description(&output, &id)) {
|
||||
Ok(description) => self.remember_description(id, description),
|
||||
Err(error) => self.description_error = Some(error),
|
||||
}
|
||||
self.description_viewport = None;
|
||||
}
|
||||
|
||||
fn update(&mut self) {
|
||||
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
|
||||
self.finish_page(result);
|
||||
@@ -2461,17 +2490,11 @@ impl App {
|
||||
.as_mut()
|
||||
.and_then(|(_, job)| job.poll())
|
||||
{
|
||||
let (index, _) = self
|
||||
let (id, _) = self
|
||||
.description_job
|
||||
.take()
|
||||
.expect("completed description job exists");
|
||||
if let Some(video) = self.views[self.view_index].videos.get(index) {
|
||||
match result.and_then(|output| youtube::full_description(&output, &video.id)) {
|
||||
Ok(description) => self.remember_description(video.id.clone(), description),
|
||||
Err(error) => self.description_error = Some(error),
|
||||
}
|
||||
}
|
||||
self.description_viewport = None;
|
||||
self.finish_description(id, result);
|
||||
}
|
||||
if let Some(result) = self.player_launch.as_mut().and_then(PlayerLaunch::poll) {
|
||||
self.player_launch = None;
|
||||
@@ -2736,12 +2759,18 @@ impl App {
|
||||
self.browse_category_collection(CollectionKind::Saved)
|
||||
}
|
||||
Some(Action::VideoPlay) => self.play(),
|
||||
Some(Action::VideoNext) => self.views[self.view_index].selection.select_next(),
|
||||
Some(Action::VideoNext) => {
|
||||
self.views[self.view_index].prefetch_requested = true;
|
||||
self.views[self.view_index].selection.select_next();
|
||||
}
|
||||
Some(Action::VideoPrevious) => {
|
||||
self.views[self.view_index].selection.select_previous()
|
||||
}
|
||||
Some(Action::VideoFirst) => self.views[self.view_index].selection.select_first(),
|
||||
Some(Action::VideoLast) => self.views[self.view_index].selection.select_last(),
|
||||
Some(Action::VideoLast) => {
|
||||
self.views[self.view_index].prefetch_requested = true;
|
||||
self.views[self.view_index].selection.select_last();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -3570,6 +3599,46 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
// Unrelated UI fixtures still parse real feed envelopes with a shared-budget plan.
|
||||
fn feed_fixture(output: &str) -> Result<Page, String> {
|
||||
let channels = serde_json::Deserializer::from_str(output)
|
||||
.into_iter::<serde_json::Value>()
|
||||
.count();
|
||||
let requests = youtube::FeedCursor::default().plan(channels).unwrap();
|
||||
youtube::feed_results(output, &requests)
|
||||
}
|
||||
|
||||
fn finish_feed_fixture(app: &mut App, output: &str, full: bool) {
|
||||
let view = &mut app.views[app.view_index];
|
||||
let channels = view
|
||||
.source
|
||||
.channels(&app.config, &app.feed_channels, &view.group_channels)
|
||||
.len();
|
||||
view.feed_requests = view.feed_cursor.plan(channels).unwrap();
|
||||
let mut envelopes: Vec<_> = serde_json::Deserializer::from_str(output)
|
||||
.into_iter::<serde_json::Value>()
|
||||
.map(Result::unwrap)
|
||||
.collect();
|
||||
assert!(envelopes.len() <= view.feed_requests.len());
|
||||
envelopes.resize_with(
|
||||
view.feed_requests.len(),
|
||||
|| serde_json::json!({"entries":[]}),
|
||||
);
|
||||
if full {
|
||||
for (envelope, request) in envelopes.iter_mut().zip(&view.feed_requests) {
|
||||
let entries = envelope["entries"].as_array_mut().unwrap();
|
||||
assert!(entries.len() <= request.count);
|
||||
entries.resize(request.count, serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
let output = envelopes
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
app.finish_page(Ok(output));
|
||||
}
|
||||
|
||||
fn choose_combined(app: &mut App, key: KeyEvent) {
|
||||
app.key(key);
|
||||
while app
|
||||
@@ -3723,10 +3792,12 @@ mod tests {
|
||||
"https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA",
|
||||
]
|
||||
);
|
||||
app.finish_page(Ok(
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared","timestamp":300},{"id":"l-GaaivVJCE","title":"Old","timestamp":100}]}
|
||||
{"entries":[{"id":"jNQXAC9IVRw","title":"Shared again","timestamp":300},{"id":"rQ_J9WH6CGk","title":"Middle","timestamp":200}]}"#.into()
|
||||
));
|
||||
{"entries":[{"id":"jNQXAC9IVRw","title":"Shared again","timestamp":300},{"id":"rQ_J9WH6CGk","title":"Middle","timestamp":200}]}"#,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
app.views[app.view_index]
|
||||
.videos
|
||||
@@ -3736,7 +3807,11 @@ mod tests {
|
||||
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE"]
|
||||
);
|
||||
rename_press(&mut app, KeyCode::Down);
|
||||
app.finish_page(Ok(r#"{"entries":[{"id":"l-GaaivVJCE","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Last","timestamp":50}]}"#.into()));
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Last","timestamp":50}]}"#,
|
||||
false,
|
||||
);
|
||||
let next_start = app.views[app.view_index].next_start;
|
||||
let names = vec!["Alpha".into(), "Gamma".into()];
|
||||
let mut replacement = View::new(group_name(&names), ViewSource::CategoryGroup(names));
|
||||
@@ -3777,9 +3852,11 @@ mod tests {
|
||||
#[test]
|
||||
fn combined_cache_resets_only_for_changed_resolved_union_and_drops_deleted_members() {
|
||||
let mut app = group_app("config", "");
|
||||
app.finish_page(Ok(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#.into(),
|
||||
));
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
||||
false,
|
||||
);
|
||||
let group = app.views[app.view_index].source.clone();
|
||||
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
||||
std::fs::write(
|
||||
@@ -3825,9 +3902,11 @@ mod tests {
|
||||
#[test]
|
||||
fn checked_category_rename_preserves_canonical_group_pin_selection_and_history() {
|
||||
let mut app = group_app("rename", "");
|
||||
app.finish_page(Ok(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#.into(),
|
||||
));
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
||||
false,
|
||||
);
|
||||
let old = app.views[app.view_index].source.clone();
|
||||
app.view_history.push(old.clone());
|
||||
app.forward_history.push(old);
|
||||
@@ -3869,16 +3948,20 @@ mod tests {
|
||||
#[test]
|
||||
fn saving_combined_view_copies_snapshot_and_preserves_cached_state_without_pinning() {
|
||||
let mut app = group_app("save", "");
|
||||
app.finish_page(Ok(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":200},{"id":"l-GaaivVJCE","title":"Second","timestamp":100}]}"#.into()));
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":200},{"id":"l-GaaivVJCE","title":"Second","timestamp":100}]}"#,
|
||||
true,
|
||||
);
|
||||
rename_press(&mut app, KeyCode::Down);
|
||||
let source = app.views[app.view_index].source.clone();
|
||||
let channels = app.views[app.view_index].group_channels.clone();
|
||||
let pending = youtube::search_results(
|
||||
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Pending","timestamp":50}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[app.view_index].pending = pending;
|
||||
let next_requests = app.views[app.view_index]
|
||||
.feed_cursor
|
||||
.plan(channels.len())
|
||||
.unwrap();
|
||||
app.views[app.view_index].feed_requests = next_requests.clone();
|
||||
app.views[app.view_index].prefetch_requested = true;
|
||||
app.views[app.view_index].has_more = true;
|
||||
app.views[app.view_index].page_error = Some("keep failure".into());
|
||||
let original = std::fs::read_to_string(&app.config_path).unwrap();
|
||||
@@ -3921,7 +4004,12 @@ mod tests {
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(view.videos.len(), 2);
|
||||
assert_eq!(view.next_start, PAGE_SIZE + 1);
|
||||
assert_eq!(view.pending[0].id, "rQ_J9WH6CGk");
|
||||
assert_eq!(view.feed_requests, next_requests);
|
||||
assert_eq!(
|
||||
view.feed_cursor.clone().plan(channels.len()).unwrap(),
|
||||
next_requests
|
||||
);
|
||||
assert!(view.prefetch_requested);
|
||||
assert!(view.has_more);
|
||||
assert_eq!(view.page_error.as_deref(), Some("keep failure"));
|
||||
assert!(app.job.is_none());
|
||||
@@ -4147,7 +4235,8 @@ mod tests {
|
||||
app.view_index = 2;
|
||||
let view = &mut app.views[2];
|
||||
view.videos = videos.clone();
|
||||
view.pending = vec![videos[0].clone()];
|
||||
view.feed_requests = view.feed_cursor.plan(1).unwrap();
|
||||
view.prefetch_requested = true;
|
||||
view.selection.select(Some(1));
|
||||
view.loaded = true;
|
||||
view.next_start = 41;
|
||||
@@ -4162,7 +4251,8 @@ mod tests {
|
||||
assert_eq!(view.name, "Zeta");
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(view.videos.len(), 2);
|
||||
assert_eq!(view.pending.len(), 1);
|
||||
assert_eq!(view.feed_requests.len(), 1);
|
||||
assert!(view.prefetch_requested);
|
||||
assert_eq!(view.next_start, 41);
|
||||
assert!(view.loaded && view.has_more);
|
||||
assert_eq!(view.page_error.as_deref(), Some("keep page failure"));
|
||||
@@ -5851,7 +5941,7 @@ mod tests {
|
||||
let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
|
||||
for (label, identity) in app.config.saved_channels() {
|
||||
let mut view = View::new(label, ViewSource::Channel(identity));
|
||||
view.videos = youtube::feed_results(videos).unwrap().videos;
|
||||
view.videos = feed_fixture(videos).unwrap().videos;
|
||||
view.loaded = true;
|
||||
view.next_start = 41;
|
||||
view.selection.select(Some(1));
|
||||
@@ -6131,6 +6221,7 @@ tags.cancel = "z"
|
||||
let selected = app.views[app.view_index].selection.selected();
|
||||
let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
|
||||
app.views[app.view_index].has_more = true;
|
||||
app.views[app.view_index].prefetch_requested = true;
|
||||
assert!(app.can_prefetch());
|
||||
press(&mut app, KeyCode::Char('?'));
|
||||
assert!(!app.can_prefetch());
|
||||
@@ -6682,10 +6773,9 @@ tags.cancel = "z"
|
||||
loaded_channel_views(&mut app);
|
||||
browse_channel(&mut app, "Alpha");
|
||||
app.views[app.view_index].has_more = true;
|
||||
app.views[app.view_index].pending =
|
||||
youtube::feed_results(r#"{"entries":[{"id":"l-GaaivVJCE","title":"Pending"}]}"#)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[app.view_index].feed_requests =
|
||||
app.views[app.view_index].feed_cursor.plan(1).unwrap();
|
||||
app.views[app.view_index].prefetch_requested = true;
|
||||
app.views[app.view_index].page_error = Some("kept".into());
|
||||
app.apply_config(config::Config {
|
||||
keys: Default::default(),
|
||||
@@ -6708,7 +6798,8 @@ tags.cancel = "z"
|
||||
assert!(view.loaded && view.has_more);
|
||||
assert_eq!(view.next_start, 41);
|
||||
assert_eq!(view.selection.selected(), Some(1));
|
||||
assert_eq!(view.pending.len(), 1);
|
||||
assert_eq!(view.feed_requests.len(), 1);
|
||||
assert!(view.prefetch_requested);
|
||||
assert_eq!(view.page_error.as_deref(), Some("kept"));
|
||||
let beta = app
|
||||
.views
|
||||
@@ -7453,136 +7544,283 @@ tags.cancel = "z"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_pages_hold_short_channel_tails_until_newer_long_histories_finish() {
|
||||
let mut view = View::new("Feed".into(), ViewSource::Feed);
|
||||
let second_base = 101 + PAGE_SIZE as i64;
|
||||
let first_base = second_base + PAGE_SIZE as i64;
|
||||
let long_page = |base: i64| {
|
||||
serde_json::json!({
|
||||
"entries": (0..PAGE_SIZE).map(|index| serde_json::json!({
|
||||
"id": format!("long{:07}", base + index as i64),
|
||||
"title": "Long channel",
|
||||
"timestamp": base + index as i64,
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
let short = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Short old tail","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Unknown date"}]}"#;
|
||||
let first = youtube::feed_results(&format!("{}\n{short}", long_page(first_base))).unwrap();
|
||||
view.accept_page(first, true).unwrap();
|
||||
assert_eq!(view.videos.len(), PAGE_SIZE);
|
||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(first_base));
|
||||
assert_eq!(view.pending.len(), 2);
|
||||
view.selection.select(Some(PAGE_SIZE - 1));
|
||||
let second =
|
||||
youtube::feed_results(&format!("{}\n{short}", long_page(second_base))).unwrap();
|
||||
view.accept_page(second, true).unwrap();
|
||||
assert_eq!(view.videos.len(), PAGE_SIZE * 2);
|
||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(second_base));
|
||||
assert_eq!(view.pending.len(), 2);
|
||||
assert_eq!(view.selection.selected(), Some(PAGE_SIZE - 1));
|
||||
view.accept_page(
|
||||
youtube::feed_results(&format!(
|
||||
r#"{{"entries":[{{"id":"l-GaaivVJCE","title":"Long last row","timestamp":{}}}]}} {{"entries":[]}}"#,
|
||||
second_base - 1,
|
||||
))
|
||||
.unwrap(),
|
||||
true,
|
||||
).unwrap();
|
||||
fn many_channel_feed_consumes_only_sixty_requested_rows() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = (0..75)
|
||||
.map(|index| format!("https://www.youtube.com/@creator{index}"))
|
||||
.collect();
|
||||
let view = &mut app.views[1];
|
||||
view.feed_requests = view.feed_cursor.plan(app.feed_channels.len()).unwrap();
|
||||
assert_eq!(
|
||||
view.videos[PAGE_SIZE * 2..]
|
||||
view.feed_requests
|
||||
.iter()
|
||||
.map(|request| request.count)
|
||||
.sum::<usize>(),
|
||||
PAGE_SIZE
|
||||
);
|
||||
assert_eq!(view.feed_requests.len(), PAGE_SIZE);
|
||||
let output = view
|
||||
.feed_requests
|
||||
.iter()
|
||||
.map(|request| {
|
||||
serde_json::json!({"entries":[{
|
||||
"id":format!("channel{:04}", request.channel),
|
||||
"title":"Upload",
|
||||
"timestamp":request.channel,
|
||||
}]})
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
app.finish_page(Ok(output));
|
||||
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
|
||||
assert!(app.views[1].has_more);
|
||||
assert!(!app.can_prefetch());
|
||||
let next = app.views[1]
|
||||
.feed_cursor
|
||||
.plan(app.feed_channels.len())
|
||||
.unwrap();
|
||||
assert!(
|
||||
next.iter()
|
||||
.any(|request| request.channel >= PAGE_SIZE && request.start == 1)
|
||||
);
|
||||
assert_eq!(
|
||||
next.iter().map(|request| request.count).sum::<usize>(),
|
||||
PAGE_SIZE
|
||||
);
|
||||
app.views[1].feed_requests = next.clone();
|
||||
app.views[1].prefetch_requested = true;
|
||||
app.finish_page(Ok(r#"{"entries":[]}"#.into()));
|
||||
assert!(app.views[1].page_error.is_some());
|
||||
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
|
||||
assert_eq!(app.views[1].next_start, PAGE_SIZE + 1);
|
||||
assert_eq!(
|
||||
app.views[1]
|
||||
.feed_cursor
|
||||
.plan(app.feed_channels.len())
|
||||
.unwrap(),
|
||||
next
|
||||
);
|
||||
assert!(app.views[1].prefetch_requested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loaded_feed_rows_are_visible_sorted_and_keep_selection_and_description_identity() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels = vec![
|
||||
"https://www.youtube.com/@one".into(),
|
||||
"https://www.youtube.com/@two".into(),
|
||||
];
|
||||
// Keep both channels active despite a short visible page.
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Undated"}]}
|
||||
{"entries":[{"id":"l-GaaivVJCE","title":"Middle","timestamp":200}]}"#,
|
||||
true,
|
||||
);
|
||||
assert_eq!(
|
||||
app.views[1]
|
||||
.videos
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
|
||||
);
|
||||
assert!(view.pending.is_empty());
|
||||
assert!(!view.has_more);
|
||||
assert!(!view.needs_page());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_full_channel_pages_do_not_release_other_channels_ahead_of_newer_uploads() {
|
||||
let mut view = View::new("Feed".into(), ViewSource::Feed);
|
||||
let dated = |base: i64| {
|
||||
serde_json::json!({
|
||||
"entries": (0..PAGE_SIZE).map(|index| serde_json::json!({
|
||||
"id": format!("dated{:06}", base + index as i64),
|
||||
"title": "Dated upload",
|
||||
"timestamp": base + index as i64,
|
||||
})).collect::<Vec<_>>()
|
||||
})
|
||||
};
|
||||
let unavailable = serde_json::json!({
|
||||
"entries": vec![serde_json::Value::Null; PAGE_SIZE]
|
||||
});
|
||||
let page = youtube::feed_results(&format!("{}\n{unavailable}", dated(100))).unwrap();
|
||||
view.accept_page(page, true).unwrap();
|
||||
assert!(view.videos.is_empty());
|
||||
assert_eq!(view.selection.selected(), None);
|
||||
assert!(view.needs_page());
|
||||
let page = youtube::feed_results(&format!("{{\"entries\":[]}}\n{}", dated(200))).unwrap();
|
||||
view.accept_page(page, true).unwrap();
|
||||
assert_eq!(
|
||||
view.videos.first().unwrap().timestamp,
|
||||
Some(200 + PAGE_SIZE as i64 - 1)
|
||||
app.views[1].selection.select(Some(1));
|
||||
let description_id = app.views[1].videos[1].id.clone();
|
||||
app.preview = Some(PreviewMode::Info);
|
||||
app.remember_description("l-GaaivVJCE".into(), "Keep cached preview".into());
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Duplicate","timestamp":100},{"id":"SLV9AJxnb1U","title":"Newer","timestamp":300}]}
|
||||
{"entries":[]}"#,
|
||||
false,
|
||||
);
|
||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
|
||||
view.accept_page(youtube::feed_results(r#"{"entries":[]}"#).unwrap(), true)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
view.videos[PAGE_SIZE].timestamp,
|
||||
Some(100 + PAGE_SIZE as i64 - 1)
|
||||
app.views[1]
|
||||
.videos
|
||||
.iter()
|
||||
.map(|video| video.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["SLV9AJxnb1U", "l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
|
||||
);
|
||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(100));
|
||||
assert!(!view.has_more);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undated_full_feed_pages_stay_pending_and_do_not_look_exhausted() {
|
||||
let mut app = app(&["tubular", "rust"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels
|
||||
.push("https://www.youtube.com/@jawed".into());
|
||||
app.config
|
||||
.channels
|
||||
.insert("@jawed".into(), app.feed_channels[0].clone());
|
||||
let entries: Vec<_> = (0..PAGE_SIZE)
|
||||
.map(|index| {
|
||||
serde_json::json!({
|
||||
"id": format!("unknown{index:04}"),
|
||||
"title": "Undated",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string()));
|
||||
assert!(app.views[1].videos.is_empty());
|
||||
assert_eq!(app.views[1].pending.len(), PAGE_SIZE);
|
||||
assert_eq!(app.views[1].next_start, PAGE_SIZE + 1);
|
||||
assert!(app.views[1].loaded);
|
||||
assert!(app.can_prefetch());
|
||||
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[1].selection.selected(), None);
|
||||
assert!(app.job.is_none());
|
||||
app.finish_page(Err("Continuation failed".into()));
|
||||
assert_eq!(app.views[1].pending.len(), PAGE_SIZE);
|
||||
assert_eq!(app.views[1].next_start, PAGE_SIZE + 1);
|
||||
assert!(!app.can_prefetch());
|
||||
app.finish_page(Ok(r#"{"entries":[]}"#.into()));
|
||||
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
|
||||
assert!(app.views[1].pending.is_empty());
|
||||
assert_eq!(app.views[1].selection.selected(), Some(0));
|
||||
assert!(!app.can_prefetch());
|
||||
app.views[1].next_start = 81;
|
||||
app.views[1].has_more = true;
|
||||
app.views[1].page_error = Some("Refresh resets this".into());
|
||||
app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
|
||||
assert_eq!(app.views[1].next_start, 1);
|
||||
assert!(app.views[1].videos.is_empty());
|
||||
assert!(app.views[1].pending.is_empty());
|
||||
assert_eq!(app.views[1].selection.selected(), Some(2));
|
||||
assert_eq!(app.views[1].videos[2].title, "Old");
|
||||
app.finish_description(
|
||||
description_id,
|
||||
Ok(r#"{"id":"jNQXAC9IVRw","description":"Selected upload description"}"#.into()),
|
||||
);
|
||||
assert_eq!(
|
||||
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
||||
Some("Selected upload description")
|
||||
);
|
||||
assert_eq!(
|
||||
cached_description(&app.descriptions, "l-GaaivVJCE"),
|
||||
Some("Keep cached preview")
|
||||
);
|
||||
assert_eq!(cached_description(&app.descriptions, "SLV9AJxnb1U"), None);
|
||||
assert_eq!(app.preview, Some(PreviewMode::Info));
|
||||
assert!(!app.views[1].has_more);
|
||||
assert!(app.views[1].page_error.is_none());
|
||||
assert!(app.job.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_undated_or_invalid_feed_pages_require_new_forward_navigation() {
|
||||
for visible in [0, 1, 19] {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.view_index = 1;
|
||||
app.feed_channels
|
||||
.push("https://www.youtube.com/@jawed".into());
|
||||
let mut entries: Vec<_> = (0..visible)
|
||||
.map(|index| {
|
||||
serde_json::json!({
|
||||
"id":format!("unknown{index:04}"), "title":"Undated",
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
entries.resize(PAGE_SIZE - 1, serde_json::Value::Null);
|
||||
entries.push(serde_json::json!({"id":"bad", "title":"Invalid ID"}));
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
&serde_json::json!({"entries":entries}).to_string(),
|
||||
false,
|
||||
);
|
||||
assert_eq!(app.views[1].videos.len(), visible);
|
||||
assert!(app.views[1].has_more);
|
||||
assert!(!app.can_prefetch());
|
||||
app.update();
|
||||
assert!(app.job.is_none());
|
||||
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert!(app.can_prefetch());
|
||||
assert_eq!(
|
||||
app.views[1].selection.selected(),
|
||||
if visible == 0 {
|
||||
None
|
||||
} else {
|
||||
Some((visible - 1).min(1))
|
||||
}
|
||||
);
|
||||
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
||||
assert!(app.views[1].has_more);
|
||||
assert!(!app.can_prefetch());
|
||||
app.key(KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT));
|
||||
assert!(app.views[1].prefetch_requested);
|
||||
let next = app.views[1].feed_cursor.plan(1).unwrap();
|
||||
app.finish_page(Err("Continuation failed".into()));
|
||||
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), next);
|
||||
assert!(!app.can_prefetch());
|
||||
app.views[1].reset();
|
||||
assert!(app.views[1].videos.is_empty());
|
||||
assert!(app.views[1].feed_requests.is_empty());
|
||||
assert!(!app.views[1].prefetch_requested);
|
||||
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap()[0].start, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feed_cursors_and_staged_requests_survive_cancellation_and_history_independently() {
|
||||
let mut app = app(&["tubular"]);
|
||||
app.feed_channels
|
||||
.push("https://www.youtube.com/@one".into());
|
||||
app.config
|
||||
.categories
|
||||
.insert("Two".into(), vec!["https://www.youtube.com/@two".into()]);
|
||||
app.views
|
||||
.push(View::new("Two".into(), ViewSource::Category("Two".into())));
|
||||
app.views[0].loaded = true;
|
||||
for index in [1, 2] {
|
||||
app.view_index = index;
|
||||
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
||||
}
|
||||
app.view_index = 1;
|
||||
app.views[1].prefetch_requested = true;
|
||||
app.views[1].feed_requests = app.views[1].feed_cursor.plan(1).unwrap();
|
||||
let cancelled = app.views[1].feed_requests.clone();
|
||||
app.switch_view(2);
|
||||
finish_feed_fixture(&mut app, r#"{"entries":[]}"#, true);
|
||||
assert_eq!(
|
||||
app.views[2].feed_cursor.plan(1).unwrap()[0].start,
|
||||
PAGE_SIZE * 2 + 1
|
||||
);
|
||||
app.back_view();
|
||||
assert_eq!(app.view_index, 1);
|
||||
assert_eq!(app.views[1].feed_requests, cancelled);
|
||||
assert_eq!(app.views[1].feed_cursor.plan(1).unwrap(), cancelled);
|
||||
assert!(app.views[1].prefetch_requested);
|
||||
app.forward_view();
|
||||
assert_eq!(app.view_index, 2);
|
||||
assert!(!app.views[2].prefetch_requested);
|
||||
assert_eq!(
|
||||
app.views[2].feed_cursor.plan(1).unwrap()[0].start,
|
||||
PAGE_SIZE * 2 + 1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_membership_reordering_keeps_cursor_request_intent_and_cache() {
|
||||
let mut app = App::new(
|
||||
Options::parse_from(["tubular"]),
|
||||
PathBuf::new(),
|
||||
config::Config {
|
||||
categories: [(
|
||||
"Creators".into(),
|
||||
vec![
|
||||
"https://www.youtube.com/@two".into(),
|
||||
"https://www.youtube.com/@one".into(),
|
||||
],
|
||||
)]
|
||||
.into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
app.view_index = 2;
|
||||
finish_feed_fixture(
|
||||
&mut app,
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Cached","timestamp":100}]}"#,
|
||||
true,
|
||||
);
|
||||
app.remember_description("jNQXAC9IVRw".into(), "Retained".into());
|
||||
app.views[2].feed_requests = app.views[2].feed_cursor.plan(2).unwrap();
|
||||
app.views[2].prefetch_requested = true;
|
||||
let requests = app.views[2].feed_requests.clone();
|
||||
let mut categories = app.config.categories.clone();
|
||||
categories.get_mut("Creators").unwrap().reverse();
|
||||
app.apply_config(config::Config {
|
||||
categories,
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(app.views[2].feed_requests, requests);
|
||||
assert_eq!(app.views[2].feed_cursor.plan(2).unwrap(), requests);
|
||||
assert!(app.views[2].prefetch_requested);
|
||||
assert_eq!(app.views[2].videos[0].id, "jNQXAC9IVRw");
|
||||
assert_eq!(app.views[2].selection.selected(), Some(0));
|
||||
assert_eq!(
|
||||
cached_description(&app.descriptions, "jNQXAC9IVRw"),
|
||||
Some("Retained")
|
||||
);
|
||||
let mut categories = app.config.categories.clone();
|
||||
categories
|
||||
.get_mut("Creators")
|
||||
.unwrap()
|
||||
.push("https://www.youtube.com/@three".into());
|
||||
app.apply_config(config::Config {
|
||||
categories,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(!app.views[2].loaded);
|
||||
assert!(app.views[2].videos.is_empty());
|
||||
assert!(app.views[2].feed_requests.is_empty());
|
||||
assert!(!app.views[2].prefetch_requested);
|
||||
assert_eq!(app.views[2].next_start, 1);
|
||||
assert!(
|
||||
app.views[2]
|
||||
.feed_cursor
|
||||
.plan(3)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|request| request.start == 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -7624,13 +7862,13 @@ tags.cancel = "z"
|
||||
assert_eq!(app.views[0].next_start, PAGE_SIZE + 1);
|
||||
assert!(!app.views[0].needs_page());
|
||||
let view = &mut app.views[0];
|
||||
view.pending =
|
||||
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Pending"}]}"#)
|
||||
.unwrap()
|
||||
.videos;
|
||||
view.feed_requests = view.feed_cursor.plan(1).unwrap();
|
||||
view.prefetch_requested = true;
|
||||
view.reset();
|
||||
assert!(view.videos.is_empty());
|
||||
assert!(view.pending.is_empty());
|
||||
assert!(view.feed_requests.is_empty());
|
||||
assert!(!view.feed_cursor.has_more());
|
||||
assert!(!view.prefetch_requested);
|
||||
assert_eq!(view.selection.selected(), None);
|
||||
assert_eq!(view.next_start, 1);
|
||||
assert!(!view.loaded);
|
||||
@@ -7649,6 +7887,8 @@ tags.cancel = "z"
|
||||
app.views[0].accept_page(page, false).unwrap();
|
||||
assert!(!app.can_prefetch());
|
||||
app.mode = Mode::Normal;
|
||||
assert!(!app.can_prefetch());
|
||||
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
|
||||
assert!(app.can_prefetch());
|
||||
app.preview = Some(PreviewMode::Description);
|
||||
assert!(!app.can_prefetch());
|
||||
@@ -7945,10 +8185,10 @@ tags.cancel = "z"
|
||||
pinned_categories: vec!["Rust".into()],
|
||||
});
|
||||
app.views[0].selection.select(Some(1));
|
||||
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
|
||||
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
||||
app.views[1].loaded = true;
|
||||
app.views[1].selection.select(Some(0));
|
||||
app.views[2].videos = youtube::feed_results(videos).unwrap().videos;
|
||||
app.views[2].videos = feed_fixture(videos).unwrap().videos;
|
||||
app.views[2].loaded = true;
|
||||
app.views[2].selection.select(Some(0));
|
||||
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
@@ -8018,31 +8258,25 @@ tags.cancel = "z"
|
||||
);
|
||||
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#;
|
||||
assert_eq!(app.feed_channels, vec![direct, registered]);
|
||||
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
|
||||
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
||||
app.views[1].selection.select(Some(1));
|
||||
app.views[1].loaded = true;
|
||||
app.views[1].next_start = 41;
|
||||
app.views[1].has_more = true;
|
||||
app.views[1].pending = youtube::feed_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Buffered","timestamp":50}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[1].feed_requests = app.views[1].feed_cursor.plan(2).unwrap();
|
||||
app.views[1].prefetch_requested = true;
|
||||
app.views[1].page_error = Some("Retained page error".into());
|
||||
app.remember_description(
|
||||
app.views[1].videos[1].id.clone(),
|
||||
"Retained description".into(),
|
||||
);
|
||||
app.views[3].videos = youtube::feed_results(videos).unwrap().videos;
|
||||
app.views[3].videos = feed_fixture(videos).unwrap().videos;
|
||||
app.views[3].selection.select(Some(0));
|
||||
app.views[3].loaded = true;
|
||||
app.views[3].next_start = 61;
|
||||
app.views[3].has_more = true;
|
||||
app.views[3].pending = youtube::feed_results(
|
||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Tag pending","timestamp":50}]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
.videos;
|
||||
app.views[3].feed_requests = app.views[3].feed_cursor.plan(1).unwrap();
|
||||
app.views[3].prefetch_requested = true;
|
||||
app.views[3].page_error = Some("Tag page failed".into());
|
||||
app.switch_view(1);
|
||||
|
||||
@@ -8063,7 +8297,8 @@ tags.cancel = "z"
|
||||
assert_eq!(app.views[1].selection.selected(), Some(1));
|
||||
assert_eq!(app.views[1].next_start, 41);
|
||||
assert!(app.views[1].has_more);
|
||||
assert_eq!(app.views[1].pending[0].id, "jNQXAC9IVRw");
|
||||
assert_eq!(app.views[1].feed_requests.len(), 2);
|
||||
assert!(app.views[1].prefetch_requested);
|
||||
assert_eq!(
|
||||
app.views[1].page_error.as_deref(),
|
||||
Some("Retained page error")
|
||||
@@ -8077,7 +8312,8 @@ tags.cancel = "z"
|
||||
assert_eq!(app.views[4].selection.selected(), None);
|
||||
assert_eq!(app.views[4].next_start, 1);
|
||||
assert!(!app.views[4].has_more);
|
||||
assert!(app.views[4].pending.is_empty());
|
||||
assert!(app.views[4].feed_requests.is_empty());
|
||||
assert!(!app.views[4].prefetch_requested);
|
||||
assert!(app.views[4].page_error.is_none());
|
||||
|
||||
app.apply_config(config::Config {
|
||||
@@ -8097,7 +8333,8 @@ tags.cancel = "z"
|
||||
assert_eq!(app.views[1].selection.selected(), Some(1));
|
||||
assert_eq!(app.views[1].next_start, 41);
|
||||
assert!(app.views[1].has_more);
|
||||
assert_eq!(app.views[1].pending.len(), 1);
|
||||
assert_eq!(app.views[1].feed_requests.len(), 2);
|
||||
assert!(app.views[1].prefetch_requested);
|
||||
assert!(app.views[1].page_error.is_some());
|
||||
|
||||
let mut channels = app.config.channels.clone();
|
||||
@@ -8114,12 +8351,13 @@ tags.cancel = "z"
|
||||
assert_eq!(app.views[1].selection.selected(), None);
|
||||
assert_eq!(app.views[1].next_start, 1);
|
||||
assert!(!app.views[1].has_more);
|
||||
assert!(app.views[1].pending.is_empty());
|
||||
assert!(app.views[1].feed_requests.is_empty());
|
||||
assert!(!app.views[1].prefetch_requested);
|
||||
assert!(app.views[1].page_error.is_none());
|
||||
assert_eq!(app.view_index, 1);
|
||||
assert!(app.job.is_none());
|
||||
|
||||
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
|
||||
app.views[1].videos = feed_fixture(videos).unwrap().videos;
|
||||
app.views[1].selection.select(Some(1));
|
||||
app.views[1].loaded = true;
|
||||
let mut channels = app.config.channels.clone();
|
||||
@@ -8153,7 +8391,7 @@ tags.cancel = "z"
|
||||
});
|
||||
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
|
||||
for (index, view) in app.views.iter_mut().enumerate() {
|
||||
view.videos = youtube::feed_results(videos).unwrap().videos;
|
||||
view.videos = feed_fixture(videos).unwrap().videos;
|
||||
view.loaded = true;
|
||||
view.selection.select(Some(usize::from(index % 2 == 0)));
|
||||
}
|
||||
|
||||
+781
-150
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user