Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,8 @@ PLAYLISTS
- Playlists whose id contains "VL" open correctly, playlist headers show their owner again, and changing the 4K, HD or subtitles search filter now runs the search again.
- Long YouTube playlists load past their first 100 videos as you scroll, show their real title and video count, and download or save in full. Sorting a playlist by date or popularity reads the whole playlist first.
- Searching inside a channel keeps returning results past the first page, and long comment threads keep their Show more replies button.
- Playlist tracks that came from sync or an import without a length, view count or upload date get them filled in when the playlist is open, a few a second, and keep them. Sorting by date uses each video's exact upload time, and a drag made meanwhile is kept.
- Videos that can't be filled in, such as removed ones, are skipped for a week instead of being looked up on every visit.

DOWNLOADS
- Videos now save as MP4 with AAC audio in every quality, VP9 and AV1 included, so they open in more players. A video with no AAC track still saves as MKV.
Expand Down
74 changes: 0 additions & 74 deletions src-tauri/src/api/innertube/extractors/category.rs

This file was deleted.

2 changes: 1 addition & 1 deletion src-tauri/src/api/innertube/extractors/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
pub mod audio_preference;
pub mod category;
pub mod channel;
pub mod comments;
pub mod explore;
Expand All @@ -12,4 +11,5 @@ pub mod search;
pub mod shorts;
pub mod storyboard;
pub mod subscription;
pub mod video_metadata;
pub mod watch_next;
173 changes: 173 additions & 0 deletions src-tauri/src/api/innertube/extractors/video_metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
//! What a listed video is: its length, views, upload date and category. The
//! clients that serve streams return no microformat, so this costs one small
//! `MWEB` player request, asked without a signature timestamp: the answer is
//! unplayable and carries no streams, 5 to 10 KB (Flow for Android's
//! `videoCategory`). It stays out of the playback caches, so filling in a list
//! never pushes out what is playing.

use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};

use serde_json::Value;

use crate::api::innertube::InnertubeClient;
use crate::api::innertube::core::clients;
use crate::api::innertube::core::player_requests::PlayerFlights;
use crate::api::innertube::transport::request::Host;
use crate::errors::{AppError, AppResult};
use crate::models::video::VideoMetadata;

// Every answer this session is kept; a failure is not, so a later ask tries again.
const CAPACITY: usize = 1024;

fn cache() -> &'static Mutex<HashMap<String, VideoMetadata>> {
static CACHE: OnceLock<Mutex<HashMap<String, VideoMetadata>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(HashMap::new()))
}

/// The upload time as epoch milliseconds, from a full timestamp or a bare date.
fn published_at_ms(text: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(text)
.map(|date| date.timestamp_millis())
.ok()
.or_else(|| {
chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d")
.ok()?
.and_hms_opt(0, 0, 0)
.map(|date| date.and_utc().timestamp_millis())
})
}

/// The metadata in an `MWEB` player answer, or none when it describes no video
/// (a removed or private one answers `ERROR` without `videoDetails`).
#[must_use]
pub fn parse_video_metadata(response: &Value) -> Option<VideoMetadata> {
let details = response.get("videoDetails")?;
let microformat = &response["microformat"]["playerMicroformatRenderer"];
let text = |value: &Value| {
value
.as_str()
.filter(|text| !text.is_empty())
.map(ToOwned::to_owned)
};
let is_live = microformat["liveBroadcastDetails"]["isLiveNow"]
.as_bool()
.unwrap_or(false);
Some(VideoMetadata {
title: text(&details["title"]).unwrap_or_default(),
channel_name: text(&details["author"]).unwrap_or_default(),
channel_id: text(&details["channelId"]),
// A stream that is live reports how long it has run, not a length.
duration_seconds: details["lengthSeconds"]
.as_str()
.and_then(|seconds| seconds.parse().ok())
.filter(|seconds| *seconds > 0 && !is_live),
view_count: details["viewCount"]
.as_str()
.and_then(|views| views.parse().ok()),
published_at_ms: microformat["publishDate"]
.as_str()
.or_else(|| microformat["uploadDate"].as_str())
.and_then(published_at_ms),
is_live,
category: text(&microformat["category"]),
})
}

