dfl-cli 1.2.3

A CLI tool for downloading videos from youtube, and twitch.
Documentation
use std::{error::Error, time::Instant, fs::File, io::prelude::*};
use colored::*;
use crate::utils::*;
use serde_json::Value;
use regex::Regex;
use reqwest;
use urlencoding::encode;
use invidious::{*, hidden::AdaptiveFormat};

pub struct TwitchClient;

impl TwitchClient {
    pub async fn fetch(&self, id: &str, type_: &str) -> Result<(String, String), Box<dyn Error>> {
        match type_ {
            "twitch-video" => self.fetch_video(id).await,
            "twitch-clip" => self.fetch_clip(id).await,
            _ => Err("Invalid type".into()),
        }
    }

    async fn fetch_clip(&self, id: &str) -> Result<(String, String), Box<dyn Error>> {
        let data = self.get_data(id).await?;
        let title = self.get_title(id, "clip").await?;
        let download_url = format!(
            "{}?sig={}&token={}",
            data["videoQualities"][0]["sourceURL"].as_str().unwrap_or_default(),
            data["playbackAccessToken"]["signature"].as_str().unwrap_or_default(),
            encode(data["playbackAccessToken"]["value"].as_str().unwrap_or_default())
        );

        Ok((download_url, title))
    }

    async fn fetch_video(&self, id: &str) -> Result<(String, String), Box<dyn Error>> {
        let client = reqwest::Client::new();
        let title = self.get_title(id, "video").await?;
        let playlist_url = self.get_playlist_url(id, &client).await?;

        Ok((playlist_url, title))
    }

    async fn send_gql_request(query: String) -> Result<Value, Box<dyn Error>> {
        let client = reqwest::Client::new();
        let response = client
            .post("https://gql.twitch.tv/gql")
            .header("Client-ID", "kimne78kx3ncx6brgo4mv6wki5h1ko")
            .body(query)
            .send()
            .await?;

        if !response.status().is_success() {
            return Err("Unsuccessful response (GQL API)".into());
        }

        let json_response: Value = serde_json::from_str(&response.text().await?)?;
        Ok(json_response)
    }

