First commit
This commit is contained in:
commit
74ea5fc59a
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/target
|
||||||
|
/.direnv
|
1972
Cargo.lock
generated
Normal file
1972
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "honbra-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.6.20", default-features = false, features = ["http1", "json", "macros", "matched-path", "tokio", "tower-log", "tracing"] }
|
||||||
|
eyre = "0.6.8"
|
||||||
|
http = "0.2.9"
|
||||||
|
lazy_static = "1.4.0"
|
||||||
|
maud = { version = "0.25.0", features = ["axum"] }
|
||||||
|
moka = { version = "0.12.1", features = ["future"] }
|
||||||
|
reqwest = { version = "0.11.22", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
serde = { version = "1.0.189", features = ["derive"] }
|
||||||
|
serde_json = "1.0.107"
|
||||||
|
serde_with = "3.3.0"
|
||||||
|
tokio = { version = "1.33.0", features = ["rt-multi-thread", "macros"] }
|
||||||
|
tower-http = { version = "0.4.4", features = ["trace"] }
|
||||||
|
tracing = "0.1.37"
|
||||||
|
tracing-subscriber = "0.3.17"
|
116
flake.lock
Normal file
116
flake.lock
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1694529238,
|
||||||
|
"narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "ff7b65b44d01cf9ba6a71320833626af21126384",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"flake-utils_2": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems_2"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681202837,
|
||||||
|
"narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "cfacdce06f30d2b68473a46042957675eebb3401",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697456312,
|
||||||
|
"narHash": "sha256-roiSnrqb5r+ehnKCauPLugoU8S36KgmWraHgRqVYndo=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "ca012a02bf8327be9e488546faecae5e05d7d749",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-utils": "flake-utils_2",
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1697595136,
|
||||||
|
"narHash": "sha256-9honwiIeMbBKi7FzfEy89f1ShUiXz/gVxZSS048pKyc=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "a2ccfb2134622b28668a274e403ba6f075ae1223",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"systems_2": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1681028828,
|
||||||
|
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "nix-systems",
|
||||||
|
"repo": "default",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
35
flake.nix
Normal file
35
flake.nix
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
description = "Honbra API";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }:
|
||||||
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
|
let
|
||||||
|
overlays = [ (import rust-overlay) ];
|
||||||
|
pkgs = import nixpkgs {
|
||||||
|
inherit system overlays;
|
||||||
|
};
|
||||||
|
rust = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default.override {
|
||||||
|
extensions = [ "rust-src" ];
|
||||||
|
});
|
||||||
|
in
|
||||||
|
with pkgs;
|
||||||
|
{
|
||||||
|
devShells.default = mkShell rec {
|
||||||
|
buildInputs = [ rust ] ++ [
|
||||||
|
rust-analyzer
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
LD_LIBRARY_PATH = "${lib.makeLibraryPath buildInputs}";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
2
rust-toolchain.toml
Normal file
2
rust-toolchain.toml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
[toolchain]
|
||||||
|
channel = "nightly"
|
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
group_imports = "StdExternalCrate"
|
||||||
|
imports_granularity = "Crate"
|
||||||
|
wrap_comments = true
|
21
src/app/mod.rs
Normal file
21
src/app/mod.rs
Normal 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
139
src/app/yt_embed/mod.rs
Normal 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
24
src/error.rs
Normal 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
42
src/main.rs
Normal 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
5
src/reqwest.rs
Normal 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
80
src/youtube.rs
Normal 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
|
||||||
|
}
|
Loading…
Reference in a new issue