1
0
Fork 0

First commit

This commit is contained in:
Honbra 2023-10-18 23:57:01 +02:00
commit 74ea5fc59a
Signed by: honbra
GPG key ID: B61CC9ADABE2D952
14 changed files with 2462 additions and 0 deletions

21
src/app/mod.rs Normal file
View file

@ -0,0 +1,21 @@
mod yt_embed;
use axum::{body::Body, Router};
use http::Request;
use tower_http::trace::{DefaultOnRequest, DefaultOnResponse, TraceLayer};
use tracing::{field, span, Level};
pub fn build_app() -> Router {
Router::new().nest("/yt-embed", yt_embed::router()).layer(
TraceLayer::new_for_http()
.make_span_with(|request: &Request<Body>| {
span!(
Level::INFO,
"http-request",
uri = field::display(request.uri()),
)
})
.on_request(DefaultOnRequest::new().level(Level::DEBUG))
.on_response(DefaultOnResponse::new().level(Level::INFO)),
)
}

139
src/app/yt_embed/mod.rs Normal file
View file

@ -0,0 +1,139 @@
use std::{
sync::Arc,
time::{Duration, Instant},
};
use axum::{
extract::{Path, State},
response::{IntoResponse, Redirect, Response},
routing::get,
Router,
};
use eyre::eyre;
use maud::{html, DOCTYPE};
use moka::{future::Cache, Expiry};
use tracing::{debug, error, field, instrument};
use crate::{
reqwest::REQWEST_CLIENT,
youtube::{get_video_info, VideoInfo},
};
type VideoCache = Cache<String, Arc<CachedVideo>>;
#[derive(Clone, Debug)]
struct CachedVideo {
url: String,
expires_in: Duration,
title: String,
width: u32,
height: u32,
mime_type: String,
}
impl Expiry<String, CachedVideo> for CachedVideo {
fn expire_after_create(
&self,
_id: &String,
video: &CachedVideo,
_created_at: Instant,
) -> Option<Duration> {
Some(video.expires_in)
}
}
pub fn router() -> Router {
Router::new()
.route("/:video_id", get(embed_video))
.with_state(VideoCache::new(100))
}
async fn embed_video(Path(video_id): Path<String>, State(cache): State<VideoCache>) -> Response {
match get_cached_video(&video_id, cache).await {
Ok(video) => html! {
(DOCTYPE)
html {
head {
meta charset="utf-8";
meta name="robots" content="noindex";
title { (video.title) }
meta property="og:title" content=(video.title);
meta property="og:site_name" content="YouTube";
meta property="theme-color" content="#ff0000";
meta property="og:type" content="video.other";
meta property="og:url" content=(format!("https://www.youtube.com/watch?v={video_id}"));
meta property="og:video" content=(video.url);
meta property="og:video:type" content=(video.mime_type);
meta property="og:video:width" content=(video.width);
meta property="og:video:height" content=(video.height);
}
body {
video preload="none" style="display: none;" {
source src="https://valve-software.com/videos/gabeNewell.mp4" type="video/mp4";
}
h1 { (video.title) }
video controls width=(video.width) height=(video.height) {
source src=(video.url) type=(video.mime_type);
}
p {
a href=(format!("https://www.youtube.com/watch?v={video_id}")) {
"Watch on YouTube"
}
}
}
}
}
.into_response(),
Err(err) => {
error!(error = field::display(err));
Redirect::temporary(&format!("https://www.youtube.com/watch?v={video_id}")).into_response()
},
}
}
#[instrument(skip(cache))]
async fn get_cached_video(video_id: &str, cache: VideoCache) -> eyre::Result<Arc<CachedVideo>> {
if let Some(video) = cache.get(video_id).await {
if REQWEST_CLIENT
.head(&video.url)
.send()
.await?
.status()
.is_success()
{
debug!("cache hit");
return Ok(video);
}
debug!("cache invalidated");
cache.invalidate(video_id).await;
} else {
debug!("cache miss");
}
let VideoInfo {
video_details,
streaming_data,
..
} = get_video_info(video_id).await?;
let mut formats = streaming_data.formats;
formats.sort_unstable_by(|a, b| b.bitrate.cmp(&a.bitrate));
let format = formats
.into_iter()
.next()
.ok_or(eyre!("no formats found"))?;
let video = Arc::new(CachedVideo {
url: format.url,
expires_in: streaming_data.expires_in,
title: video_details.title,
width: format.width,
height: format.height,
mime_type: format.mime_type,
});
cache.insert(video_id.to_string(), video.clone()).await;
Ok(video)
}

