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

@ -181,6 +181,7 @@ async fn playlist() {
("short", "RDCLAK5uy_kFQXdnqMaQCVx2wpUM4ZfbsGCDibZtkJk"),
("long", "PL5dDx681T4bR7ZF1IuWzOv1omlRbE7PiJ"),
("nomusic", "PL1J-6JOckZtE_P9Xx8D3b2O6w0idhuKBe"),
("live", "UULVvqRdlKsE5Q8mf8YXbdIJLw"),
] {
let json_path = path!(*TESTFILES_DIR / "playlist" / format!("playlist_{name}.json"));
if json_path.exists() {

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);

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -340,6 +340,13 @@ fn get_player_error(#[case] id: &str, #[case] expect: UnavailabilityReason, rp:
Some("SHINE - Survival Hardcore in New Environment: Auf einem Server machen sich tapfere Spieler auf, mystische Welten zu erkunden, magische Technologien zu erforschen und vorallem zu überleben...".to_owned()),
Some(("UCQM0bS4_04-Y4JuYrgmnpZQ", "Chaosflo44")),
)]
#[case::live(
"UULVvqRdlKsE5Q8mf8YXbdIJLw",
"Live streams",
true,
None,
Some(("UCvqRdlKsE5Q8mf8YXbdIJLw", "LoL Esports"))
)]
fn get_playlist(
#[case] id: &str,
#[case] name: &str,