Compare commits

..

10 Commits

8 changed files with 499 additions and 725 deletions

1
.dockerignore Normal file
View File

@ -0,0 +1 @@
/target

954
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,17 @@
[package] [package]
name = "dzmedia" name = "dzmedia"
version = "0.1.0" version = "0.1.0"
edition = "2018" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
rocket = { version = "0.5.0-rc.1", features = ["json"], default-features = false } axum = "0.5"
http = "0.2"
tower-http = { version = "0.3", features = ["cors", "trace", "compression-br", "compression-deflate", "compression-gzip"] }
tracing-subscriber = "0.3"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
reqwest = { version = "0.11", features = ["json", "rustls-tls"], default-features = false } reqwest = { version = "0.11", features = ["json", "rustls-tls", "cookies", "gzip"], default-features = false }
serde_json = "1.0" serde_json = "1.0"
thiserror = "1.0" thiserror = "1.0"
regex = "1"

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM rust:latest as builder
# Make a fake Rust app to keep a cached layer of compiled crates
RUN USER=root cargo new app
WORKDIR /usr/src/app
COPY Cargo.toml Cargo.lock ./
# Needs at least a main.rs file with a main function
RUN mkdir src && echo "fn main(){}" > src/main.rs
# Will build all dependent crates in release mode
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/src/app/target \
cargo build --release
# Copy the rest
COPY . .
# Build (install) the actual binaries
RUN cargo install --path .
# Runtime image
FROM debian:bullseye-slim
# Run as "app" user
RUN useradd -ms /bin/bash app
USER app
WORKDIR /app
# Get compiled binaries from builder's cargo install directory
COPY --from=builder /usr/local/cargo/bin/dzmedia /app/dzmedia
# No CMD or ENTRYPOINT, see fly.toml with `cmd` override.

View File

@ -1 +1 @@
web: ROCKET_ADDRESS=0.0.0.0 ROCKET_PORT=$PORT ROCKET_KEEP_ALIVE=0 ./target/release/dzmedia web: ./target/release/dzmedia

35
fly.toml Normal file
View File

@ -0,0 +1,35 @@
app = "dzmedia"
kill_signal = "SIGINT"
kill_timeout = 5
[experimental]
# required because we can't infer your binary's name
cmd = "./dzmedia"
[env]
PORT = "8080"
RUST_LOG = "tower_http=trace"
[[services]]
internal_port = 8080
protocol = "tcp"
[services.concurrency]
hard_limit = 25
soft_limit = 20
[[services.ports]]
handlers = ["http"]
port = 80
[[services.ports]]
handlers = ["tls", "http"]
port = 443
[[services.tcp_checks]]
grace_period = "1s"
interval = "15s"
port = "8080"
restart_limit = 6
timeout = "2s"

View File