24
src/error.rs Normal file
View file

@ -0,0 +1,24 @@
use axum::response::{IntoResponse, Response};
use http::StatusCode;
pub struct AppError(eyre::Error);
impl IntoResponse for AppError {
fn into_response(self) -> Response {
eprintln!("{}", self.0);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("An error has occured:\n{:?}", self.0),
)
.into_response()
}
}
impl<E> From<E> for AppError
where
E: Into<eyre::Error>,
{
fn from(err: E) -> Self {
Self(err.into())
}
}

42
src/main.rs Normal file
View file

@ -0,0 +1,42 @@
mod app;
mod error;
mod reqwest;
mod youtube;
use std::net::SocketAddr;
use eyre::Context;
use tracing::{debug, field, Level};
use tracing_subscriber::{filter, layer::SubscriberExt, util::SubscriberInitExt};
use self::app::build_app;
#[tokio::main]
async fn main() -> eyre::Result<()> {
let filter = filter::Targets::new()
.with_target("honbra_api", Level::TRACE)
.with_target("tower_http::trace::on_response", Level::TRACE)
.with_target("tower_http::trace::on_request", Level::TRACE)
.with_default(Level::INFO);
let tracing_layer = tracing_subscriber::fmt::layer();
tracing_subscriber::registry()
.with(tracing_layer)
.with(filter)
.try_init()
.map_err(eyre::Error::msg)
.context("failed to initialize tracing subscriber")?;
let addr: SocketAddr = ([0, 0, 0, 0], 3000).into();
debug!(addr = field::display(addr), "binding");
axum::Server::try_bind(&addr)
.context("unable to bind to server address")?
.serve(build_app().into_make_service())
.await
.context("server encountered a runtime error")?;
Ok(())
}

5
src/reqwest.rs Normal file
View file

@ -0,0 +1,5 @@
use lazy_static::lazy_static;
lazy_static! {
pub static ref REQWEST_CLIENT: reqwest::Client = reqwest::Client::new();
}

80
src/youtube.rs Normal file
View file

@ -0,0 +1,80 @@
use std::time::Duration;
use serde::Deserialize;
use serde_json::json;
use serde_with::{serde_as, DurationSeconds};
use crate::reqwest::REQWEST_CLIENT;
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoInfo {
pub playability_status: PlayabilityStatus,
pub streaming_data: StreamingData,
pub video_details: VideoDetails,
}
#[derive(Clone, Debug, Deserialize)]
pub struct PlayabilityStatus {
pub status: PlayabilityStatusStatus,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum PlayabilityStatusStatus {
#[serde(rename = "OK")]
Ok,
Unknown(String),
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamingData {
#[serde(rename = "expiresInSeconds")]
#[serde_as(as = "DurationSeconds<String>")]
pub expires_in: Duration,
pub formats: Vec<StreamingFormat>,
}
#[serde_as]
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct StreamingFormat {
#[serde(rename = "itag")]
pub id: u32,
pub url: String,
pub bitrate: u32,
pub width: u32,
pub height: u32,
pub mime_type: String,
}
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoDetails {
pub title: String,
}
pub async fn get_video_info(video_id: &str) -> reqwest::Result<VideoInfo> {
REQWEST_CLIENT
.post("https://www.youtube.com/youtubei/v1/player?key=AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8&prettyPrint=false")
.header("User-Agent", "com.google.android.youtube/17.31.35 (Linux; U; Android 11) gzip")
.json(&json!({
"videoId": video_id,
"context": {
"client": {
"hl": "en",
"gl": "US",
"clientName": "ANDROID",
"clientVersion": "17.31.35",
"androidSdkVersion": 30,
"userAgent": "com.google.android.youtube/17.31.35 (Linux; U; Android 11) gzip"
}
},
}))
.send()
.await?
.json()
.await
}