impl InnertubeClient {
pub async fn get_video_metadata(&self, video_id: &str) -> AppResult<VideoMetadata> {
static FLIGHTS: OnceLock<PlayerFlights<Option<VideoMetadata>>> = OnceLock::new();
if let Some(metadata) = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(video_id)
{
return Ok(metadata.clone());
}
let flight = FLIGHTS
.get_or_init(PlayerFlights::default)
.for_video(video_id);
let metadata = flight
.get_or_init(|| async {
let mut payload = serde_json::json!({
"videoId": video_id,
"contentCheckOk": true,
"racyCheckOk": true,
});
let response = self
.post_via(
&self.client,
Host::Www,
"player",
&clients::MWEB,
&mut payload,
None,
None,
)
.await
.ok()?;
let metadata = parse_video_metadata(&response)?;
let mut cache = cache()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= CAPACITY {
cache.clear();
}
cache.insert(video_id.to_string(), metadata.clone());
Some(metadata)
})
.await
.clone();
metadata.ok_or_else(|| {
AppError::ContentNotAvailable(format!("No details for video {video_id}"))
})
}

/// The video's category, which the recommendation engine clusters by.
pub async fn get_video_category(&self, video_id: &str) -> AppResult<Option<String>> {
Ok(self
.get_video_metadata(video_id)
.await
.ok()
.and_then(|metadata| metadata.category))
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn an_mweb_answer_gives_length_views_date_and_category() {
let response = json!({
"playabilityStatus": { "status": "UNPLAYABLE" },
"videoDetails": { "title": "Never Gonna Give You Up", "author": "Rick Astley",
"channelId": "UCuAXFkgsw1L7xaCfnd5JJOw", "lengthSeconds": "213", "viewCount": "1830673075" },
"microformat": { "playerMicroformatRenderer": {
"publishDate": "2009-10-24T23:57:33-07:00", "category": "Music" } }
});
let metadata = parse_video_metadata(&response).expect("metadata");
assert_eq!(metadata.duration_seconds, Some(213));
assert_eq!(metadata.view_count, Some(1_830_673_075));
assert_eq!(metadata.published_at_ms, Some(1_256_453_853_000));
assert_eq!(metadata.category.as_deref(), Some("Music"));
assert!(!metadata.is_live);
}

#[test]
fn a_live_stream_has_no_length_and_a_removed_video_no_metadata() {
let live = json!({
"videoDetails": { "lengthSeconds": "121601512", "viewCount": "651847115" },
"microformat": { "playerMicroformatRenderer": { "publishDate": "2022-07-12",
"liveBroadcastDetails": { "isLiveNow": true } } }
});
let metadata = parse_video_metadata(&live).expect("metadata");
assert!(metadata.is_live);
assert_eq!(metadata.duration_seconds, None);
assert_eq!(metadata.published_at_ms, Some(1_657_584_000_000));
assert!(
parse_video_metadata(&json!({ "playabilityStatus": { "status": "ERROR" } })).is_none()
);
}
}
16 changes: 15 additions & 1 deletion src-tauri/src/commands/youtube.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::models::comment::CommentsResponse;
use crate::models::explore::{ExploreGrid, ExplorePage, ExploreTarget};
use crate::models::playlist::PlaylistDetailsResponse;
use crate::models::search::{SearchVideosRequest, SearchVideosResponse};
use crate::models::video::{RelatedPage, StreamInfo, VideoDetails, VideoSummary};
use crate::models::video::{RelatedPage, StreamInfo, VideoDetails, VideoMetadata, VideoSummary};
use crate::security::validation::{
validate_browse_id, validate_channel_id, validate_page_token, validate_search_query,
validate_video_id,
Expand Down Expand Up @@ -367,6 +367,20 @@ pub async fn get_related_videos(
Ok(related)
}

/// A listed video's length, views, upload date and category, from one small
/// request that stays out of the playback caches.
#[tauri::command]
pub async fn get_video_metadata(
video_id: String,
youtube_service: State<'_, YoutubeService>,
) -> Result<VideoMetadata, ErrorResponse> {
validate_video_id(&video_id).map_err(ErrorResponse::from)?;
youtube_service
.get_video_metadata(&video_id)
.await
.map_err(ErrorResponse::from)
}

/// The video's category, for the recommendation engine. A small request of
/// its own, so the watch page asks only once playback has settled.
#[tauri::command]
Expand Down
5 changes: 3 additions & 2 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ use commands::youtube::{
get_post_comments, get_related_videos, get_return_youtube_dislike, get_sabr_debug_state,
get_search_suggestions, get_sponsorblock_segments, get_stream_info,
get_subscription_rotation_feed, get_subscription_rss_feed, get_video_category,
get_video_details, parse_subscription_export, resolve_channel_id, search_videos,
stream_subscription_rss_feed, submit_sponsorblock_segment,
get_video_details, get_video_metadata, parse_subscription_export, resolve_channel_id,
search_videos, stream_subscription_rss_feed, submit_sponsorblock_segment,
};
use services::discord_presence::DiscordPresence;
use services::music_service::MusicService;
Expand Down Expand Up @@ -485,6 +485,7 @@ pub fn run() {
get_related_videos,
get_more_related_videos,
get_video_category,
get_video_metadata,
get_stream_info,
get_channel_details,
get_channel_page,
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/src/models/video.rs
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,22 @@ pub struct RelatedPage {
pub continuation: Option<String>,
}

/// What a listed video is, from one small player request: enough to fill in a
/// stored track without the full details lookup.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoMetadata {
pub title: String,
pub channel_name: String,
pub channel_id: Option<String>,
pub duration_seconds: Option<u64>,
pub view_count: Option<u64>,
/// The upload time, exact, in epoch milliseconds.
pub published_at_ms: Option<i64>,
pub is_live: bool,
pub category: Option<String>,
}

/// The two facts a feed needs to fill in for a listed video, without the full
/// details lookup and its `next` request.
#[derive(Debug, Clone, Default)]
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/services/youtube_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use crate::models::live_chat::LiveChatResponse;
use crate::models::playlist::PlaylistDetailsResponse;
use crate::models::search::{SearchVideosRequest, SearchVideosResponse};
use crate::models::shorts::{ShortItem, ShortsFeed};
use crate::models::video::{RelatedPage, StreamInfo, VideoBasics, VideoDetails};
use crate::models::video::{RelatedPage, StreamInfo, VideoBasics, VideoDetails, VideoMetadata};

#[derive(Clone)]
pub struct YoutubeService {
Expand Down Expand Up @@ -42,6 +42,10 @@ impl YoutubeService {
self.extractor.get_related_videos(video_id).await
}

pub async fn get_video_metadata(&self, video_id: &str) -> AppResult<VideoMetadata> {
self.extractor.get_video_metadata(video_id).await
}

pub async fn get_video_category(&self, video_id: &str) -> AppResult<Option<String>> {
self.extractor.get_video_category(video_id).await
}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/api/youtube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
SearchVideosRequest,
SearchVideosResponse,
VideoDetails,
VideoMetadata,
StreamInfo,
ChannelAbout,
ChannelDetails,
Expand Down Expand Up @@ -127,6 +128,12 @@ export async function getMoreRelatedVideos(continuation: string): Promise<Relate
return invokeBackend<RelatedPage>("get_more_related_videos", { continuation });
}

/** A listed video's length, views, upload date and category, from one small request. */
export async function getVideoMetadata(videoId: string): Promise<VideoMetadata> {
if (!(await isTauriEnv())) throw new Error("Video metadata requires the desktop app.");
return invokeBackend<VideoMetadata>("get_video_metadata", { videoId });
}

/** The video's category, from a small request of its own. */
export async function getVideoCategory(videoId: string): Promise<string | null> {
if (!(await isTauriEnv())) return null;
Expand Down
Loading
Loading