fix!: parse full video info from playlist items, remove PlaylistVideo model

This commit is contained in:
ThetaDev 2023-05-13 21:14:18 +02:00
parent 54f42bcb54
commit bf80db8a9a
21 changed files with 105688 additions and 42159 deletions

View file

@ -2,7 +2,7 @@ use crate::error::{Error, ExtractionError};
use crate::model::{
paginator::{ContinuationEndpoint, Paginator},
traits::FromYtItem,
Comment, MusicItem, PlaylistVideo, YouTubeItem,
Comment, MusicItem, YouTubeItem,
};
use crate::serializer::MapResult;
@ -265,16 +265,6 @@ impl Paginator<Comment> {
}
}
impl Paginator<PlaylistVideo> {
/// Get the next page from the paginator (or `None` if the paginator is exhausted)
pub async fn next<Q: AsRef<RustyPipeQuery>>(&self, query: Q) -> Result<Option<Self>, Error> {
Ok(match &self.ctoken {
Some(ctoken) => Some(query.as_ref().playlist_continuation(ctoken).await?),
None => None,
})
}
}
macro_rules! paginator {
($entity_type:ty) => {
impl Paginator<$entity_type> {
@ -337,7 +327,6 @@ macro_rules! paginator {
}
paginator!(Comment);
paginator!(PlaylistVideo);
#[cfg(test)]
mod tests {
@ -348,15 +337,15 @@ mod tests {
use super::*;
use crate::{
model::{MusicPlaylistItem, PlaylistItem, TrackItem},
model::{MusicPlaylistItem, PlaylistItem, TrackItem, VideoItem},
param::Language,
util::tests::TESTFILES,
};
#[rstest]
#[case("search", path!("search" / "cont.json"))]
#[case("startpage", path!("trends" / "startpage_cont.json"))]
#[case("recommendations", path!("video_details" / "recommendations.json"))]
#[case::search("search", path!("search" / "cont.json"))]
#[case::startpage("startpage", path!("trends" / "startpage_cont.json"))]
#[case::recommendations("recommendations", path!("video_details" / "recommendations.json"))]
fn map_continuation_items(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -377,7 +366,31 @@ mod tests {
}
#[rstest]
#[case("channel_playlists", path!("channel" / "channel_playlists_cont.json"))]
#[case::channel_videos("channel_videos", path!("channel" / "channel_videos_cont.json"))]
#[case::playlist("playlist", path!("playlist" / "playlist_cont.json"))]
fn map_continuation_videos(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
let items: response::Continuation =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res: MapResult<Paginator<YouTubeItem>> =
items.map_response("", Language::En, None).unwrap();
let paginator: Paginator<VideoItem> =
map_yt_paginator(map_res.c, None, ContinuationEndpoint::Browse);
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_{name}"), paginator, {
".items[].publish_date" => "[date]",
});
}
#[rstest]
#[case::channel_playlists("channel_playlists", path!("channel" / "channel_playlists_cont.json"))]
fn map_continuation_playlists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -398,9 +411,9 @@ mod tests {
}
#[rstest]
#[case("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
#[case("search_tracks", path!("music_search" / "tracks_cont.json"))]
#[case("radio_tracks", path!("music_details" / "radio_cont.json"))]
#[case::playlist_tracks("playlist_tracks", path!("music_playlist" / "playlist_cont.json"))]
#[case::search_tracks("search_tracks", path!("music_search" / "tracks_cont.json"))]
#[case::radio_tracks("radio_tracks", path!("music_details" / "radio_cont.json"))]
fn map_continuation_tracks(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();
@ -421,7 +434,7 @@ mod tests {
}
#[rstest]
#[case("playlist_related", path!("music_playlist" / "playlist_related.json"))]
#[case::playlist_related("playlist_related", path!("music_playlist" / "playlist_related.json"))]
fn map_continuation_music_playlists(#[case] name: &str, #[case] path: PathBuf) {
let json_path = path!(*TESTFILES / path);
let json_file = File::open(json_path).unwrap();

View file

@ -4,11 +4,11 @@ use time::OffsetDateTime;
use crate::{
error::{Error, ExtractionError},
model::{paginator::Paginator, ChannelId, Playlist, PlaylistVideo},
model::{paginator::Paginator, ChannelId, Playlist, VideoItem},
util::{self, timeago, TryRemove},
};
use super::{response, ClientType, MapResponse, MapResult, QBrowse, QContinuation, RustyPipeQuery};
use super::{response, ClientType, MapResponse, MapResult, QBrowse, RustyPipeQuery};
impl RustyPipeQuery {
/// Get a YouTube playlist
@ -29,28 +29,6 @@ impl RustyPipeQuery {
)
.await
}
/// Get more playlist items using the given continuation token
pub async fn playlist_continuation<S: AsRef<str>>(
&self,
ctoken: S,
) -> Result<Paginator<PlaylistVideo>, Error> {
let ctoken = ctoken.as_ref();
let context = self.get_context(ClientType::Desktop, true, None).await;
let request_body = QContinuation {
context,
continuation: ctoken,
};
self.execute_request::<response::PlaylistCont, _, _>(
ClientType::Desktop,
"playlist_continuation",
ctoken,
"browse",
&request_body,
)
.await
}
}
impl MapResponse<Playlist> for response::Playlist {
@ -91,7 +69,8 @@ impl MapResponse<Playlist> for response::Playlist {
.playlist_video_list_renderer
.contents;
let (videos, ctoken) = map_playlist_items(video_items.c);
let mut mapper = response::YouTubeListMapper::<VideoItem>::new(lang);
mapper.map_response(video_items);
let (thumbnails, last_update_txt) = match self.sidebar {
Some(sidebar) => {
@ -136,10 +115,11 @@ impl MapResponse<Playlist> for response::Playlist {
}
};
let n_videos = match ctoken {
Some(_) => util::parse_numeric(&header.playlist_header_renderer.num_videos_text)
.map_err(|_| ExtractionError::InvalidData(Cow::Borrowed("no video count")))?,
None => videos.len() as u64,
let n_videos = if mapper.ctoken.is_some() {
util::parse_numeric(&header.playlist_header_renderer.num_videos_text)
.map_err(|_| ExtractionError::InvalidData(Cow::Borrowed("no video count")))?
} else {
mapper.items.len() as u64
};
let playlist_id = header.playlist_header_renderer.playlist_id;
@ -156,16 +136,16 @@ impl MapResponse<Playlist> for response::Playlist {
.owner_text
.and_then(|link| ChannelId::try_from(link).ok());
let mut warnings = video_items.warnings;
let last_update = last_update_txt.as_ref().and_then(|txt| {
timeago::parse_textual_date_or_warn(lang, txt, &mut warnings).map(OffsetDateTime::date)
timeago::parse_textual_date_or_warn(lang, txt, &mut mapper.warnings)
.map(OffsetDateTime::date)
});
Ok(MapResult {
c: Playlist {
id: playlist_id,
name,
videos: Paginator::new(Some(n_videos), videos, ctoken),
videos: Paginator::new(Some(n_videos), mapper.items, mapper.ctoken),
video_count: n_videos,
thumbnail: thumbnails.into(),
description,
@ -174,63 +154,11 @@ impl MapResponse<Playlist> for response::Playlist {
last_update_txt,
visitor_data: self.response_context.visitor_data,
},
warnings,
warnings: mapper.warnings,
})
}
}
impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
fn map_response(
self,
_id: &str,
_lang: crate::param::Language,
_deobf: Option<&crate::deobfuscate::DeobfData>,
) -> Result<MapResult<Paginator<PlaylistVideo>>, ExtractionError> {
let action = self.on_response_received_actions.into_iter().next();
let ((items, ctoken), warnings) = action
.map(|action| {
(
map_playlist_items(
action.append_continuation_items_action.continuation_items.c,
),
action
.append_continuation_items_action
.continuation_items
.warnings,
)
})
.unwrap_or_default();
Ok(MapResult {
c: Paginator::new(None, items, ctoken),
warnings,
})
}
}
fn map_playlist_items(
items: Vec<response::playlist::PlaylistItem>,
) -> (Vec<PlaylistVideo>, Option<String>) {
let mut ctoken: Option<String> = None;
let videos = items
.into_iter()
.filter_map(|it| match it {
response::playlist::PlaylistItem::PlaylistVideoRenderer(video) => {
PlaylistVideo::try_from(video).ok()
}
response::playlist::PlaylistItem::ContinuationItemRenderer {
continuation_endpoint,
} => {
ctoken = Some(continuation_endpoint.continuation_command.token);
None
}
response::playlist::PlaylistItem::None => None,
})
.collect::<Vec<_>>();
(videos, ctoken)
}
#[cfg(test)]
mod tests {
use std::{fs::File, io::BufReader};
@ -246,6 +174,7 @@ mod tests {
#[case::short("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk")]
#[case::long("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ")]
#[case::nomusic("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe")]
#[case::live("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw")]
fn map_playlist_data(#[case] name: &str, #[case] id: &str) {
let json_path = path!(*TESTFILES / "playlist" / format!("playlist_{name}.json"));
let json_file = File::open(json_path).unwrap();
@ -260,24 +189,8 @@ mod tests {
map_res.warnings
);
insta::assert_ron_snapshot!(format!("map_playlist_data_{name}"), map_res.c, {
".last_update" => "[date]"
".last_update" => "[date]",
".videos.items[].publish_date" => "[date]",
});
}
#[test]
fn map_playlist_cont() {
let json_path = path!(*TESTFILES / "playlist" / "playlist_cont.json");
let json_file = File::open(json_path).unwrap();
let playlist: response::PlaylistCont =
serde_json::from_reader(BufReader::new(json_file)).unwrap();
let map_res = playlist.map_response("", Language::En, None).unwrap();
assert!(
map_res.warnings.is_empty(),
"deserialization/mapping warnings: {:?}",
map_res.warnings
);
insta::assert_ron_snapshot!("map_playlist_cont", map_res.c);
}
}

View file

@ -31,7 +31,6 @@ pub(crate) use music_search::MusicSearch;
pub(crate) use music_search::MusicSearchSuggestion;
pub(crate) use player::Player;
pub(crate) use playlist::Playlist;
pub(crate) use playlist::PlaylistCont;
pub(crate) use search::Search;
pub(crate) use search::SearchSuggestion;
pub(crate) use trends::Startpage;

View file

@ -1,16 +1,10 @@
use serde::Deserialize;
use serde_with::{
json::JsonString, rust::deserialize_ignore_any, serde_as, DefaultOnError, VecSkipError,
};
use serde_with::{serde_as, DefaultOnError};
use crate::serializer::{
text::{Text, TextComponent},
MapResult,
};
use crate::util::MappingError;
use crate::serializer::text::{Text, TextComponent};
use super::{
Alert, ContentsRenderer, ContinuationEndpoint, ResponseContext, SectionList, Tab, Thumbnails,
video_item::YouTubeListRenderer, Alert, ContentsRenderer, ResponseContext, SectionList, Tab,
ThumbnailsWrap, TwoColumnBrowseResults,
};
@ -26,15 +20,6 @@ pub(crate) struct Playlist {
pub response_context: ResponseContext,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistCont {
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub on_response_received_actions: Vec<OnResponseReceivedAction>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ItemSection {
@ -44,13 +29,7 @@ pub(crate) struct ItemSection {
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistVideoListRenderer {
pub playlist_video_list_renderer: PlaylistVideoList,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistVideoList {
pub contents: MapResult<Vec<PlaylistItem>>,
pub playlist_video_list_renderer: YouTubeListRenderer,
}
#[derive(Debug, Deserialize)]
@ -130,63 +109,3 @@ pub(crate) struct PlaylistThumbnailRenderer {
#[serde(alias = "playlistCustomThumbnailRenderer")]
pub playlist_video_thumbnail_renderer: ThumbnailsWrap,
}
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) enum PlaylistItem {
/// Video in playlist
PlaylistVideoRenderer(PlaylistVideoRenderer),
/// Continauation items are located at the end of a list
/// and contain the continuation token for progressive loading
#[serde(rename_all = "camelCase")]
ContinuationItemRenderer {
continuation_endpoint: ContinuationEndpoint,
},
/// No video list item (e.g. ad) or unimplemented item
#[serde(other, deserialize_with = "deserialize_ignore_any")]
None,
}
/// Video displayed in a playlist
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistVideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
#[serde_as(as = "JsonString")]
pub length_seconds: u32,
}
impl TryFrom<PlaylistVideoRenderer> for crate::model::PlaylistVideo {
type Error = MappingError;
fn try_from(video: PlaylistVideoRenderer) -> Result<Self, Self::Error> {
Ok(Self {
id: video.video_id,
name: video.title,
length: video.length_seconds,
thumbnail: video.thumbnail.into(),
channel: crate::model::ChannelId::try_from(video.channel)?,
})
}
}
// Continuation
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OnResponseReceivedAction {
pub append_continuation_items_action: AppendAction,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AppendAction {
pub continuation_items: MapResult<Vec<PlaylistItem>>,
}

View file

@ -9,8 +9,8 @@ use time::OffsetDateTime;
use super::{url_endpoint::NavigationEndpoint, ChannelBadge, ContinuationEndpoint, Thumbnails};
use crate::{
model::{
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, VideoItem,
YouTubeItem,
Channel, ChannelId, ChannelInfo, ChannelItem, ChannelTag, PlaylistItem, Verification,
VideoItem, YouTubeItem,
},
param::Language,
serializer::{
@ -27,6 +27,7 @@ pub(crate) enum YouTubeListItem {
#[serde(alias = "gridVideoRenderer", alias = "compactVideoRenderer")]
VideoRenderer(VideoRenderer),
ReelItemRenderer(ReelItemRenderer),
PlaylistVideoRenderer(PlaylistVideoRenderer),
#[serde(alias = "gridPlaylistRenderer")]
PlaylistRenderer(PlaylistRenderer),
@ -145,6 +146,33 @@ pub(crate) struct ReelItemRenderer {
pub navigation_endpoint: Option<ReelNavigationEndpoint>,
}
/// Video displayed in a playlist
#[serde_as]
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct PlaylistVideoRenderer {
pub video_id: String,
pub thumbnail: Thumbnails,
#[serde_as(as = "Text")]
pub title: String,
#[serde(rename = "shortBylineText")]
pub channel: TextComponent,
#[serde_as(as = "Option<JsonString>")]
pub length_seconds: Option<u32>,
/// Regular video: `["29K views", " • ", "13 years ago"]`
/// Livestream: `["66K", " watching"]`
/// Upcoming: `["8", " waiting"]`
#[serde(default)]
#[serde_as(as = "Text")]
pub video_info: Vec<String>,
/// Contains Short/Live tag
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
pub thumbnail_overlays: Vec<TimeOverlay>,
/// Release date for upcoming videos
pub upcoming_event_data: Option<UpcomingEventData>,
}
/// Playlist displayed in search results
#[serde_as]
#[derive(Debug, Deserialize)]
@ -492,7 +520,7 @@ impl<T> YouTubeListMapper<T> {
}
}
fn map_short_video(&mut self, video: ReelItemRenderer, lang: Language) -> VideoItem {
fn map_short_video(&mut self, video: ReelItemRenderer) -> VideoItem {
let pub_date_txt = video.navigation_endpoint.map(|n| {
n.reel_watch_endpoint
.overlay
@ -505,7 +533,7 @@ impl<T> YouTubeListMapper<T> {
let length = video.accessibility.and_then(|acc| {
let parts = ACCESSIBILITY_SEP_REGEX.split(&acc).collect::<Vec<_>>();
if parts.len() > 2 {
let i = match lang {
let i = match self.lang {
Language::Ru => 1,
_ => 2,
};
@ -531,9 +559,9 @@ impl<T> YouTubeListMapper<T> {
timeago::parse_timeago_dt_or_warn(self.lang, txt, &mut self.warnings)
}),
publish_date_txt: pub_date_txt,
view_count: video
.view_count_text
.and_then(|txt| util::parse_large_numstr_or_warn(&txt, lang, &mut self.warnings)),
view_count: video.view_count_text.and_then(|txt| {
util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings)
}),
is_live: false,
is_short: true,
is_upcoming: false,
@ -541,6 +569,68 @@ impl<T> YouTubeListMapper<T> {
}
}
fn map_playlist_video(&mut self, video: PlaylistVideoRenderer) -> VideoItem {
let channel = ChannelId::try_from(video.channel)
.ok()
.map(|ch| ChannelTag {
id: ch.id,
name: ch.name,
avatar: Vec::new(),
verification: Verification::None,
subscriber_count: None,
});
let mut video_info = video.video_info.into_iter();
let video_info1 = video_info
.next()
.map(|s| match video_info.next().as_deref() {
None | Some(util::DOT_SEPARATOR) => s,
Some(s2) => s + s2,
});
let video_info2 = video_info.next();
// RU: "7 лет назад" " • " "210 млн просмотров" (order flipped)
let (view_count_txt, publish_date_txt) =
if self.lang == Language::Ru && video_info2.is_some() {
(video_info2, video_info1)
} else {
(video_info1, video_info2)
};
let is_live = video.thumbnail_overlays.is_live();
let publish_date = video
.upcoming_event_data
.as_ref()
.and_then(|upc| OffsetDateTime::from_unix_timestamp(upc.start_time).ok())
.or_else(|| {
if is_live {
None
} else {
publish_date_txt.as_ref().and_then(|txt| {
timeago::parse_timeago_dt_or_warn(self.lang, txt, &mut self.warnings)
})
}
});
VideoItem {
id: video.video_id,
name: video.title,
length: video.length_seconds,
thumbnail: video.thumbnail.into(),
channel,
publish_date,
publish_date_txt,
view_count: view_count_txt.and_then(|txt| {
util::parse_large_numstr_or_warn(&txt, self.lang, &mut self.warnings)
}),
is_live,
is_short: video.thumbnail_overlays.is_short(),
is_upcoming: video.upcoming_event_data.is_some(),
short_description: None,
}
}
fn map_playlist(&self, playlist: PlaylistRenderer) -> PlaylistItem {
PlaylistItem {
id: playlist.playlist_id,
@ -607,7 +697,11 @@ impl YouTubeListMapper<YouTubeItem> {
self.items.push(mapped);
}
YouTubeListItem::ReelItemRenderer(video) => {
let mapped = self.map_short_video(video, self.lang);
let mapped = self.map_short_video(video);
self.items.push(YouTubeItem::Video(mapped));
}
YouTubeListItem::PlaylistVideoRenderer(video) => {
let mapped = self.map_playlist_video(video);
self.items.push(YouTubeItem::Video(mapped));
}
YouTubeListItem::PlaylistRenderer(playlist) => {
@ -671,7 +765,11 @@ impl YouTubeListMapper<VideoItem> {
self.items.push(mapped);
}
YouTubeListItem::ReelItemRenderer(video) => {
let mapped = self.map_short_video(video, self.lang);
let mapped = self.map_short_video(video);
self.items.push(mapped);
}
YouTubeListItem::PlaylistVideoRenderer(video) => {
let mapped = self.map_playlist_video(video);
self.items.push(mapped);
}
YouTubeListItem::ContinuationItemRenderer {

View file

@ -1,13 +1,13 @@
---
source: src/client/channel.rs
expression: map_res.c
source: src/client/pagination.rs
expression: paginator
---
Paginator(
count: None,
items: [
ChannelVideo(
VideoItem(
id: "R2fw2g6WFbg",
title: "EEVblog 1477 - TEARDOWN! - NEW Tektronix 2 Series Oscilloscope",
name: "EEVblog 1477 - TEARDOWN! - NEW Tektronix 2 Series Oscilloscope",
length: Some(2718),
thumbnail: [
Thumbnail(
@ -31,16 +31,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("3 months ago"),
view_count: 80296,
view_count: Some(80296),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "JDXKrXJloSw",
title: "EEVblog 1476 - Keithley 515A Wheatstone Bridge TEARDOWN & TUTORIAL",
name: "EEVblog 1476 - Keithley 515A Wheatstone Bridge TEARDOWN & TUTORIAL",
length: Some(1721),
thumbnail: [
Thumbnail(
@ -64,16 +66,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("3 months ago"),
view_count: 36294,
view_count: Some(36294),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "H8ot_YPi6QU",
title: "eevBLAB 98 - The Pressure Youtubers Are Under",
name: "eevBLAB 98 - The Pressure Youtubers Are Under",
length: Some(431),
thumbnail: [
Thumbnail(
@ -97,16 +101,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("3 months ago"),
view_count: 34736,
view_count: Some(34736),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "i1Ad5jfk_v4",
title: "EEVblog 1475 - What\'s This SMD Part?",
name: "EEVblog 1475 - What\'s This SMD Part?",
length: Some(1785),
thumbnail: [
Thumbnail(
@ -130,16 +136,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("3 months ago"),
view_count: 73544,
view_count: Some(73544),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "GHbo4v8pahc",
title: "eevBLAB 97 - Is Apple Serious About Right To Repair? (The Verge)",
name: "eevBLAB 97 - Is Apple Serious About Right To Repair? (The Verge)",
length: Some(1186),
thumbnail: [
Thumbnail(
@ -163,16 +171,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: 67231,
view_count: Some(67231),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "Uds-wLoaZmA",
title: "EEVblog 1474 - Can You Measure Capacitors IN Circuit?",
name: "EEVblog 1474 - Can You Measure Capacitors IN Circuit?",
length: Some(1407),
thumbnail: [
Thumbnail(
@ -196,16 +206,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: 44946,
view_count: Some(44946),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "D9J-AmCcf4U",
title: "EEVblog 1473 - How Your LCR Meter Works",
name: "EEVblog 1473 - How Your LCR Meter Works",
length: Some(1183),
thumbnail: [
Thumbnail(
@ -229,16 +241,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: 43264,
view_count: Some(43264),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "Eoh-JKVQZwg",
title: "EEVblog 1472 - Resistor Cube Problem SOLVED",
name: "EEVblog 1472 - Resistor Cube Problem SOLVED",
length: Some(1196),
thumbnail: [
Thumbnail(
@ -262,16 +276,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: 98175,
view_count: Some(98175),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "U81glZBDpIg",
title: "EEVblog 1471 - Mailbag",
name: "EEVblog 1471 - Mailbag",
length: Some(2252),
thumbnail: [
Thumbnail(
@ -295,16 +311,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("4 months ago"),
view_count: 59376,
view_count: Some(59376),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "gLfxznVJ2q0",
title: "Petition - Australian Standards Should be FREE",
name: "Petition - Australian Standards Should be FREE",
length: Some(585),
thumbnail: [
Thumbnail(
@ -328,16 +346,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 25496,
view_count: Some(25496),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "GfihUkWPCQQ",
title: "EEVblog 1470 - AC Basics Tutorial Part 3 - Complex Numbers are EASY!",
name: "EEVblog 1470 - AC Basics Tutorial Part 3 - Complex Numbers are EASY!",
length: Some(1468),
thumbnail: [
Thumbnail(
@ -361,16 +381,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 22982,
view_count: Some(22982),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "yEG6pKUdIlg",
title: "EEVblog 1469 - AC Basics Tutorial - Part 2 - Phasors",
name: "EEVblog 1469 - AC Basics Tutorial - Part 2 - Phasors",
length: Some(1147),
thumbnail: [
Thumbnail(
@ -394,16 +416,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 38804,
view_count: Some(38804),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "wPzzPGzxD00",
title: "EEVblog 1468 - Electronex Show Tour 2022",
name: "EEVblog 1468 - Electronex Show Tour 2022",
length: Some(2850),
thumbnail: [
Thumbnail(
@ -427,16 +451,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 25505,
view_count: Some(25505),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "Tdge8vEODeY",
title: "EEVblog 1467 - Stanford Solar Power at Nightime! BUSTED",
name: "EEVblog 1467 - Stanford Solar Power at Nightime! BUSTED",
length: Some(836),
thumbnail: [
Thumbnail(
@ -460,16 +486,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 98432,
view_count: Some(98432),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "ebQ2Im5zfT0",
title: "EEVblog 1466 - Dumpster Dive Xeon Server",
name: "EEVblog 1466 - Dumpster Dive Xeon Server",
length: Some(1138),
thumbnail: [
Thumbnail(
@ -493,16 +521,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 53410,
view_count: Some(53410),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "UrS5ezesA9s",
title: "EEVblog 1465 - Your Multimeter Can Measure Inductors",
name: "EEVblog 1465 - Your Multimeter Can Measure Inductors",
length: Some(596),
thumbnail: [
Thumbnail(
@ -526,16 +556,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("5 months ago"),
view_count: 54771,
view_count: Some(54771),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "c5M8P6oe9xY",
title: "EEVblog 1464 - TOP 5 Jellybean Comparators",
name: "EEVblog 1464 - TOP 5 Jellybean Comparators",
length: Some(2399),
thumbnail: [
Thumbnail(
@ -559,16 +591,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 39823,
view_count: Some(39823),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "9TDKP9RLlPs",
title: "EEVblog 1463 - Mailbag",
name: "EEVblog 1463 - Mailbag",
length: Some(2664),
thumbnail: [
Thumbnail(
@ -592,16 +626,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 51596,
view_count: Some(51596),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "hwggIw2HQuQ",
title: "eevBLAB 96 - BUSTED! - Dymo Gets WORSE!",
name: "eevBLAB 96 - BUSTED! - Dymo Gets WORSE!",
length: Some(347),
thumbnail: [
Thumbnail(
@ -625,16 +661,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 125391,
view_count: Some(125391),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "xzSDJRC0F6c",
title: "EEVblog 1462 - Why Dymo Label Printers SUCK!",
name: "EEVblog 1462 - Why Dymo Label Printers SUCK!",
length: Some(1353),
thumbnail: [
Thumbnail(
@ -658,16 +696,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 120457,
view_count: Some(120457),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "9wuyPZjjR9k",
title: "EEVblog 1461 - The MOSFET Search CHALLENGE",
name: "EEVblog 1461 - The MOSFET Search CHALLENGE",
length: Some(3505),
thumbnail: [
Thumbnail(
@ -691,16 +731,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 49062,
view_count: Some(49062),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "vyJuMGEFbjQ",
title: "EEVblog1460 - REPAIRING a LED Studio Light with a DUMPSTER LAPTOP!",
name: "EEVblog1460 - REPAIRING a LED Studio Light with a DUMPSTER LAPTOP!",
length: Some(1798),
thumbnail: [
Thumbnail(
@ -724,16 +766,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("6 months ago"),
view_count: 49032,
view_count: Some(49032),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "_pETMto-9iE",
title: "EEVblog 1459 - Is it worth PARTS SALVAGING an Inkjet Printer/Scanner?",
name: "EEVblog 1459 - Is it worth PARTS SALVAGING an Inkjet Printer/Scanner?",
length: Some(1588),
thumbnail: [
Thumbnail(
@ -757,16 +801,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 64108,
view_count: Some(64108),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "MvFf9RSJUhk",
title: "EEVblog 1458 - Microscope Polarising MAGIC!",
name: "EEVblog 1458 - Microscope Polarising MAGIC!",
length: Some(942),
thumbnail: [
Thumbnail(
@ -790,16 +836,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 76831,
view_count: Some(76831),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "E6obq3T71vI",
title: "EEVblog1457 - Old School Mailbag - ESC Burnout",
name: "EEVblog1457 - Old School Mailbag - ESC Burnout",
length: Some(1552),
thumbnail: [
Thumbnail(
@ -823,16 +871,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 49961,
view_count: Some(49961),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "ZTwVQmUm6NY",
title: "eevBLAB 95 - Why Are Youtube Playlists So BAD?",
name: "eevBLAB 95 - Why Are Youtube Playlists So BAD?",
length: Some(865),
thumbnail: [
Thumbnail(
@ -856,16 +906,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 17393,
view_count: Some(17393),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "prQinQ4MWmU",
title: "EEVblog 1456 - Sega Toys Homestar Planetarium REPAIR",
name: "EEVblog 1456 - Sega Toys Homestar Planetarium REPAIR",
length: Some(899),
thumbnail: [
Thumbnail(
@ -889,16 +941,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 38281,
view_count: Some(38281),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "yMIzsFAztv4",
title: "EEVblog 1455 - Capacitors Produce Current During Reflow Soldering! WTF!",
name: "EEVblog 1455 - Capacitors Produce Current During Reflow Soldering! WTF!",
length: Some(894),
thumbnail: [
Thumbnail(
@ -922,16 +976,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 70004,
view_count: Some(70004),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "QtqljdMwRyk",
title: "EEVblog 1454 - Water from Air AGAIN! - The Kara Pure",
name: "EEVblog 1454 - Water from Air AGAIN! - The Kara Pure",
length: Some(1198),
thumbnail: [
Thumbnail(
@ -955,16 +1011,18 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 93700,
view_count: Some(93700),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
ChannelVideo(
VideoItem(
id: "kcWwAweWjQg",
title: "EEVblog 1453 - Elgato Key Light TEARDOWN",
name: "EEVblog 1453 - Elgato Key Light TEARDOWN",
length: Some(1048),
thumbnail: [
Thumbnail(
@ -988,13 +1046,16 @@ Paginator(
height: 188,
),
],
channel: None,
publish_date: "[date]",
publish_date_txt: Some("7 months ago"),
view_count: 37515,
view_count: Some(37515),
is_live: false,
is_short: false,
is_upcoming: false,
short_description: None,
),
],
ctoken: Some("4qmFsgKxARIYVUMyRGpGRTdYZjExVVJacVdCaWdjVk9RGmZFZ1oyYVdSbGIzTVlBeUFBTUFFNEFlb0ROME5uVGtSU1JXdFRRM2RwU1cxMGNUaHpTVVJ6TkhCRlFrdEVTWGRCYW1jNFVXZHpTUzFRUkVodFVWbFJhVzloY0VWclowTlZSRWslM0SaAixicm93c2UtZmVlZFVDMkRqRkU3WGYxMVVSWnFXQmlnY1ZPUXZpZGVvczEwMg%3D%3D"),
endpoint: browse,
)

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
use super::{
AlbumItem, ArtistId, ArtistItem, Channel, ChannelId, ChannelItem, ChannelRssVideo, ChannelTag,
MusicArtist, MusicItem, MusicPlaylistItem, PlaylistItem, PlaylistVideo, TrackItem, VideoId,
VideoItem, YouTubeItem,
MusicArtist, MusicItem, MusicPlaylistItem, PlaylistItem, TrackItem, VideoId, VideoItem,
YouTubeItem,
};
/// Trait for casting generic YouTube/YouTube music items to a specific kind.
@ -159,15 +159,6 @@ impl From<VideoItem> for VideoId {
}
}
impl From<PlaylistVideo> for VideoId {
fn from(video: PlaylistVideo) -> Self {
Self {
id: video.id,
name: video.name,
}
}
}
impl From<ChannelRssVideo> for VideoId {
fn from(video: ChannelRssVideo) -> Self {
Self {

View file

@ -510,7 +510,7 @@ pub struct Playlist {
/// Playlist name
pub name: String,
/// Playlist videos
pub videos: Paginator<PlaylistVideo>,
pub videos: Paginator<VideoItem>,
/// Number of videos in the playlist
pub video_count: u64,
/// Playlist thumbnail
@ -528,22 +528,6 @@ pub struct Playlist {
pub visitor_data: Option<String>,
}
/// YouTube video extracted from a playlist
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct PlaylistVideo {
/// Unique YouTube video ID
pub id: String,
/// Video title
pub name: String,
/// Video length in seconds
pub length: u32,
/// Video thumbnail
pub thumbnail: Vec<Thumbnail>,
/// Channel of the video
pub channel: ChannelId,
}
/// Channel identifier
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]

View file

@ -329,7 +329,7 @@ where
if after_point {
exp -= 1;
}
} else if c == decimal_point {
} else if c == decimal_point && !digits.is_empty() {
after_point = true;
} else if !matches!(
c,
@ -581,6 +581,7 @@ pub(crate) mod tests {
3_360_000
)]
#[case(Language::As, "১ জন গ্ৰাহক", 1)]
#[case(Language::Ru, "Зрителей, ожидающих начала трансляции: 6", 6)]
fn t_parse_large_numstr(#[case] lang: Language, #[case] string: &str, #[case] expect: u64) {
let res = parse_large_numstr::<u64>(string, lang).unwrap();
assert_eq!(res, expect);