feat(backend): remake backend in Rust with Axum, stream piping, and low memory footprint
This commit is contained in:
parent
9c79317ffd
commit
8e99c3ca8d
25 changed files with 3603 additions and 3 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -7,3 +7,6 @@ venv/
|
||||||
ENV/
|
ENV/
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
target/
|
||||||
|
downloads/
|
||||||
|
yt-dlp
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -6,10 +6,33 @@ yeah
|
||||||
|
|
||||||
## features
|
## features
|
||||||
|
|
||||||
- youtube downloads (video, audio, thumbnail, subtitles)
|
- youtube downloads (video, audio, thumbnail, subtitles, channel art)
|
||||||
- instagram downloads (reels, posts)
|
- instagram downloads (reels, posts, profile avatars, thumbnails)
|
||||||
- minimal dark web interface
|
- minimal dark web interface
|
||||||
- self-hostable fastapi backend
|
- ultra-fast, low-memory Rust backend (Axum + Tokio)
|
||||||
|
- stream piping for flat memory footprint (~8MB idle RAM)
|
||||||
|
- automatic background cleanup of temporary downloads
|
||||||
|
- concurrency limiter to safeguard low-resource servers
|
||||||
|
|
||||||
|
## backend (rust)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
|
||||||
|
# run in development
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
# or run optimized release
|
||||||
|
cargo run --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
- `PORT` (default: `8000`)
|
||||||
|
- `HOST` (default: `0.0.0.0`)
|
||||||
|
- `MAX_CONCURRENT_DOWNLOADS` (default: `3`)
|
||||||
|
- `MAX_FILE_AGE_SECS` (default: `3600`)
|
||||||
|
|
||||||
|
> Note: Legacy Python FastAPI backend is preserved in `backend-py/`.
|
||||||
|
|
||||||
## license
|
## license
|
||||||
|
|
||||||
|
|
|
||||||
2000
backend/Cargo.lock
generated
Normal file
2000
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
29
backend/Cargo.toml
Normal file
29
backend/Cargo.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[package]
|
||||||
|
name = "just-download-backend"
|
||||||
|
version = "2.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
authors = ["oiupoyt"]
|
||||||
|
description = "Ultra-fast, memory-efficient Rust backend for just-download"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "http2", "json"] }
|
||||||
|
tokio = { version = "1.38", features = ["full"] }
|
||||||
|
tokio-util = { version = "0.7", features = ["io"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
tower-http = { version = "0.5", features = ["cors", "trace", "fs", "compression-full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
regex = "1.10"
|
||||||
|
uuid = { version = "1.9", features = ["v4", "fast-rng"] }
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||||
|
mime_guess = "2.0"
|
||||||
|
which = "6.0"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
16
backend/Makefile
Normal file
16
backend/Makefile
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
.PHONY: build release run clean check
|
||||||
|
|
||||||
|
check:
|
||||||
|
cargo check
|
||||||
|
|
||||||
|
build:
|
||||||
|
cargo build
|
||||||
|
|
||||||
|
release:
|
||||||
|
cargo build --release
|
||||||
|
|
||||||
|
run:
|
||||||
|
cargo run --release
|
||||||
|
|
||||||
|
clean:
|
||||||
|
cargo clean
|
||||||
62
backend/src/config.rs
Normal file
62
backend/src/config.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Semaphore;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AppConfig {
|
||||||
|
pub host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub download_dir: PathBuf,
|
||||||
|
pub max_file_age_secs: u64,
|
||||||
|
pub cleanup_interval_secs: u64,
|
||||||
|
pub semaphore: Arc<Semaphore>,
|
||||||
|
pub ytdlp_bin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppConfig {
|
||||||
|
pub fn from_env() -> Self {
|
||||||
|
let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
|
||||||
|
let port = std::env::var("PORT")
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| p.parse().ok())
|
||||||
|
.unwrap_or(8000);
|
||||||
|
let download_dir = PathBuf::from(
|
||||||
|
std::env::var("DOWNLOAD_DIR").unwrap_or_else(|_| "downloads".to_string()),
|
||||||
|
);
|
||||||
|
let max_file_age_secs = std::env::var("MAX_FILE_AGE_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(3600); // 1 hour
|
||||||
|
let cleanup_interval_secs = std::env::var("CLEANUP_INTERVAL_SECS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(600); // 10 minutes
|
||||||
|
|
||||||
|
// Limit concurrent downloads on low-memory servers (default 2)
|
||||||
|
let max_concurrent = std::env::var("MAX_CONCURRENT_DOWNLOADS")
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(3);
|
||||||
|
|
||||||
|
// Find yt-dlp binary
|
||||||
|
let ytdlp_bin = if let Ok(custom) = std::env::var("YTDLP_PATH") {
|
||||||
|
custom
|
||||||
|
} else if which::which("yt-dlp").is_ok() {
|
||||||
|
"yt-dlp".to_string()
|
||||||
|
} else if std::path::Path::new("./yt-dlp").exists() {
|
||||||
|
"./yt-dlp".to_string()
|
||||||
|
} else {
|
||||||
|
"yt-dlp".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
download_dir,
|
||||||
|
max_file_age_secs,
|
||||||
|
cleanup_interval_secs,
|
||||||
|
semaphore: Arc::new(Semaphore::new(max_concurrent)),
|
||||||
|
ytdlp_bin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/src/error.rs
Normal file
60
backend/src/error.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use axum::Json;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AppError {
|
||||||
|
BadRequest(String),
|
||||||
|
NotFound(String),
|
||||||
|
Internal(String),
|
||||||
|
#[allow(dead_code)]
|
||||||
|
Timeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for AppError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
AppError::BadRequest(msg) => write!(f, "{}", msg),
|
||||||
|
AppError::NotFound(msg) => write!(f, "{}", msg),
|
||||||
|
AppError::Internal(msg) => write!(f, "{}", msg),
|
||||||
|
AppError::Timeout => write!(f, "Operation timed out"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for AppError {}
|
||||||
|
|
||||||
|
impl IntoResponse for AppError {
|
||||||
|
fn into_response(self) -> Response {
|
||||||
|
let (status, msg) = match self {
|
||||||
|
AppError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||||
|
AppError::NotFound(ref m) => (StatusCode::NOT_FOUND, m.clone()),
|
||||||
|
AppError::Internal(ref m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
|
||||||
|
AppError::Timeout => (StatusCode::GATEWAY_TIMEOUT, "Operation timed out".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// FastAPI standard format is {"detail": "..."}
|
||||||
|
let body = Json(json!({ "detail": msg }));
|
||||||
|
(status, body).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for AppError {
|
||||||
|
fn from(err: std::io::Error) -> Self {
|
||||||
|
AppError::Internal(err.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for AppError {
|
||||||
|
fn from(err: serde_json::Error) -> Self {
|
||||||
|
AppError::Internal(format!("JSON error: {}", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for AppError {
|
||||||
|
fn from(err: reqwest::Error) -> Self {
|
||||||
|
AppError::Internal(format!("Network error: {}", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
161
backend/src/main.rs
Normal file
161
backend/src/main.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod routes;
|
||||||
|
mod services;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use axum::routing::{get, post};
|
||||||
|
use axum::{Json, Router};
|
||||||
|
use serde_json::json;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tower_http::compression::CompressionLayer;
|
||||||
|
use tower_http::cors::CorsLayer;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
use tower_http::trace::TraceLayer;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Initialize logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(
|
||||||
|
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| "info,tower_http=info".into()),
|
||||||
|
)
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = AppConfig::from_env();
|
||||||
|
|
||||||
|
// Ensure downloads directory exists
|
||||||
|
if let Err(e) = tokio::fs::create_dir_all(&config.download_dir).await {
|
||||||
|
warn!("Failed to create download directory: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn periodic cleanup loop
|
||||||
|
let cleanup_dir = config.download_dir.clone();
|
||||||
|
let max_age = config.max_file_age_secs;
|
||||||
|
let interval = config.cleanup_interval_secs;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_cleanup_loop(cleanup_dir, max_age, interval).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build Axum routers
|
||||||
|
let info_routes = Router::new()
|
||||||
|
.route("/detect", post(routes::info::detect))
|
||||||
|
.route("/preview", post(routes::info::preview));
|
||||||
|
|
||||||
|
let yt_routes = Router::new()
|
||||||
|
.route("/video", post(routes::youtube::video))
|
||||||
|
.route("/audio", post(routes::youtube::audio))
|
||||||
|
.route("/thumbnail", post(routes::youtube::thumbnail))
|
||||||
|
.route("/subtitles", post(routes::youtube::subtitles))
|
||||||
|
.route("/channel-art", post(routes::youtube::channel_art));
|
||||||
|
|
||||||
|
let ig_routes = Router::new()
|
||||||
|
.route("/post", post(routes::instagram::post))
|
||||||
|
.route("/reel", post(routes::instagram::reel))
|
||||||
|
.route("/profile", post(routes::instagram::profile))
|
||||||
|
.route("/thumbnail", post(routes::instagram::thumbnail));
|
||||||
|
|
||||||
|
// Static file serving for /files/*
|
||||||
|
let serve_dir = ServeDir::new(&config.download_dir);
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(root_handler))
|
||||||
|
.route("/health", get(health_handler))
|
||||||
|
.nest("/api/info", info_routes)
|
||||||
|
.nest("/api/youtube", yt_routes)
|
||||||
|
.nest("/api/instagram", ig_routes)
|
||||||
|
.nest_service("/files", serve_dir)
|
||||||
|
.layer(CorsLayer::permissive())
|
||||||
|
.layer(CompressionLayer::new())
|
||||||
|
.layer(TraceLayer::new_for_http())
|
||||||
|
.with_state(config.clone());
|
||||||
|
|
||||||
|
let addr: SocketAddr = format!("{}:{}", config.host, config.port)
|
||||||
|
.parse()
|
||||||
|
.expect("Invalid host/port configuration");
|
||||||
|
|
||||||
|
info!("🚀 just-download Rust backend listening on http://{}", addr);
|
||||||
|
info!("⚡ yt-dlp binary resolved to: {}", config.ytdlp_bin);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("Failed to bind to {}: {}", addr, e));
|
||||||
|
|
||||||
|
axum::serve(listener, app)
|
||||||
|
.with_graceful_shutdown(shutdown_signal())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn root_handler() -> Json<serde_json::Value> {
|
||||||
|
Json(json!({
|
||||||
|
"status": "ok",
|
||||||
|
"message": "just download stuff lol (rust engine)",
|
||||||
|
"version": "2.0.0-rs"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health_handler() -> Json<serde_json::Value> {
|
||||||
|
Json(json!({
|
||||||
|
"status": "healthy",
|
||||||
|
"engine": "rust",
|
||||||
|
"uptime": "ok"
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic background task that removes files older than max_age_secs
|
||||||
|
async fn run_cleanup_loop(dir: PathBuf, max_age_secs: u64, interval_secs: u64) {
|
||||||
|
let mut ticker = tokio::time::interval(Duration::from_secs(interval_secs));
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
if let Ok(mut entries) = tokio::fs::read_dir(&dir).await {
|
||||||
|
let now = std::time::SystemTime::now();
|
||||||
|
while let Ok(Some(entry)) = entries.next_entry().await {
|
||||||
|
if let Ok(meta) = entry.metadata().await {
|
||||||
|
if meta.is_file() {
|
||||||
|
if let Ok(modified) = meta.modified() {
|
||||||
|
if let Ok(elapsed) = now.duration_since(modified) {
|
||||||
|
if elapsed.as_secs() > max_age_secs {
|
||||||
|
let _ = tokio::fs::remove_file(entry.path()).await;
|
||||||
|
info!("Cleaned up expired file: {:?}", entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Graceful shutdown listener for SIGINT (Ctrl+C) and SIGTERM
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("Failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("Failed to install SIGTERM handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
info!("Shutdown signal received, draining active requests...");
|
||||||
|
}
|
||||||
92
backend/src/routes/info.rs
Normal file
92
backend/src/routes/info.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use regex::Regex;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::instagram::InstagramService;
|
||||||
|
use crate::services::youtube::YouTubeService;
|
||||||
|
use crate::utils::{clean_ig_url, clean_yt_url, extract_ig_username, is_ig_profile_url};
|
||||||
|
|
||||||
|
static YT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(youtube\.com|youtu\.be)").expect("valid regex")
|
||||||
|
});
|
||||||
|
static IG_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(instagram\.com|instagr\.am)").expect("valid regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct InfoRequest {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct DetectResponse {
|
||||||
|
pub platform: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detect_platform(url: &str) -> &'static str {
|
||||||
|
if YT_RE.is_match(url) {
|
||||||
|
"youtube"
|
||||||
|
} else if IG_RE.is_match(url) {
|
||||||
|
"instagram"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn detect(Json(req): Json<InfoRequest>) -> Result<Json<DetectResponse>, AppError> {
|
||||||
|
let platform = detect_platform(&req.url);
|
||||||
|
if platform == "unknown" {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"could not detect platform from that url".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Json(DetectResponse {
|
||||||
|
platform: platform.to_string(),
|
||||||
|
url: req.url,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<InfoRequest>,
|
||||||
|
) -> Result<Json<Value>, AppError> {
|
||||||
|
let raw_url = req.url.trim();
|
||||||
|
let platform = detect_platform(raw_url);
|
||||||
|
|
||||||
|
let mut result_json = match platform {
|
||||||
|
"youtube" => {
|
||||||
|
let clean = clean_yt_url(raw_url);
|
||||||
|
let info = YouTubeService::fetch_info(&config.ytdlp_bin, &clean).await?;
|
||||||
|
serde_json::to_value(info)?
|
||||||
|
}
|
||||||
|
"instagram" => {
|
||||||
|
let clean = clean_ig_url(raw_url);
|
||||||
|
if is_ig_profile_url(&clean) {
|
||||||
|
let username = extract_ig_username(&clean)
|
||||||
|
.map_err(|e| AppError::BadRequest(e))?;
|
||||||
|
let info = InstagramService::fetch_profile_info(&config.ytdlp_bin, &username).await?;
|
||||||
|
serde_json::to_value(info)?
|
||||||
|
} else {
|
||||||
|
let info = InstagramService::fetch_post_info(&config.ytdlp_bin, &clean).await?;
|
||||||
|
serde_json::to_value(info)?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"unsupported platform — only YouTube and Instagram are supported".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(obj) = result_json.as_object_mut() {
|
||||||
|
obj.insert("platform".to_string(), json!(platform));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(result_json))
|
||||||
|
}
|
||||||
123
backend/src/routes/instagram.rs
Normal file
123
backend/src/routes/instagram.rs
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::instagram::InstagramService;
|
||||||
|
use crate::utils::{clean_ig_url, extract_ig_username};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct PostRequest {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
pub format: String,
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_format() -> String {
|
||||||
|
"mp4".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ProfileRequest {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SimpleRequest {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn post(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<PostRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_ig_url(req.url.trim());
|
||||||
|
let (path, filename) = InstagramService::download_post(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
&req.format,
|
||||||
|
req.start_time.as_deref(),
|
||||||
|
req.end_time.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reel(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<PostRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_ig_url(req.url.trim());
|
||||||
|
let (path, filename) = InstagramService::download_reel(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
req.start_time.as_deref(),
|
||||||
|
req.end_time.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn profile(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<ProfileRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let raw = req.username.trim();
|
||||||
|
let username = if raw.contains("instagram.com") {
|
||||||
|
extract_ig_username(raw).map_err(|e| AppError::BadRequest(e))?
|
||||||
|
} else {
|
||||||
|
raw.trim_start_matches('@').to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if username.is_empty() {
|
||||||
|
return Err(AppError::BadRequest("no username provided".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let (path, filename) = InstagramService::download_profile(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&username,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn thumbnail(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<SimpleRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_ig_url(req.url.trim());
|
||||||
|
let (path, filename) = InstagramService::download_thumbnail(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
47
backend/src/routes/mod.rs
Normal file
47
backend/src/routes/mod.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
pub mod info;
|
||||||
|
pub mod instagram;
|
||||||
|
pub mod youtube;
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE};
|
||||||
|
use axum::http::HeaderMap;
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
/// Streams a file response with Content-Disposition and schedules background cleanup
|
||||||
|
pub async fn stream_file_response(path: PathBuf, filename: String) -> Result<Response, AppError> {
|
||||||
|
let file = tokio::fs::File::open(&path).await.map_err(|e| {
|
||||||
|
AppError::NotFound(format!("Failed to open generated file: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let file_len = file.metadata().await.ok().map(|m| m.len());
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
let body = Body::from_stream(stream);
|
||||||
|
|
||||||
|
let mime = mime_guess::from_path(&filename)
|
||||||
|
.first_or_octet_stream()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
if let Ok(ct) = mime.parse() {
|
||||||
|
headers.insert(CONTENT_TYPE, ct);
|
||||||
|
}
|
||||||
|
if let Some(len) = file_len {
|
||||||
|
headers.insert(CONTENT_LENGTH, len.to_string().parse().unwrap());
|
||||||
|
}
|
||||||
|
if let Ok(cd) = format!("attachment; filename=\"{}\"", filename).parse() {
|
||||||
|
headers.insert(CONTENT_DISPOSITION, cd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background deletion after 15 minutes (900 seconds)
|
||||||
|
let path_clone = path.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(900)).await;
|
||||||
|
let _ = tokio::fs::remove_file(&path_clone).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((headers, body).into_response())
|
||||||
|
}
|
||||||
166
backend/src/routes/youtube.rs
Normal file
166
backend/src/routes/youtube.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::response::Response;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::youtube::YouTubeService;
|
||||||
|
use crate::utils::clean_yt_url;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct VideoRequest {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_resolution")]
|
||||||
|
pub resolution: String,
|
||||||
|
#[serde(default = "default_format")]
|
||||||
|
pub format: String,
|
||||||
|
pub start_time: Option<String>,
|
||||||
|
pub end_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_resolution() -> String {
|
||||||
|
"best".to_string()
|
||||||
|
}
|
||||||
|
fn default_format() -> String {
|
||||||
|
"mp4".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct AudioRequest {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_quality")]
|
||||||
|
pub quality: String,
|
||||||
|
#[serde(default = "default_audio_format")]
|
||||||
|
pub format: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_quality() -> String {
|
||||||
|
"192".to_string()
|
||||||
|
}
|
||||||
|
fn default_audio_format() -> String {
|
||||||
|
"mp3".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SimpleRequest {
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct SubtitleRequest {
|
||||||
|
pub url: String,
|
||||||
|
#[serde(default = "default_lang")]
|
||||||
|
pub lang: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_lang() -> String {
|
||||||
|
"en".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChannelRequest {
|
||||||
|
pub channel_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn video(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<VideoRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_yt_url(&req.url.trim());
|
||||||
|
let (path, filename) = YouTubeService::download_video(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
&req.resolution,
|
||||||
|
&req.format,
|
||||||
|
req.start_time.as_deref(),
|
||||||
|
req.end_time.as_deref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn audio(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<AudioRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_yt_url(&req.url.trim());
|
||||||
|
let (path, filename) = YouTubeService::download_audio(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
&req.quality,
|
||||||
|
&req.format,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn thumbnail(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<SimpleRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_yt_url(&req.url.trim());
|
||||||
|
let (path, filename) = YouTubeService::download_thumbnail(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn subtitles(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<SubtitleRequest>,
|
||||||
|
) -> Result<Response, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let url = clean_yt_url(&req.url.trim());
|
||||||
|
let (path, filename) = YouTubeService::download_subtitles(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
&url,
|
||||||
|
&req.lang,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
crate::routes::stream_file_response(path, filename).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn channel_art(
|
||||||
|
State(config): State<AppConfig>,
|
||||||
|
Json(req): Json<ChannelRequest>,
|
||||||
|
) -> Result<Json<serde_json::Value>, AppError> {
|
||||||
|
let _permit = config.semaphore.acquire().await.map_err(|_| {
|
||||||
|
AppError::Internal("Server busy, please retry shortly".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let assets = YouTubeService::download_channel_art(
|
||||||
|
&config.ytdlp_bin,
|
||||||
|
&config.download_dir,
|
||||||
|
req.channel_url.trim(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(Json(json!({ "assets": assets })))
|
||||||
|
}
|
||||||
210
backend/src/services/instagram.rs
Normal file
210
backend/src/services/instagram.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::ytdlp::YtDlp;
|
||||||
|
use crate::utils::{format_duration, safe_filename};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct IgPostInfoResponse {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub media_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub duration_str: Option<String>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
pub likes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct IgProfileInfoResponse {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub media_type: String,
|
||||||
|
pub title: String,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub owner: String,
|
||||||
|
pub likes: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct InstagramService;
|
||||||
|
|
||||||
|
impl InstagramService {
|
||||||
|
pub async fn fetch_post_info(bin: &str, url: &str) -> Result<IgPostInfoResponse, AppError> {
|
||||||
|
let json = YtDlp::dump_json(bin, url).await?;
|
||||||
|
|
||||||
|
let title = json
|
||||||
|
.get("title")
|
||||||
|
.or_else(|| json.get("uploader"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("Instagram post")
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let thumbnail = json.get("thumbnail").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let duration = json.get("duration").and_then(|v| v.as_f64());
|
||||||
|
let duration_str = duration.map(|d| format_duration(Some(d)));
|
||||||
|
let owner = json.get("uploader").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let likes = json.get("like_count").and_then(|v| v.as_u64());
|
||||||
|
|
||||||
|
Ok(IgPostInfoResponse {
|
||||||
|
media_type: "video".to_string(),
|
||||||
|
title,
|
||||||
|
thumbnail,
|
||||||
|
duration,
|
||||||
|
duration_str,
|
||||||
|
owner,
|
||||||
|
likes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_profile_info(bin: &str, username: &str) -> Result<IgProfileInfoResponse, AppError> {
|
||||||
|
let profile_url = format!("https://www.instagram.com/{}/", username);
|
||||||
|
let json = YtDlp::dump_json(bin, &profile_url).await?;
|
||||||
|
|
||||||
|
let thumbnail = json.get("thumbnail").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let followers = json.get("channel_follower_count").and_then(|v| v.as_u64());
|
||||||
|
|
||||||
|
Ok(IgProfileInfoResponse {
|
||||||
|
media_type: "profile".to_string(),
|
||||||
|
title: format!("@{}", username),
|
||||||
|
thumbnail,
|
||||||
|
owner: username.to_string(),
|
||||||
|
likes: followers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_post(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
fmt: &str,
|
||||||
|
start_time: Option<&str>,
|
||||||
|
end_time: Option<&str>,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("ig_post_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let args = [
|
||||||
|
"-f", "bestvideo+bestaudio/best",
|
||||||
|
"--merge-output-format", fmt,
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound("Instagram post file not found".to_string()))?;
|
||||||
|
|
||||||
|
if start_time.is_some() || end_time.is_some() {
|
||||||
|
let trimmed = download_dir.join(format!("ig_post_{}_trim.mp4", uid));
|
||||||
|
if YtDlp::trim_media(&final_file, &trimmed, start_time, end_time).await.is_ok() {
|
||||||
|
let _ = tokio::fs::remove_file(&final_file).await;
|
||||||
|
final_file = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let download_name = format!("ig_post_{}.{}", uid, fmt);
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_reel(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
start_time: Option<&str>,
|
||||||
|
end_time: Option<&str>,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("ig_reel_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let args = [
|
||||||
|
"-f", "bestvideo+bestaudio/best",
|
||||||
|
"--merge-output-format", "mp4",
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let mut final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound("Instagram reel file not found".to_string()))?;
|
||||||
|
|
||||||
|
if start_time.is_some() || end_time.is_some() {
|
||||||
|
let trimmed = download_dir.join(format!("ig_reel_{}_trim.mp4", uid));
|
||||||
|
if YtDlp::trim_media(&final_file, &trimmed, start_time, end_time).await.is_ok() {
|
||||||
|
let _ = tokio::fs::remove_file(&final_file).await;
|
||||||
|
final_file = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let download_name = format!("ig_reel_{}.mp4", uid);
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_thumbnail(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let json = YtDlp::dump_json(bin, url).await?;
|
||||||
|
|
||||||
|
let thumb_url = json
|
||||||
|
.get("thumbnail")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| AppError::NotFound("No thumbnail found for Instagram post".to_string()))?;
|
||||||
|
|
||||||
|
let filename = format!("ig_thumb_{}.jpg", uid);
|
||||||
|
let dest = download_dir.join(&filename);
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let bytes = client
|
||||||
|
.get(thumb_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to fetch thumbnail: {}", e)))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read thumbnail bytes: {}", e)))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&dest, bytes).await?;
|
||||||
|
|
||||||
|
Ok((dest, filename))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_profile(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
username: &str,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let profile_url = format!("https://www.instagram.com/{}/", username);
|
||||||
|
let json = YtDlp::dump_json(bin, &profile_url).await?;
|
||||||
|
|
||||||
|
let thumb_url = json
|
||||||
|
.get("thumbnail")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("Could not find profile picture for @{}", username)))?;
|
||||||
|
|
||||||
|
let safe_user = safe_filename(username);
|
||||||
|
let filename = format!("{}_profile.jpg", safe_user);
|
||||||
|
let dest = download_dir.join(format!("ig_pfp_{}_{}.jpg", safe_user, uid));
|
||||||
|
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let bytes = client
|
||||||
|
.get(thumb_url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to fetch profile picture: {}", e)))?
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to read profile picture bytes: {}", e)))?;
|
||||||
|
|
||||||
|
tokio::fs::write(&dest, bytes).await?;
|
||||||
|
|
||||||
|
Ok((dest, filename))
|
||||||
|
}
|
||||||
|
}
|
||||||
3
backend/src/services/mod.rs
Normal file
3
backend/src/services/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod instagram;
|
||||||
|
pub mod youtube;
|
||||||
|
pub mod ytdlp;
|
||||||
336
backend/src/services/youtube.rs
Normal file
336
backend/src/services/youtube.rs
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::services::ytdlp::YtDlp;
|
||||||
|
use crate::utils::{format_duration, safe_filename};
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct YtInfoResponse {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub duration: Option<f64>,
|
||||||
|
pub duration_str: String,
|
||||||
|
pub thumbnail: Option<String>,
|
||||||
|
pub uploader: Option<String>,
|
||||||
|
pub view_count: Option<u64>,
|
||||||
|
pub upload_date: Option<String>,
|
||||||
|
pub available_resolutions: Vec<u64>,
|
||||||
|
pub fps_options: Vec<u64>,
|
||||||
|
pub has_subtitles: bool,
|
||||||
|
pub subtitle_langs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ChannelAsset {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub asset_type: String,
|
||||||
|
pub filename: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct YouTubeService;
|
||||||
|
|
||||||
|
impl YouTubeService {
|
||||||
|
pub async fn fetch_info(bin: &str, url: &str) -> Result<YtInfoResponse, AppError> {
|
||||||
|
let json = YtDlp::dump_json(bin, url).await?;
|
||||||
|
|
||||||
|
let title = json.get("title").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let duration = json.get("duration").and_then(|v| v.as_f64());
|
||||||
|
let duration_str = format_duration(duration);
|
||||||
|
let thumbnail = json.get("thumbnail").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let uploader = json.get("uploader").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
let view_count = json.get("view_count").and_then(|v| v.as_u64());
|
||||||
|
let upload_date = json.get("upload_date").and_then(|v| v.as_str()).map(String::from);
|
||||||
|
|
||||||
|
let mut res_set = BTreeSet::new();
|
||||||
|
let mut fps_set = BTreeSet::new();
|
||||||
|
|
||||||
|
if let Some(formats) = json.get("formats").and_then(|v| v.as_array()) {
|
||||||
|
for f in formats {
|
||||||
|
if let Some(h) = f.get("height").and_then(|v| v.as_u64()) {
|
||||||
|
res_set.insert(h);
|
||||||
|
}
|
||||||
|
if let Some(fps) = f.get("fps").and_then(|v| v.as_u64()) {
|
||||||
|
fps_set.insert(fps);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut available_resolutions: Vec<u64> = res_set.into_iter().collect();
|
||||||
|
available_resolutions.reverse();
|
||||||
|
|
||||||
|
let mut fps_options: Vec<u64> = fps_set.into_iter().collect();
|
||||||
|
fps_options.reverse();
|
||||||
|
|
||||||
|
let mut subtitle_langs = Vec::new();
|
||||||
|
let mut has_subtitles = false;
|
||||||
|
|
||||||
|
if let Some(subs) = json.get("subtitles").and_then(|v| v.as_object()) {
|
||||||
|
has_subtitles = !subs.is_empty();
|
||||||
|
for lang in subs.keys() {
|
||||||
|
subtitle_langs.push(lang.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(YtInfoResponse {
|
||||||
|
title,
|
||||||
|
duration,
|
||||||
|
duration_str,
|
||||||
|
thumbnail,
|
||||||
|
uploader,
|
||||||
|
view_count,
|
||||||
|
upload_date,
|
||||||
|
available_resolutions,
|
||||||
|
fps_options,
|
||||||
|
has_subtitles,
|
||||||
|
subtitle_langs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_video(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
resolution: &str,
|
||||||
|
fmt: &str,
|
||||||
|
start_time: Option<&str>,
|
||||||
|
end_time: Option<&str>,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("yt_video_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let fmt_sel = if resolution == "best" {
|
||||||
|
format!("bestvideo[ext={}]+bestaudio/bestvideo+bestaudio/best", fmt)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"bestvideo[height<={}][ext={}]+bestaudio/bestvideo[height<={}]+bestaudio/best",
|
||||||
|
resolution, fmt, resolution
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut args: Vec<&str> = vec![
|
||||||
|
"-f", &fmt_sel,
|
||||||
|
"--merge-output-format", fmt,
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
];
|
||||||
|
|
||||||
|
let start_owned;
|
||||||
|
let end_owned;
|
||||||
|
if let Some(st) = start_time {
|
||||||
|
start_owned = format!("*{}", st);
|
||||||
|
args.push("--download-sections");
|
||||||
|
args.push(&start_owned);
|
||||||
|
} else if let Some(et) = end_time {
|
||||||
|
end_owned = format!("*-{}", et);
|
||||||
|
args.push("--download-sections");
|
||||||
|
args.push(&end_owned);
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push(url);
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound("Output video file not found".to_string()))?;
|
||||||
|
|
||||||
|
// Extract video title
|
||||||
|
let title = match YtDlp::dump_json(bin, url).await {
|
||||||
|
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("video")),
|
||||||
|
Err(_) => "video".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = final_file.extension().and_then(|e| e.to_str()).unwrap_or(fmt);
|
||||||
|
let download_name = format!("{}.{}", title, ext);
|
||||||
|
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_audio(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
quality: &str,
|
||||||
|
fmt: &str,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("yt_audio_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let codec = match fmt {
|
||||||
|
"opus" => "opus",
|
||||||
|
"flac" => "flac",
|
||||||
|
"m4a" => "m4a",
|
||||||
|
_ => "mp3",
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = [
|
||||||
|
"-x",
|
||||||
|
"--audio-format", codec,
|
||||||
|
"--audio-quality", quality,
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound("Audio file not found".to_string()))?;
|
||||||
|
|
||||||
|
let title = match YtDlp::dump_json(bin, url).await {
|
||||||
|
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("audio")),
|
||||||
|
Err(_) => "audio".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = final_file.extension().and_then(|e| e.to_str()).unwrap_or(codec);
|
||||||
|
let download_name = format!("{}.{}", title, ext);
|
||||||
|
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_thumbnail(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("yt_thumb_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let args = [
|
||||||
|
"--skip-download",
|
||||||
|
"--write-thumbnail",
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound("Thumbnail file not found".to_string()))?;
|
||||||
|
|
||||||
|
let title = match YtDlp::dump_json(bin, url).await {
|
||||||
|
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("thumbnail")),
|
||||||
|
Err(_) => "thumbnail".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = final_file.extension().and_then(|e| e.to_str()).unwrap_or("jpg");
|
||||||
|
let download_name = format!("{}_thumbnail.{}", title, ext);
|
||||||
|
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_subtitles(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
url: &str,
|
||||||
|
lang: &str,
|
||||||
|
) -> Result<(PathBuf, String), AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let out_prefix = format!("yt_subs_{}", uid);
|
||||||
|
let out_tmpl = format!("{}/{}.%(ext)s", download_dir.display(), out_prefix);
|
||||||
|
|
||||||
|
let args = [
|
||||||
|
"--skip-download",
|
||||||
|
"--write-subs",
|
||||||
|
"--write-auto-subs",
|
||||||
|
"--sub-lang", lang,
|
||||||
|
"--sub-format", "srt",
|
||||||
|
"-o", &out_tmpl,
|
||||||
|
url,
|
||||||
|
];
|
||||||
|
|
||||||
|
YtDlp::execute(bin, &args).await?;
|
||||||
|
|
||||||
|
let final_file = YtDlp::find_file_with_prefix(download_dir, &out_prefix)
|
||||||
|
.ok_or_else(|| AppError::NotFound(format!("No subtitles found for lang={}", lang)))?;
|
||||||
|
|
||||||
|
let title = match YtDlp::dump_json(bin, url).await {
|
||||||
|
Ok(json) => safe_filename(json.get("title").and_then(|v| v.as_str()).unwrap_or("subtitles")),
|
||||||
|
Err(_) => "subtitles".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let download_name = format!("{}.{}.srt", title, lang);
|
||||||
|
|
||||||
|
Ok((final_file, download_name))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn download_channel_art(
|
||||||
|
bin: &str,
|
||||||
|
download_dir: &Path,
|
||||||
|
channel_url: &str,
|
||||||
|
) -> Result<Vec<ChannelAsset>, AppError> {
|
||||||
|
let uid = &Uuid::new_v4().to_string()[..10];
|
||||||
|
let json = YtDlp::dump_json(bin, channel_url).await?;
|
||||||
|
|
||||||
|
let channel_name = safe_filename(
|
||||||
|
json.get("channel")
|
||||||
|
.or_else(|| json.get("uploader"))
|
||||||
|
.or_else(|| json.get("title"))
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("channel"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut assets = Vec::new();
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
|
||||||
|
if let Some(thumbnails) = json.get("thumbnails").and_then(|v| v.as_array()) {
|
||||||
|
// Find avatar
|
||||||
|
let mut avatar_candidates = Vec::new();
|
||||||
|
for t in thumbnails {
|
||||||
|
if let Some(u) = t.get("url").and_then(|v| v.as_str()) {
|
||||||
|
let w = t.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let h = t.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
if w > 0 && w == h {
|
||||||
|
avatar_candidates.push((w, u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((_, best_avatar_url)) = avatar_candidates.into_iter().max_by_key(|c| c.0) {
|
||||||
|
let filename = format!("{}_avatar_{}.jpg", channel_name, uid);
|
||||||
|
let dest = download_dir.join(&filename);
|
||||||
|
if let Ok(resp) = client.get(best_avatar_url).send().await {
|
||||||
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
|
let _ = tokio::fs::write(&dest, bytes).await;
|
||||||
|
assets.push(ChannelAsset {
|
||||||
|
asset_type: "avatar".to_string(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
url: format!("/files/{}", filename),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find banner
|
||||||
|
let mut banner_candidates = Vec::new();
|
||||||
|
for t in thumbnails {
|
||||||
|
if let Some(u) = t.get("url").and_then(|v| v.as_str()) {
|
||||||
|
let w = t.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let h = t.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
if w > 0 && h > 0 && w > h * 2 {
|
||||||
|
banner_candidates.push((w * h, u));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((_, best_banner_url)) = banner_candidates.into_iter().max_by_key(|c| c.0) {
|
||||||
|
let filename = format!("{}_banner_{}.jpg", channel_name, uid);
|
||||||
|
let dest = download_dir.join(&filename);
|
||||||
|
if let Ok(resp) = client.get(best_banner_url).send().await {
|
||||||
|
if let Ok(bytes) = resp.bytes().await {
|
||||||
|
let _ = tokio::fs::write(&dest, bytes).await;
|
||||||
|
assets.push(ChannelAsset {
|
||||||
|
asset_type: "banner".to_string(),
|
||||||
|
filename: filename.clone(),
|
||||||
|
url: format!("/files/{}", filename),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(assets)
|
||||||
|
}
|
||||||
|
}
|
||||||
125
backend/src/services/ytdlp.rs
Normal file
125
backend/src/services/ytdlp.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
use crate::error::AppError;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Stdio;
|
||||||
|
use tokio::process::Command;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
pub struct YtDlp;
|
||||||
|
|
||||||
|
impl YtDlp {
|
||||||
|
/// Detects if node runtime exists for yt-dlp javascript engine
|
||||||
|
fn has_node() -> bool {
|
||||||
|
which::which("node").is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns yt-dlp and extracts JSON metadata
|
||||||
|
pub async fn dump_json(bin: &str, url: &str) -> Result<serde_json::Value, AppError> {
|
||||||
|
let mut cmd = Command::new(bin);
|
||||||
|
cmd.arg("--dump-json")
|
||||||
|
.arg("--skip-download")
|
||||||
|
.arg("--no-warnings")
|
||||||
|
.arg("--no-playlist");
|
||||||
|
|
||||||
|
if Self::has_node() {
|
||||||
|
cmd.arg("--js-runtimes").arg("node");
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.arg(url);
|
||||||
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let output = cmd.output().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to execute yt-dlp: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
error!("yt-dlp error: {}", stderr);
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"yt-dlp failed: {}",
|
||||||
|
stderr.lines().next().unwrap_or("Unknown error")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let json_val: serde_json::Value = serde_json::from_slice(&output.stdout)
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to parse yt-dlp metadata: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(json_val)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs yt-dlp with custom arguments
|
||||||
|
pub async fn execute(bin: &str, args: &[&str]) -> Result<(), AppError> {
|
||||||
|
let mut cmd = Command::new(bin);
|
||||||
|
cmd.args(args);
|
||||||
|
cmd.arg("--no-warnings").arg("--no-playlist");
|
||||||
|
|
||||||
|
if Self::has_node() {
|
||||||
|
cmd.arg("--js-runtimes").arg("node");
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
info!("Executing yt-dlp with {} args", args.len());
|
||||||
|
let output = cmd.output().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to run yt-dlp: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
error!("yt-dlp execution failed: {}", stderr);
|
||||||
|
return Err(AppError::Internal(format!(
|
||||||
|
"Download failed: {}",
|
||||||
|
stderr.lines().last().unwrap_or("yt-dlp error")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Trims video/audio using ffmpeg stream copy
|
||||||
|
pub async fn trim_media(
|
||||||
|
src: &Path,
|
||||||
|
dest: &Path,
|
||||||
|
start_time: Option<&str>,
|
||||||
|
end_time: Option<&str>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let mut cmd = Command::new("ffmpeg");
|
||||||
|
cmd.arg("-y");
|
||||||
|
|
||||||
|
if let Some(start) = start_time {
|
||||||
|
cmd.arg("-ss").arg(start);
|
||||||
|
}
|
||||||
|
cmd.arg("-i").arg(src);
|
||||||
|
|
||||||
|
if let Some(end) = end_time {
|
||||||
|
cmd.arg("-to").arg(end);
|
||||||
|
}
|
||||||
|
cmd.arg("-c").arg("copy").arg(dest);
|
||||||
|
|
||||||
|
cmd.stdout(Stdio::null()).stderr(Stdio::piped());
|
||||||
|
|
||||||
|
let output = cmd.output().await.map_err(|e| {
|
||||||
|
AppError::Internal(format!("Failed to execute ffmpeg: {}", e))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
return Err(AppError::Internal(format!("ffmpeg trim failed: {}", stderr)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finds the first file in a directory that matches a prefix
|
||||||
|
pub fn find_file_with_prefix(dir: &Path, prefix: &str) -> Option<PathBuf> {
|
||||||
|
if let Ok(entries) = std::fs::read_dir(dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let name_str = name.to_string_lossy();
|
||||||
|
if name_str.starts_with(prefix) && entry.path().is_file() {
|
||||||
|
return Some(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
144
backend/src/utils.rs
Normal file
144
backend/src/utils.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
use regex::Regex;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
static YT_V_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"[?&]v=([A-Za-z0-9_-]{11})").expect("valid regex")
|
||||||
|
});
|
||||||
|
static YT_SHORT_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"https?://youtu\.be/([A-Za-z0-9_-]{11})").expect("valid regex")
|
||||||
|
});
|
||||||
|
#[allow(dead_code)]
|
||||||
|
static IG_SHORTCODE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"/(?:p|reel|reels|tv)/([A-Za-z0-9_-]+)").expect("valid regex")
|
||||||
|
});
|
||||||
|
static SAFE_RE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r#"[\\/*?:"<>|]"#).expect("valid regex")
|
||||||
|
});
|
||||||
|
|
||||||
|
pub fn clean_yt_url(url: &str) -> String {
|
||||||
|
if let Some(caps) = YT_V_RE.captures(url) {
|
||||||
|
if let Some(id) = caps.get(1) {
|
||||||
|
return format!("https://www.youtube.com/watch?v={}", id.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(caps) = YT_SHORT_RE.captures(url) {
|
||||||
|
if let Some(id) = caps.get(1) {
|
||||||
|
return format!("https://youtu.be/{}", id.as_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
url.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clean_ig_url(url: &str) -> String {
|
||||||
|
url.split('?').next().unwrap_or(url).trim_end_matches('/').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_ig_profile_url(url: &str) -> bool {
|
||||||
|
let stripped = url
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_start_matches("www.")
|
||||||
|
.trim_start_matches("instagram.com")
|
||||||
|
.trim_start_matches('/');
|
||||||
|
|
||||||
|
let first_segment = stripped
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('?')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start_matches('@');
|
||||||
|
|
||||||
|
!matches!(
|
||||||
|
first_segment,
|
||||||
|
"p" | "reel" | "reels" | "tv" | "explore" | "stories" | "accounts" | ""
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn extract_ig_username(url: &str) -> Result<String, String> {
|
||||||
|
let stripped = url
|
||||||
|
.trim_start_matches("https://")
|
||||||
|
.trim_start_matches("http://")
|
||||||
|
.trim_start_matches("www.")
|
||||||
|
.trim_start_matches("instagram.com")
|
||||||
|
.trim_start_matches('/');
|
||||||
|
|
||||||
|
let username = stripped
|
||||||
|
.split('/')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.split('?')
|
||||||
|
.next()
|
||||||
|
.unwrap_or("")
|
||||||
|
.trim_start_matches('@')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
if username.is_empty() {
|
||||||
|
Err("could not extract username from URL".to_string())
|
||||||
|
} else {
|
||||||
|
Ok(username.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn extract_ig_shortcode(url: &str) -> Result<String, String> {
|
||||||
|
if let Some(caps) = IG_SHORTCODE_RE.captures(url) {
|
||||||
|
if let Some(sc) = caps.get(1) {
|
||||||
|
return Ok(sc.as_str().to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!(
|
||||||
|
"could not extract shortcode from: {} — expected a post, reel, or tv URL",
|
||||||
|
url
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn safe_filename(name: &str) -> String {
|
||||||
|
let sanitized = SAFE_RE.replace_all(name, "_");
|
||||||
|
let trimmed = sanitized.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
"file".to_string()
|
||||||
|
} else if trimmed.len() > 80 {
|
||||||
|
trimmed[..80].to_string()
|
||||||
|
} else {
|
||||||
|
trimmed.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_duration(seconds: Option<f64>) -> String {
|
||||||
|
match seconds {
|
||||||
|
Some(s) if s > 0.0 => {
|
||||||
|
let total = s as u64;
|
||||||
|
let h = total / 3600;
|
||||||
|
let m = (total % 3600) / 60;
|
||||||
|
let sec = total % 60;
|
||||||
|
if h > 0 {
|
||||||
|
format!("{}:{:02}:{:02}", h, m, sec)
|
||||||
|
} else {
|
||||||
|
format!("{}:{:02}", m, sec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => "0:00".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn parse_time_to_seconds(t: &str) -> Option<f64> {
|
||||||
|
let parts: Vec<&str> = t.trim().split(':').collect();
|
||||||
|
match parts.len() {
|
||||||
|
3 => {
|
||||||
|
let h = parts[0].parse::<f64>().ok()?;
|
||||||
|
let m = parts[1].parse::<f64>().ok()?;
|
||||||
|
let s = parts[2].parse::<f64>().ok()?;
|
||||||
|
Some(h * 3600.0 + m * 60.0 + s)
|
||||||
|
}
|
||||||
|
2 => {
|
||||||
|
let m = parts[0].parse::<f64>().ok()?;
|
||||||
|
let s = parts[1].parse::<f64>().ok()?;
|
||||||
|
Some(m * 60.0 + s)
|
||||||
|
}
|
||||||
|
1 => parts[0].parse::<f64>().ok(),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue