feat: add channel_videos
refactor: unify VideoListItem
This commit is contained in:
parent
86a348f210
commit
67ae1eb21d
19 changed files with 39591 additions and 100 deletions
|
|
@ -10,7 +10,7 @@ inspired by [NewPipe](https://github.com/TeamNewPipe/NewPipeExtractor).
|
||||||
- [X] **Player** (video/audio streams, subtitles)
|
- [X] **Player** (video/audio streams, subtitles)
|
||||||
- TODO: Livestream support
|
- TODO: Livestream support
|
||||||
- [X] **Playlist**
|
- [X] **Playlist**
|
||||||
- [ ] **VideoDetails** (metadata, comments, recommended videos)
|
- [X] **VideoDetails** (metadata, comments, recommended videos)
|
||||||
- [ ] **Channel**
|
- [ ] **Channel**
|
||||||
- [ ] **ChannelRSS**
|
- [ ] **ChannelRSS**
|
||||||
- [ ] **Search**
|
- [ ] **Search**
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub async fn download_testfiles(project_root: &Path) {
|
||||||
playlist(&testfiles),
|
playlist(&testfiles),
|
||||||
video_details(&testfiles),
|
video_details(&testfiles),
|
||||||
comments_top(&testfiles),
|
comments_top(&testfiles),
|
||||||
|
channel_videos(&testfiles),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -159,3 +160,22 @@ async fn comments_top(testfiles: &Path) {
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn channel_videos(testfiles: &Path) {
|
||||||
|
for (name, id) in [
|
||||||
|
("base", "UC2DjFE7Xf11URZqWBigcVOQ"),
|
||||||
|
("music", "UC_vmjW5e1xEHhYjY2a0kK1A"), // YouTube Music channels have no videos
|
||||||
|
("shorts", "UCh8gHdtzO2tXd593_bjErWg"), // shorts and livestreams are rendered differently
|
||||||
|
("live", "UChs0pSaEoNLV4mevBFGaoKA"),
|
||||||
|
] {
|
||||||
|
let mut json_path = testfiles.to_path_buf();
|
||||||
|
json_path.push("channel");
|
||||||
|
json_path.push(format!("channel_videos_{}.json", name));
|
||||||
|
if json_path.exists() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rp = rp_testfile(&json_path);
|
||||||
|
rp.query().channel_videos(id).await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,3 +52,13 @@ Sep PL1J-6JOckZtHVs0JhBW_qfsW-dtXuM0mQ 16.09.2018
|
||||||
Oct PL1J-6JOckZtE4g-XgZkL_N0kkoKui5Eys 31.10.2014
|
Oct PL1J-6JOckZtE4g-XgZkL_N0kkoKui5Eys 31.10.2014
|
||||||
Nov PL1J-6JOckZtEzjMUEyPyPpG836pjeIapw 03.11.2016
|
Nov PL1J-6JOckZtEzjMUEyPyPpG836pjeIapw 03.11.2016
|
||||||
Dec PL1J-6JOckZtHo91uApeb10Qlf2XhkfM-9 24.12.2021
|
Dec PL1J-6JOckZtHo91uApeb10Qlf2XhkfM-9 24.12.2021
|
||||||
|
|
||||||
|
# Channels
|
||||||
|
10e8: 225M UCq-Fj5jknLsUf-MWSy4_brA
|
||||||
|
10e7: 52M UC0C-w0YjGpqDXGB8IHb662A
|
||||||
|
10e6: 1.7M UC6mIxFTvXkWQVEHPsEdflzQ
|
||||||
|
10e5: 125K UCD0y51PJfvkZNe3y3FR5riw
|
||||||
|
10e4: 27K UCNcN0dW43zE0Om3278fjY8A
|
||||||
|
10e3: 5K UCNcN0dW43zE0Om3278fjY8A
|
||||||
|
10e2: 388 UCllyEQfcoiPN68zHv6mGHDQ
|
||||||
|
10e1: 37 UCNcN0dW43zE0Om3278fjY8A
|
||||||
|
|
|
||||||
83
src/client/channel.rs
Normal file
83
src/client/channel.rs
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use reqwest::Method;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::{model::ChannelVideos, serializer::MapResult};
|
||||||
|
|
||||||
|
use super::{response, ClientType, MapResponse, RustyPipeQuery, YTContext};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct QChannel {
|
||||||
|
context: YTContext,
|
||||||
|
browse_id: String,
|
||||||
|
params: Params,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
enum Params {
|
||||||
|
#[serde(rename = "EgZ2aWRlb3PyBgQKAjoA")]
|
||||||
|
VideosLatest,
|
||||||
|
#[serde(rename = "EgZ2aWRlb3MYAiAAMAE%3D")]
|
||||||
|
VideosOldest,
|
||||||
|
#[serde(rename = "EgZ2aWRlb3MYASAAMAE%3D")]
|
||||||
|
VideosPopular,
|
||||||
|
#[serde(rename = "EglwbGF5bGlzdHMgAQ%3D%3D")]
|
||||||
|
Playlists,
|
||||||
|
#[serde(rename = "EgVhYm91dPIGBAoCEgA%3D")]
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RustyPipeQuery {
|
||||||
|
pub async fn channel_videos(&self, channel_id: &str) -> Result<ChannelVideos> {
|
||||||
|
let context = self.get_context(ClientType::Desktop, true).await;
|
||||||
|
let request_body = QChannel {
|
||||||
|
context,
|
||||||
|
browse_id: channel_id.to_owned(),
|
||||||
|
params: Params::VideosLatest,
|
||||||
|
};
|
||||||
|
|
||||||
|
self.execute_request::<response::Channel, _, _>(
|
||||||
|
ClientType::Desktop,
|
||||||
|
"channel_videos",
|
||||||
|
channel_id,
|
||||||
|
Method::POST,
|
||||||
|
"browse",
|
||||||
|
&request_body,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MapResponse<ChannelVideos> for response::Channel {
|
||||||
|
fn map_response(
|
||||||
|
self,
|
||||||
|
id: &str,
|
||||||
|
_lang: crate::model::Language,
|
||||||
|
_deobf: Option<&crate::deobfuscate::Deobfuscator>,
|
||||||
|
) -> Result<MapResult<ChannelVideos>> {
|
||||||
|
let warnings = Vec::new();
|
||||||
|
// dbg!(&self);
|
||||||
|
let header = self.header.c4_tabbed_header_renderer;
|
||||||
|
|
||||||
|
if header.channel_id != id {
|
||||||
|
bail!(
|
||||||
|
"got wrong channel id {}, expected {}",
|
||||||
|
header.channel_id,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(MapResult {
|
||||||
|
c: ChannelVideos {
|
||||||
|
id: header.channel_id,
|
||||||
|
name: header.title,
|
||||||
|
subscriber_count_txt: header.subscriber_count_text,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {}
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
pub mod pagination;
|
mod channel;
|
||||||
pub mod player;
|
mod pagination;
|
||||||
pub mod playlist;
|
mod player;
|
||||||
pub mod video_details;
|
mod playlist;
|
||||||
|
|
||||||
mod response;
|
mod response;
|
||||||
|
mod video_details;
|
||||||
|
|
||||||
use std::fmt::Debug;
|
use std::fmt::Debug;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ use super::{
|
||||||
ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext,
|
ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QPlayer {
|
struct QPlayer {
|
||||||
context: YTContext,
|
context: YTContext,
|
||||||
|
|
@ -42,13 +42,13 @@ struct QPlayer {
|
||||||
racy_check_ok: bool,
|
racy_check_ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QPlaybackContext {
|
struct QPlaybackContext {
|
||||||
content_playback_context: QContentPlaybackContext,
|
content_playback_context: QContentPlaybackContext,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QContentPlaybackContext {
|
struct QContentPlaybackContext {
|
||||||
/// Signature timestamp extracted from player.js
|
/// Signature timestamp extracted from player.js
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ use crate::{
|
||||||
|
|
||||||
use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
|
use super::{response, ClientType, MapResponse, MapResult, RustyPipeQuery, YTContext};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QPlaylist {
|
struct QPlaylist {
|
||||||
context: YTContext,
|
context: YTContext,
|
||||||
browse_id: String,
|
browse_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QPlaylistCont {
|
struct QPlaylistCont {
|
||||||
context: YTContext,
|
context: YTContext,
|
||||||
|
|
@ -206,14 +206,12 @@ impl MapResponse<Paginator<PlaylistVideo>> for response::PlaylistCont {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_playlist_items(
|
fn map_playlist_items(items: Vec<response::VideoListItem>) -> (Vec<PlaylistVideo>, Option<String>) {
|
||||||
items: Vec<response::VideoListItem<response::playlist::PlaylistVideo>>,
|
|
||||||
) -> (Vec<PlaylistVideo>, Option<String>) {
|
|
||||||
let mut ctoken: Option<String> = None;
|
let mut ctoken: Option<String> = None;
|
||||||
let videos = items
|
let videos = items
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|it| match it {
|
.filter_map(|it| match it {
|
||||||
response::VideoListItem::GridVideoRenderer { video } => {
|
response::VideoListItem::PlaylistVideoRenderer(video) => {
|
||||||
match ChannelId::try_from(video.channel) {
|
match ChannelId::try_from(video.channel) {
|
||||||
Ok(channel) => Some(PlaylistVideo {
|
Ok(channel) => Some(PlaylistVideo {
|
||||||
id: video.video_id,
|
id: video.video_id,
|
||||||
|
|
@ -231,7 +229,7 @@ fn map_playlist_items(
|
||||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
response::VideoListItem::None => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
(videos, ctoken)
|
(videos, ctoken)
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@ use serde::Deserialize;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
use serde_with::VecSkipError;
|
use serde_with::VecSkipError;
|
||||||
|
|
||||||
use super::TimeOverlay;
|
use super::ChannelBadge;
|
||||||
use super::{ContentRenderer, ContentsRenderer, Thumbnails, VideoListItem};
|
use super::Thumbnails;
|
||||||
use crate::serializer::text::Text;
|
use super::{ContentRenderer, ContentsRenderer, VideoListItem};
|
||||||
|
use crate::serializer::{text::Text, MapResult, VecLogError};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
|
pub header: Header,
|
||||||
pub contents: Contents,
|
pub contents: Contents,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,6 +20,8 @@ pub struct Contents {
|
||||||
pub two_column_browse_results_renderer: TabsRenderer,
|
pub two_column_browse_results_renderer: TabsRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// YouTube channel tab view. Contains multiple tabs
|
||||||
|
/// (Home, Videos, Playlists, About...). We can ignore unknown tabs.
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -35,7 +39,14 @@ pub struct TabRendererWrap {
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SectionListRendererWrap {
|
pub struct SectionListRendererWrap {
|
||||||
pub section_list_renderer: ContentsRenderer<ItemSectionRendererWrap>,
|
pub section_list_renderer: SectionListRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SectionListRenderer {
|
||||||
|
pub contents: Vec<ItemSectionRendererWrap>,
|
||||||
|
pub target_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
@ -54,22 +65,31 @@ pub struct GridRendererWrap {
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct GridRenderer {
|
pub struct GridRenderer {
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
pub items: Vec<VideoListItem<ChannelVideo>>,
|
pub items: MapResult<Vec<VideoListItem>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Header {
|
||||||
|
pub c4_tabbed_header_renderer: HeaderRenderer,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
#[serde_as]
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ChannelVideo {
|
pub struct HeaderRenderer {
|
||||||
pub video_id: String,
|
pub channel_id: String,
|
||||||
pub thumbnail: Thumbnails,
|
/// Channel name
|
||||||
#[serde_as(as = "Text")]
|
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde_as(as = "Option<Text>")]
|
/// Approximate subscriber count (e.g. `880K subscribers`), depends on language
|
||||||
pub published_time_text: Option<String>,
|
|
||||||
#[serde_as(as = "Text")]
|
#[serde_as(as = "Text")]
|
||||||
pub view_count_text: String,
|
pub subscriber_count_text: String,
|
||||||
|
pub avatar: Thumbnails,
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
pub thumbnail_overlays: Vec<TimeOverlay>,
|
pub badges: Vec<ChannelBadge>,
|
||||||
|
pub banner: Thumbnails,
|
||||||
|
pub mobile_banner: Thumbnails,
|
||||||
|
/// Fullscreen (16:9) channel banner
|
||||||
|
pub tv_banner: Thumbnails,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ pub use video_details::VideoDetails;
|
||||||
pub use video_details::VideoRecommendations;
|
pub use video_details::VideoRecommendations;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::{serde_as, DefaultOnError, VecSkipError};
|
use serde_with::{json::JsonString, serde_as, DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use crate::serializer::{
|
use crate::serializer::{
|
||||||
ignore_any,
|
ignore_any,
|
||||||
|
|
@ -40,6 +40,8 @@ pub struct ThumbnailsWrap {
|
||||||
pub thumbnail: Thumbnails,
|
pub thumbnail: Thumbnails,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List of images in different resolutions.
|
||||||
|
/// Not only used for thumbnails, but also for avatars and banners.
|
||||||
#[derive(Default, Clone, Debug, Deserialize)]
|
#[derive(Default, Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct Thumbnails {
|
pub struct Thumbnails {
|
||||||
|
|
@ -54,27 +56,107 @@ pub struct Thumbnail {
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum VideoListItem<T> {
|
pub enum VideoListItem {
|
||||||
#[serde(alias = "playlistVideoRenderer", alias = "compactVideoRenderer")]
|
GridVideoRenderer(GridVideoRenderer),
|
||||||
GridVideoRenderer {
|
CompactVideoRenderer(CompactVideoRenderer),
|
||||||
#[serde(flatten)]
|
PlaylistVideoRenderer(PlaylistVideoRenderer),
|
||||||
video: T,
|
|
||||||
},
|
GridPlaylistRenderer(GridPlaylistRenderer),
|
||||||
|
|
||||||
|
/// Continauation items are located at the end of a list
|
||||||
|
/// and contain the continuation token for progressive loading
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
ContinuationItemRenderer {
|
ContinuationItemRenderer {
|
||||||
continuation_endpoint: ContinuationEndpoint,
|
continuation_endpoint: ContinuationEndpoint,
|
||||||
},
|
},
|
||||||
/// No video list item (e.g. ad)
|
/// No video list item (e.g. ad) or unimplemented item
|
||||||
///
|
///
|
||||||
/// Note that there are sometimes playlists among the recommended
|
/// Unimplemented:
|
||||||
/// videos. They are currently ignored.
|
/// - compactPlaylistRenderer (recommended playlists)
|
||||||
|
/// - compactRadioRenderer (recommended mix)
|
||||||
#[serde(other, deserialize_with = "ignore_any")]
|
#[serde(other, deserialize_with = "ignore_any")]
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Video displayed on a channel page
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GridVideoRenderer {
|
||||||
|
pub video_id: String,
|
||||||
|
pub thumbnail: Thumbnails,
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub published_time_text: Option<String>,
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub view_count_text: Option<String>,
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub thumbnail_overlays: Vec<TimeOverlay>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video displayed in recommendations
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CompactVideoRenderer {
|
||||||
|
pub video_id: String,
|
||||||
|
pub thumbnail: Thumbnails,
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub title: String,
|
||||||
|
#[serde(rename = "shortBylineText")]
|
||||||
|
pub channel: TextComponent,
|
||||||
|
pub channel_thumbnail: Thumbnails,
|
||||||
|
/// Channel verification badge
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub owner_badges: Vec<ChannelBadge>,
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub length_text: Option<String>,
|
||||||
|
/// (e.g. `11 months ago`)
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub published_time_text: Option<String>,
|
||||||
|
#[serde_as(as = "Option<Text>")]
|
||||||
|
pub view_count_text: Option<String>,
|
||||||
|
/// Badges are displayed on the video thumbnail and
|
||||||
|
/// show certain video properties (e.g. active livestream)
|
||||||
|
#[serde(default)]
|
||||||
|
#[serde_as(as = "VecSkipError<_>")]
|
||||||
|
pub badges: Vec<VideoBadge>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Video displayed in a playlist
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Playlist displayed on a channel page
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct GridPlaylistRenderer {
|
||||||
|
pub playlist_id: String,
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub title: String,
|
||||||
|
pub thumbnail: Thumbnails,
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub published_time_text: String,
|
||||||
|
#[serde_as(as = "Text")]
|
||||||
|
pub video_count_short_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct ContinuationItemRenderer {
|
pub struct ContinuationItemRenderer {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_with::serde_as;
|
use serde_with::serde_as;
|
||||||
use serde_with::{json::JsonString, DefaultOnError, VecSkipError};
|
use serde_with::{DefaultOnError, VecSkipError};
|
||||||
|
|
||||||
use crate::serializer::text::{Text, TextComponent};
|
use crate::serializer::text::{Text, TextComponent};
|
||||||
use crate::serializer::{MapResult, VecLogError};
|
use crate::serializer::{MapResult, VecLogError};
|
||||||
|
|
||||||
use super::{ContentRenderer, ContentsRenderer, Thumbnails, ThumbnailsWrap, VideoListItem};
|
use super::{ContentRenderer, ContentsRenderer, ThumbnailsWrap, VideoListItem};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
@ -58,21 +58,7 @@ pub struct PlaylistVideoListRenderer {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct PlaylistVideoList {
|
pub struct PlaylistVideoList {
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
pub contents: MapResult<Vec<VideoListItem<PlaylistVideo>>>,
|
pub contents: MapResult<Vec<VideoListItem>>,
|
||||||
}
|
|
||||||
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct PlaylistVideo {
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|
@ -172,5 +158,5 @@ pub struct OnResponseReceivedAction {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AppendAction {
|
pub struct AppendAction {
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
pub continuation_items: MapResult<Vec<VideoListItem<PlaylistVideo>>>,
|
pub continuation_items: MapResult<Vec<VideoListItem>>,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,12 @@ use crate::serializer::text::TextComponents;
|
||||||
use crate::serializer::MapResult;
|
use crate::serializer::MapResult;
|
||||||
use crate::serializer::{
|
use crate::serializer::{
|
||||||
ignore_any,
|
ignore_any,
|
||||||
text::{AccessibilityText, Text, TextComponent},
|
text::{AccessibilityText, Text},
|
||||||
VecLogError,
|
VecLogError,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
ChannelBadge, ContinuationEndpoint, ContinuationItemRenderer, Icon, Thumbnails, VideoBadge,
|
ContinuationEndpoint, ContinuationItemRenderer, Icon, Thumbnails, VideoListItem, VideoOwner,
|
||||||
VideoListItem, VideoOwner,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
@ -283,37 +282,7 @@ pub struct RecommendationResultsWrap {
|
||||||
pub struct RecommendationResults {
|
pub struct RecommendationResults {
|
||||||
/// Can be `None` for age-restricted videos
|
/// Can be `None` for age-restricted videos
|
||||||
#[serde_as(as = "Option<VecLogError<_>>")]
|
#[serde_as(as = "Option<VecLogError<_>>")]
|
||||||
pub results: Option<MapResult<Vec<VideoListItem<RecommendedVideo>>>>,
|
pub results: Option<MapResult<Vec<VideoListItem>>>,
|
||||||
}
|
|
||||||
|
|
||||||
/// Video recommendation item
|
|
||||||
#[serde_as]
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
pub struct RecommendedVideo {
|
|
||||||
pub video_id: String,
|
|
||||||
pub thumbnail: Thumbnails,
|
|
||||||
#[serde_as(as = "Text")]
|
|
||||||
pub title: String,
|
|
||||||
#[serde(rename = "shortBylineText")]
|
|
||||||
pub channel: TextComponent,
|
|
||||||
pub channel_thumbnail: Thumbnails,
|
|
||||||
/// Channel verification badge
|
|
||||||
#[serde(default)]
|
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
|
||||||
pub owner_badges: Vec<ChannelBadge>,
|
|
||||||
#[serde_as(as = "Option<Text>")]
|
|
||||||
pub length_text: Option<String>,
|
|
||||||
/// (e.g. `11 months ago`)
|
|
||||||
#[serde_as(as = "Option<Text>")]
|
|
||||||
pub published_time_text: Option<String>,
|
|
||||||
#[serde_as(as = "Option<Text>")]
|
|
||||||
pub view_count_text: Option<String>,
|
|
||||||
/// Badges are displayed on the video thumbnail and
|
|
||||||
/// show certain video properties (e.g. active livestream)
|
|
||||||
#[serde(default)]
|
|
||||||
#[serde_as(as = "VecSkipError<_>")]
|
|
||||||
pub badges: Vec<VideoBadge>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The engagement panels are displayed below the video and contain chapter markers
|
/// The engagement panels are displayed below the video and contain chapter markers
|
||||||
|
|
@ -468,7 +437,7 @@ pub struct RecommendationsContItem {
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct AppendRecommendations {
|
pub struct AppendRecommendations {
|
||||||
#[serde_as(as = "VecLogError<_>")]
|
#[serde_as(as = "VecLogError<_>")]
|
||||||
pub continuation_items: MapResult<Vec<VideoListItem<RecommendedVideo>>>,
|
pub continuation_items: MapResult<Vec<VideoListItem>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ use super::{
|
||||||
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
ClientType, MapResponse, RustyPipeQuery, YTContext,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct QVideo {
|
struct QVideo {
|
||||||
context: YTContext,
|
context: YTContext,
|
||||||
/// YouTube video ID
|
/// YouTube video ID
|
||||||
|
|
@ -29,7 +29,7 @@ struct QVideo {
|
||||||
racy_check_ok: bool,
|
racy_check_ok: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct QVideoCont {
|
struct QVideoCont {
|
||||||
context: YTContext,
|
context: YTContext,
|
||||||
continuation: String,
|
continuation: String,
|
||||||
|
|
@ -407,7 +407,7 @@ impl MapResponse<Paginator<Comment>> for response::VideoComments {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn map_recommendations(
|
fn map_recommendations(
|
||||||
r: MapResult<Vec<response::VideoListItem<response::video_details::RecommendedVideo>>>,
|
r: MapResult<Vec<response::VideoListItem>>,
|
||||||
lang: Language,
|
lang: Language,
|
||||||
) -> MapResult<Paginator<RecommendedVideo>> {
|
) -> MapResult<Paginator<RecommendedVideo>> {
|
||||||
let mut warnings = r.warnings;
|
let mut warnings = r.warnings;
|
||||||
|
|
@ -416,7 +416,7 @@ fn map_recommendations(
|
||||||
let items =
|
let items =
|
||||||
r.c.into_iter()
|
r.c.into_iter()
|
||||||
.filter_map(|item| match item {
|
.filter_map(|item| match item {
|
||||||
response::VideoListItem::GridVideoRenderer { video } => {
|
response::VideoListItem::CompactVideoRenderer(video) => {
|
||||||
match ChannelId::try_from(video.channel) {
|
match ChannelId::try_from(video.channel) {
|
||||||
Ok(channel) => Some(RecommendedVideo {
|
Ok(channel) => Some(RecommendedVideo {
|
||||||
id: video.video_id,
|
id: video.video_id,
|
||||||
|
|
@ -454,7 +454,7 @@ fn map_recommendations(
|
||||||
ctoken = Some(continuation_endpoint.continuation_command.token);
|
ctoken = Some(continuation_endpoint.continuation_command.token);
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
response::VideoListItem::None => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ pub trait FileFormat {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct VideoPlayer {
|
pub struct VideoPlayer {
|
||||||
pub details: VideoPlayerDetails,
|
pub details: VideoPlayerDetails,
|
||||||
pub video_streams: Vec<VideoStream>,
|
pub video_streams: Vec<VideoStream>,
|
||||||
|
|
@ -33,6 +34,7 @@ pub struct VideoPlayer {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct VideoPlayerDetails {
|
pub struct VideoPlayerDetails {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -49,6 +51,7 @@ pub struct VideoPlayerDetails {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct VideoStream {
|
pub struct VideoStream {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub itag: u32,
|
pub itag: u32,
|
||||||
|
|
@ -69,6 +72,7 @@ pub struct VideoStream {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct AudioStream {
|
pub struct AudioStream {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub itag: u32,
|
pub itag: u32,
|
||||||
|
|
@ -128,6 +132,7 @@ pub enum VideoFormat {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct AudioTrack {
|
pub struct AudioTrack {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub lang: Option<String>,
|
pub lang: Option<String>,
|
||||||
|
|
@ -163,6 +168,7 @@ impl FileFormat for AudioFormat {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Thumbnail {
|
pub struct Thumbnail {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
|
|
@ -170,6 +176,7 @@ pub struct Thumbnail {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Subtitle {
|
pub struct Subtitle {
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub lang: String,
|
pub lang: String,
|
||||||
|
|
@ -182,6 +189,7 @@ pub struct Subtitle {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Playlist {
|
pub struct Playlist {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -195,6 +203,7 @@ pub struct Playlist {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct PlaylistVideo {
|
pub struct PlaylistVideo {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -204,6 +213,7 @@ pub struct PlaylistVideo {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct ChannelId {
|
pub struct ChannelId {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
|
@ -214,6 +224,7 @@ pub struct ChannelId {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct VideoDetails {
|
pub struct VideoDetails {
|
||||||
/// Unique YouTube video ID
|
/// Unique YouTube video ID
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -260,6 +271,7 @@ pub struct VideoDetails {
|
||||||
/// Videos can consist of different chapters, which YouTube shows
|
/// Videos can consist of different chapters, which YouTube shows
|
||||||
/// on the seek bar and below the description text.
|
/// on the seek bar and below the description text.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Chapter {
|
pub struct Chapter {
|
||||||
/// Chapter title
|
/// Chapter title
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
|
@ -274,6 +286,7 @@ pub struct Chapter {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct RecommendedVideo {
|
pub struct RecommendedVideo {
|
||||||
/// Unique YouTube video ID
|
/// Unique YouTube video ID
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -304,6 +317,7 @@ pub struct RecommendedVideo {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Channel {
|
pub struct Channel {
|
||||||
/// Unique YouTube channel ID
|
/// Unique YouTube channel ID
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -327,6 +341,7 @@ pub struct Channel {
|
||||||
|
|
||||||
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
#[derive(Default, Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
#[non_exhaustive]
|
||||||
pub enum Verification {
|
pub enum Verification {
|
||||||
#[default]
|
#[default]
|
||||||
/// Unverified channel (default)
|
/// Unverified channel (default)
|
||||||
|
|
@ -343,8 +358,8 @@ impl Verification {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: impl popularity comparison
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Comment {
|
pub struct Comment {
|
||||||
/// Unique YouTube Comment-ID (e.g. `UgynScMrsqGSL8qvePl4AaABAg`)
|
/// Unique YouTube Comment-ID (e.g. `UgynScMrsqGSL8qvePl4AaABAg`)
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|
@ -373,3 +388,18 @@ pub struct Comment {
|
||||||
/// Has the channel owner marked the comment with a ❤️ heart ?
|
/// Has the channel owner marked the comment with a ❤️ heart ?
|
||||||
pub hearted: bool,
|
pub hearted: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
#CHANNEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
pub struct ChannelVideos {
|
||||||
|
/// Unique YouTube Channel-ID (e.g. `UC-lHJZR3Gqxm24_Vd_AJ5Yw`)
|
||||||
|
pub id: String,
|
||||||
|
/// Channel name
|
||||||
|
pub name: String,
|
||||||
|
/// Textual subscriber count (e.g. `2.3M subscribers`), depends on language setting
|
||||||
|
pub subscriber_count_txt: String,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||||
/// in pages from the YouTube API (e.g. playlist items,
|
/// in pages from the YouTube API (e.g. playlist items,
|
||||||
/// video recommendations or comments).
|
/// video recommendations or comments).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct Paginator<T> {
|
pub struct Paginator<T> {
|
||||||
/// Total number of items if finite and known.
|
/// Total number of items if finite and known.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub struct RichText(pub Vec<TextComponent>);
|
pub struct RichText(pub Vec<TextComponent>);
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[non_exhaustive]
|
||||||
pub enum TextComponent {
|
pub enum TextComponent {
|
||||||
/// Plain text
|
/// Plain text
|
||||||
Text(String),
|
Text(String),
|
||||||
|
|
|
||||||
12030
testfiles/channel/channel_videos_base.json
Normal file
12030
testfiles/channel/channel_videos_base.json
Normal file
File diff suppressed because it is too large
Load diff
8570
testfiles/channel/channel_videos_live.json
Normal file
8570
testfiles/channel/channel_videos_live.json
Normal file
File diff suppressed because it is too large
Load diff
4802
testfiles/channel/channel_videos_music.json
Normal file
4802
testfiles/channel/channel_videos_music.json
Normal file
File diff suppressed because it is too large
Load diff
13888
testfiles/channel/channel_videos_shorts.json
Normal file
13888
testfiles/channel/channel_videos_shorts.json
Normal file
File diff suppressed because it is too large
Load diff
Reference in a new issue