    async fn get_data(&self, id: &str) -> Result<Value, Box<dyn Error>> {
        let query = format!(r#"{{"operationName":"VideoAccessToken_Clip","variables":{{"slug":"{}"}},"extensions":{{"persistedQuery":{{"version":1,"sha256Hash":"36b89d2507fce29e5ca551df756d27c1cfe079e2609642b4390aa4c35796eb11"}}}}}}"#, id);
        let json_response = Self::send_gql_request(query).await?;
        let data = &json_response["data"]["clip"];
        if data.is_null() {
            return Err("Clip not found".into());
        }

        Ok(data.clone())
    }

    async fn get_title(&self, id: &str, type_: &str) -> Result<String, Box<dyn Error>> {
        let query = match type_ {
            "video" => format!(r#"{{"query":"query{{video(id:\"{}\"){{title}}}}","variables":{{}}}}"#, id),
            "clip" => format!(r#"{{"query":"query{{clip(slug:\"{}\"){{title}}}}","variables":{{}}}}"#, id),
            _ => return Err("Invalid type".into()),
        };

        let json_response = Self::send_gql_request(query).await?;
        let title = json_response["data"][type_]["title"].to_string().replace("\"", "");

        Ok(title)
    }

    async fn get_playlist_url(&self, id: &str, client: &reqwest::Client) -> Result<String, Box<dyn Error>> {
        let (token, signature) = self.get_token_and_sig(id).await?;
        let playlist_url = format!("http://usher.ttvnw.net/vod/{}?nauth={}&nauthsig={}&allow_source=true&player=twitchweb", id, token, signature);
        let playlist_response = client.get(&playlist_url).send().await?;
        if !playlist_response.status().is_success() {
            return Err("Unsuccessful response (Usher API)".into());
        }

        let playlist_body = playlist_response.text().await?;
        let playlist_url = self.get_highest_bandwidth_url(&playlist_body).await?;

        Ok(playlist_url)
    }

    async fn get_token_and_sig(&self, id: &str) -> Result<(String, String), Box<dyn Error>> {
        let query = format!(r#"{{"operationName":"PlaybackAccessToken_Template","query":"query PlaybackAccessToken_Template($vodID: ID!, $playerType: String!) {{  videoPlaybackAccessToken(id: $vodID, params: {{platform: \"web\", playerBackend: \"mediaplayer\", playerType: $playerType}}) @include(if: true) {{    value    signature    __typename  }}}}", "variables":{{"vodID":"{}","playerType":"embed"}}}}"#, id);
        let response = Self::send_gql_request(query).await?;
        let token = response["data"]["videoPlaybackAccessToken"]["value"].as_str().unwrap_or_default();
        let signature = response["data"]["videoPlaybackAccessToken"]["signature"].as_str().unwrap_or_default();

        Ok((token.into(), signature.into()))
    }

    async fn get_highest_bandwidth_url(&self, playlist_body: &str) -> Result<String, Box<dyn Error>> {
        let re = Regex::new(r"#EXT-X-STREAM-INF:BANDWIDTH=(\d+),.*\n(.*)\n")?;

        let mut highest_bandwidth_url = String::new();
        let mut highest_bandwidth = 0;

        for cap in re.captures_iter(playlist_body) {
            let bandwidth: i32 = cap[1].parse()?;
            if bandwidth > highest_bandwidth {
                highest_bandwidth = bandwidth;
                highest_bandwidth_url = cap[2].to_string();
            }
        }

        Ok(highest_bandwidth_url)
    }
}

pub struct YouTubeClient;

impl YouTubeClient {
    pub async fn fetch(&self, id: &str) -> Result<(String, String), Box<dyn Error>> {
        let client = ClientAsync::default();
        let video = client.video(&id, None).await?;
        let title = video.title;
        let url = Self::get_highest_bitrate_url(&video.adaptive_formats);

        Ok((url, title))
    }

    fn get_highest_bitrate_url(formats: &Vec<AdaptiveFormat>) -> String {
        let mut highest_bitrate = 0;
        let mut url = String::new();
        for format in formats {
            let bitrate = format.bitrate.parse::<u64>().unwrap();
            if bitrate > highest_bitrate {
                highest_bitrate = bitrate;
                url = format.url.clone();
            }
        }

        url
    }
}

pub struct TikTokClient;

impl TikTokClient {
    pub async fn fetch(&self, url: &str) -> Result<(String, String), Box<dyn Error>> {
        // Get Video Details
        let details = reqwest::get(&format!("https://www.tiktok.com/oembed?url={}", url)).await?;
        if !details.status().is_success() {
            return Err("Unsuccessful response (TikTok API)".into());
        }

        let json_response: Value = serde_json::from_str(&details.text().await?)?;
        let title = json_response["title"].to_string().replace("\"", "");

        // Get Video URL
        // scrape the main url and look for the <video> tag, then return the src attribute
        let embedded_video = reqwest::get(url).await?;
        if !embedded_video.status().is_success() {
            return Err("Unsuccessful response (Tiktok may be down)".into());
        }

        let embedded_video_body = embedded_video.text().await?;

        File::create("tiktok.html")?.write_all(embedded_video_body.as_bytes())?;
        
        Ok((String::new(), title))
    }
}

pub async fn fetch(type_: &str, id: &str) -> Result<(String, String), Box<dyn Error>> {
    let start = Instant::now();

    let result = match type_ {
        "twitch-video" | "twitch-clip" => TwitchClient.fetch(id, type_).await,
        "youtube-video" | "youtube-short" => YouTubeClient.fetch(id).await,
        "tiktok-video" => TikTokClient.fetch(id).await,
        _ => Err("Invalid type".into()),
    };

    println!("{} {}", "Fetched URL in:".blue(), get_elapsed_time(start));

    Ok(result?)
}