@ -1,8 +1,8 @@
use serde::{Serialize, Deserialize, de::DeserializeOwned}; use serde::{Serialize, Deserialize, de::DeserializeOwned};
use std::{env, marker::Sized, time::Instant}; use std::{env, marker::Sized, time::Instant, sync::Arc};
use thiserror::Error; use thiserror::Error;
use serde_json::{json, value::from_value}; use serde_json::{json, value::from_value};
use regex::Regex; use reqwest::{Client, Response, cookie::Jar, Url, header::ACCEPT};
#[derive(Deserialize)] #[derive(Deserialize)]
struct DeezerResponse { struct DeezerResponse {
@ -52,23 +52,28 @@ pub enum APIError {
#[derive(Clone)] #[derive(Clone)]
pub struct APIClient { pub struct APIClient {
client: reqwest::Client, client: Client,
license_token: String, pub license_token: String,
check_form: String, check_form: String,
pub sid: String,
arl: String,
renew_instant: Option<Instant>, renew_instant: Option<Instant>,
} }
impl APIClient { impl APIClient {
pub fn new() -> Self { pub fn new() -> Self {
if let Ok(arl) = env::var("ARL") { if let Ok(arl) = env::var("ARL") {
let builder = Client::builder();
let cookie = format!("arl={}; Domain=.deezer.com", arl);
let url = "https://www.deezer.com".parse::<Url>().unwrap();
let jar = Jar::default();
jar.add_cookie_str(&cookie, &url);
let builder = builder.cookie_provider(Arc::new(jar));
Self { Self {
client: reqwest::Client::new(), client: builder.build().unwrap(),
license_token: String::new(), license_token: String::new(),
check_form: String::new(), check_form: String::new(),
sid: String::new(),
arl,
renew_instant: None, renew_instant: None,
} }
} else { } else {
@ -76,8 +81,9 @@ impl APIClient {
} }
} }
async fn raw_api_call<T>(&self, method: &str, params: &T) -> Result<reqwest::Response, reqwest::Error> async fn no_renew_api_call<P, T>(&mut self, method: &str, params: &P) -> Result<T, APIError>
where T: Serialize + ?Sized where P: Serialize + ?Sized,
T: DeserializeOwned
{ {
let check_form; let check_form;
if method == "deezer.getUserData" { if method == "deezer.getUserData" {
@ -86,40 +92,18 @@ impl APIClient {
check_form = &self.check_form; check_form = &self.check_form;
} }
let mut cookies = format!("arl={}", self.arl); let json: DeezerResponse = self.client.post("https://www.deezer.com/ajax/gw-light.php")
if self.sid != "" && method != "deezer.getUserData" {
cookies.push_str(&format!("; sid={}", self.sid))
}
self.client.post("https://www.deezer.com/ajax/gw-light.php")
.query(&[ .query(&[
("method", method), ("method", method),
("input", "3"), ("input", "3"),
("api_version", "1.0"), ("api_version", "1.0"),
("api_token", check_form) ("api_token", check_form)
]) ])
.header(ACCEPT, "*/*")
.json(params) .json(params)
.header("cookie", &cookies)
.send() .send()
.await
}
pub async fn api_call<P, T>(&mut self, method: &str, params: &P) -> Result<T, APIError>
where P: Serialize + ?Sized,
T: DeserializeOwned
{
if let Some(i) = self.renew_instant {
if i.elapsed().as_secs() >= 3600 {
self.renew().await?;
}
} else {
self.renew().await?;
}
let json = self.raw_api_call(method, params)
.await? .await?
.json::<DeezerResponse>() .json()
.await?; .await?;
if let Some(error) = json.error.as_object() { if let Some(error) = json.error.as_object() {
@ -134,24 +118,33 @@ impl APIClient {
Ok(from_value(json.results)?) Ok(from_value(json.results)?)
} }
async fn renew(&mut self) -> Result<(), reqwest::Error> { pub async fn api_call<P, T>(&mut self, method: &str, params: &P) -> Result<T, APIError>
let resp = self.raw_api_call("deezer.getUserData", &json!({})).await?; where P: Serialize + ?Sized,
T: DeserializeOwned
{
if let Some(i) = self.renew_instant {
if i.elapsed().as_secs() >= 3600 {
self.renew().await?;
}
} else {
self.renew().await?;
}
let sid = resp.headers().get("set-cookie").unwrap().to_str().unwrap(); self.no_renew_api_call(method, params).await
let sid = Regex::new("^sid=(fr[\\da-f]+)").unwrap().captures(sid).unwrap(); }
self.sid = (&sid[1]).to_string();
let json = resp.json::<DeezerResponse>().await?.results; async fn renew(&mut self) -> Result<(), APIError> {
let user_data: serde_json::Value = self.no_renew_api_call("deezer.getUserData", &json!({})).await?;
self.check_form = json["checkForm"].as_str().unwrap().to_string(); self.check_form = user_data["checkForm"].as_str().unwrap().to_string();
self.license_token = json["USER"]["OPTIONS"]["license_token"].as_str().unwrap().to_string(); self.license_token = user_data["USER"]["OPTIONS"]["license_token"].as_str().unwrap().to_string();
self.renew_instant = Some(Instant::now()); self.renew_instant = Some(Instant::now());
Ok(()) Ok(())
} }
pub async fn get_media(&self, formats: &Vec<Format>, track_tokens: Vec<&str>) -> Result<reqwest::Response, reqwest::Error> { pub async fn get_media(&self, formats: &Vec<Format>, track_tokens: Vec<&str>) -> Result<Response, reqwest::Error> {
let formats: Vec<DeezerFormat> = formats.iter().map(|f| DeezerFormat { cipher: "BF_CBC_STRIPE", format: f }).collect(); let formats: Vec<DeezerFormat> = formats.iter().map(|f| DeezerFormat { cipher: "BF_CBC_STRIPE", format: f }).collect();
let req = json!({ let req = json!({

View File

@ -1,19 +1,18 @@
#[macro_use] extern crate rocket; use axum::{
routing::{get, post},
use rocket::{serde::{Deserialize, json::Json}, State, http::Status}; http::StatusCode,
use rocket::http::Header; response::IntoResponse,
use rocket::{Request, Response}; Json, Router, Extension,
use rocket::fairing::{Fairing, Info, Kind}; };
use tower_http::{cors::{CorsLayer, Any}, compression::CompressionLayer, trace::TraceLayer};
use http::{Method, header::CONTENT_TYPE};
use serde_json::json; use serde_json::json;
use std::sync::RwLock; use serde::Deserialize;
use std::sync::{Arc, RwLock};
mod api; mod api;
use api::{APIClient, APIError, Format}; use api::{APIClient, APIError, Format};
struct StateData {
client: APIClient
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct DeezerTrackList { struct DeezerTrackList {
data: Vec<DeezerTrack> data: Vec<DeezerTrack>
@ -25,8 +24,7 @@ struct DeezerTrack {
TRACK_TOKEN: String TRACK_TOKEN: String
} }
#[get("/")] async fn root() -> &'static str {
fn index() -> &'static str {
"marecchione gay af" "marecchione gay af"
} }
@ -36,32 +34,26 @@ struct RequestParams {
ids: Vec<u32>, ids: Vec<u32>,
} }
#[post("/get_url", format = "json", data = "<req>")] async fn get_url(Json(req): Json<RequestParams>, Extension(state): Extension<Arc<RwLock<APIClient>>>) -> impl IntoResponse {
async fn get_url(req: Json<RequestParams>, state_data: &State<RwLock<StateData>>) -> (Status, String) {
if req.formats.is_empty() { if req.formats.is_empty() {
return (Status::BadRequest, "Format list cannot be empty".to_string()); return (StatusCode::BAD_REQUEST, "Format list cannot be empty".to_string());
} }
if req.ids.is_empty() { if req.ids.is_empty() {
return (Status::BadRequest, "ID list cannot be empty".to_string()); return (StatusCode::BAD_REQUEST, "ID list cannot be empty".to_string());
} }
let mut client: APIClient; let mut client = state.read().unwrap().clone();
let old_sid: String; let old_license = client.license_token.clone();
{
let state_data_read = state_data.read().unwrap();
old_sid = state_data_read.client.sid.clone();
client = state_data_read.client.clone();
}
let resp: Result<DeezerTrackList, APIError> = client.api_call("song.getListData", &json!({"sng_ids":req.ids,"array_default":["SNG_ID","TRACK_TOKEN"]})).await; let resp: Result<DeezerTrackList, APIError> = client.api_call("song.getListData", &json!({"sng_ids":req.ids,"array_default":["SNG_ID","TRACK_TOKEN"]})).await;
let track_list; let track_list;
match resp { match resp {
Ok(t) => track_list = t, Ok(t) => track_list = t,
Err(e) => return (Status::InternalServerError, e.to_string()), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
}; };
if track_list.data.is_empty() { if track_list.data.is_empty() {
return (Status::BadRequest, "No valid IDs found".to_string()) return (StatusCode::BAD_REQUEST, "No valid IDs found".to_string());
} }
let track_tokens: Vec<&str> = track_list.data.iter().map(|t| t.TRACK_TOKEN.as_str()).collect(); let track_tokens: Vec<&str> = track_list.data.iter().map(|t| t.TRACK_TOKEN.as_str()).collect();
@ -70,43 +62,46 @@ async fn get_url(req: Json<RequestParams>, state_data: &State<RwLock<StateData>>
let media_resp; let media_resp;
match media_result { match media_result {
Ok(r) => media_resp = r, Ok(r) => media_resp = r,
Err(_) => return (Status::InternalServerError, "Error while getting response from media.deezer.com".to_string()) Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Error while getting response from media.deezer.com".to_string()),
}; };
if client.sid != old_sid { if client.license_token != old_license {
let mut state_data_write = state_data.write().unwrap(); let mut client_write = state.write().unwrap();
state_data_write.client = client; *client_write = client;
} }
(Status::Ok, media_resp.text().await.unwrap()) (StatusCode::OK, media_resp.text().await.unwrap())
} }
#[options("/get_url")] #[tokio::main]
fn send_options() -> Status { async fn main() {
Status::Ok let bind_addr = std::env::var("BIND_ADDR").unwrap_or("[::]".to_string());
} let port = std::env::var("PORT").unwrap_or("8000".to_string());
let port: u16 = port.parse().unwrap_or(8000);
pub struct CORS;
let shared_state = Arc::new(RwLock::new(APIClient::new()));
#[rocket::async_trait]
impl Fairing for CORS { let cors = CorsLayer::new()
fn info(&self) -> Info { .allow_origin(Any)
Info { .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD])
name: "Add CORS headers to responses", .allow_headers([CONTENT_TYPE]);
kind: Kind::Response
} tracing_subscriber::fmt::init();
}
let app = Router::new()
async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { .route("/", get(root))
response.set_header(Header::new("Access-Control-Allow-Origin", "*")); .route("/get_url", post(get_url))
response.set_header(Header::new("Access-Control-Allow-Headers", "*")); .layer(Extension(shared_state))
} .layer(cors)
} .layer(CompressionLayer::new())
.layer(TraceLayer::new_for_http());
#[launch]
fn rocket() -> _ { let bind_addr = format!("{bind_addr}:{port}");
rocket::build() println!("Listening on {bind_addr}");
.manage(RwLock::new(StateData { client: APIClient::new() })) let bind_addr = bind_addr.parse().unwrap();
.mount("/", routes![index, get_url, send_options])
.attach(CORS) axum::Server::bind(&bind_addr)
.serve(app.into_make_service())
.await
.unwrap();
} }