├── .DS_Store ├── src ├── .DS_Store ├── utils │ ├── subtitle │ │ ├── mod.rs │ │ └── subtitle_converter.rs │ ├── http.rs │ ├── mod.rs │ ├── platform.rs │ ├── url_expiry.rs │ ├── validation.rs │ ├── fs.rs │ └── retry.rs ├── model │ ├── utils │ │ ├── serde.rs │ │ └── mod.rs │ ├── thumbnail.rs │ ├── mod.rs │ ├── heatmap.rs │ ├── selector.rs │ ├── caption.rs │ └── playlist.rs ├── client │ ├── mod.rs │ ├── config.rs │ ├── proxy.rs │ ├── deps │ │ ├── youtube.rs │ │ └── mod.rs │ └── builder.rs ├── download │ ├── mod.rs │ ├── segment.rs │ ├── progress.rs │ ├── speed_profile.rs │ └── partial.rs ├── cache │ ├── mod.rs │ ├── backend │ │ ├── mod.rs │ │ └── memory.rs │ └── video.rs ├── prelude.rs ├── executor │ ├── mod.rs │ └── process.rs ├── metadata │ ├── postprocess.rs │ ├── mp4.rs │ ├── api.rs │ ├── mod.rs │ ├── base.rs │ ├── mp3.rs │ └── chapters.rs └── macros.rs ├── .gitignore ├── CONTRIBUTING.md ├── sonar-project.properties ├── .github ├── auto_assign.yml ├── dependabot.yml └── workflows │ ├── ci-prod.yml │ └── ci-dev.yml ├── SECURITY.md ├── Cargo.toml └── CLAUDE.md /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boul2gom/yt-dlp/HEAD/.DS_Store -------------------------------------------------------------------------------- /src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boul2gom/yt-dlp/HEAD/src/.DS_Store -------------------------------------------------------------------------------- /src/utils/subtitle/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod subtitle_converter; 2 | pub mod subtitle_validator; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | yt-dlp.iml 3 | 4 | Cargo.lock 5 | target/ 6 | 7 | libs/ 8 | temp/ 9 | 10 | example.json -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🤝 Contributing to the project 2 | 3 | Please refer to README.md and for information on how to contribute to the project. -------------------------------------------------------------------------------- /sonar-project.properties: -------------------------------------------------------------------------------- 1 | sonar.projectKey=boul2gom_yt-dlp 2 | sonar.organization=boul2gom 3 | 4 | # Encoding of the source code. Default is default system encoding 5 | sonar.sourceEncoding=UTF-8 -------------------------------------------------------------------------------- /src/model/utils/serde.rs: -------------------------------------------------------------------------------- 1 | //! Serde utilities for serializing and deserializing data. 2 | 3 | use serde::{Deserialize, Deserializer}; 4 | 5 | /// Fix issue with 'none' string in JSON. 6 | pub fn json_none<'de, D>(deserializer: D) -> Result, D::Error> 7 | where 8 | D: Deserializer<'de>, 9 | { 10 | let string: Option = Option::deserialize(deserializer)?; 11 | 12 | match string.as_deref() { 13 | Some("none") => Ok(None), 14 | _ => Ok(string), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Set to true to add reviewers to pull requests 2 | addReviewers: true 3 | 4 | # Set to true to add assignees to pull requests 5 | addAssignees: true 6 | 7 | # A list of reviewers to be added to pull requests (GitHub user name) 8 | reviewers: 9 | - boul2gom 10 | 11 | # A list of keywords to be skipped the process that add reviewers if pull requests include it 12 | skipKeywords: 13 | - wip 14 | - draft 15 | 16 | # A number of reviewers added to the pull request 17 | # Set 0 to add all the reviewers (default: 0) 18 | numberOfReviewers: 0 19 | -------------------------------------------------------------------------------- /src/client/mod.rs: -------------------------------------------------------------------------------- 1 | //! Youtube client module. 2 | //! 3 | //! This module provides the main Youtube client struct and related configuration types. 4 | 5 | pub mod builder; 6 | pub mod config; 7 | pub mod deps; 8 | pub mod download_builder; 9 | pub mod proxy; 10 | mod streams; 11 | 12 | pub use builder::YoutubeBuilder; 13 | pub use config::YoutubeConfig; 14 | pub use deps::{Libraries, LibraryInstaller}; 15 | pub use download_builder::DownloadBuilder; 16 | pub use proxy::{ProxyConfig, ProxyType}; 17 | 18 | // Re-export from root lib.rs (where Youtube is currently defined) 19 | // This maintains the code in one place while providing the new API structure 20 | pub use crate::Youtube; 21 | -------------------------------------------------------------------------------- /src/download/mod.rs: -------------------------------------------------------------------------------- 1 | //! Download orchestration module. 2 | //! 3 | //! This module handles all download operations including HTTP fetching, 4 | //! parallel segment downloads, and progress tracking. 5 | 6 | pub mod fetcher; 7 | pub mod manager; 8 | pub mod partial; 9 | pub mod postprocess; 10 | pub mod progress; 11 | pub mod segment; 12 | pub mod speed_profile; 13 | 14 | pub use fetcher::Fetcher; 15 | pub use manager::{DownloadManager, DownloadPriority, DownloadStatus, ManagerConfig}; 16 | pub use partial::PartialRange; 17 | pub use postprocess::{ 18 | AudioCodec, EncodingPreset, FfmpegFilter, PostProcessConfig, Resolution, VideoCodec, 19 | WatermarkPosition, 20 | }; 21 | pub use progress::ProgressTracker; 22 | pub use speed_profile::SpeedProfile; 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | time: "09:00" 8 | timezone: "Europe/Paris" 9 | reviewers: 10 | - "boul2gom" 11 | assignees: 12 | - "boul2gom" 13 | commit-message: 14 | prefix: "🦀 [Rust Crates] " 15 | target-branch: "develop" 16 | rebase-strategy: "auto" 17 | 18 | - package-ecosystem: "github-actions" 19 | directory: "/" 20 | schedule: 21 | interval: "daily" 22 | time: "09:00" 23 | timezone: "Europe/Paris" 24 | reviewers: 25 | - "boul2gom" 26 | assignees: 27 | - "boul2gom" 28 | commit-message: 29 | prefix: "🔌 [Github Actions] " 30 | target-branch: "develop" 31 | rebase-strategy: "auto" -------------------------------------------------------------------------------- /src/cache/mod.rs: -------------------------------------------------------------------------------- 1 | //! Cache module for storing video metadata and downloaded files. 2 | //! 3 | //! This module provides async-safe functionality for caching video metadata and downloaded files 4 | //! to avoid making repeated requests for the same videos and re-downloading the same files. 5 | //! 6 | //! Uses `sqlx` for fully async SQLite operations that do not block the tokio runtime. 7 | 8 | pub mod backend; 9 | pub mod files; 10 | pub mod playlist; 11 | pub mod video; 12 | 13 | // Re-export main types 14 | pub use files::DownloadCache; 15 | pub use playlist::PlaylistCache; 16 | pub use video::VideoCache; 17 | 18 | // Re-export common structures 19 | pub use playlist::CachedPlaylist; 20 | pub use video::{CachedFile, CachedThumbnail, CachedVideo}; 21 | 22 | // Common types and traits 23 | pub use crate::model::selector::{ 24 | AudioCodecPreference, AudioQuality, VideoCodecPreference, VideoQuality, 25 | }; 26 | -------------------------------------------------------------------------------- /src/model/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions for the model module. 2 | 3 | use std::clone::Clone; 4 | use std::cmp::{Eq, PartialEq}; 5 | use std::fmt::{Debug, Display}; 6 | use std::hash::Hash; 7 | 8 | pub mod serde; 9 | 10 | /// Trait that combines the basic traits that any structure should implement. 11 | /// 12 | /// This trait combines: 13 | /// - `Debug` for debug display 14 | /// - `Clone` for duplication 15 | /// - `PartialEq` for comparison 16 | /// - `Display` for formatted display 17 | pub trait CommonTraits: Debug + Clone + PartialEq + Display {} 18 | 19 | /// Trait that combines basic and advanced traits for a complete structure. 20 | /// 21 | /// This trait combines: 22 | /// - All traits from `CommonTraits` 23 | /// - `Eq` for total equality 24 | /// - `Hash` for hashing 25 | pub trait AllTraits: CommonTraits + Eq + Hash {} 26 | 27 | /// Automatic implementation of the `CommonTraits` trait for any type that implements 28 | /// the required traits. 29 | impl CommonTraits for T {} 30 | 31 | /// Automatic implementation of the `AllTraits` trait for any type that implements 32 | /// the required traits. 33 | impl AllTraits for T {} 34 | -------------------------------------------------------------------------------- /src/client/config.rs: -------------------------------------------------------------------------------- 1 | //! Configuration types for the Youtube client. 2 | 3 | use std::time::Duration; 4 | 5 | /// Configuration for the Youtube client 6 | #[derive(Debug, Clone)] 7 | pub struct YoutubeConfig { 8 | /// The arguments to pass to 'yt-dlp' 9 | pub args: Vec, 10 | /// The timeout for command execution 11 | pub timeout: Duration, 12 | } 13 | 14 | impl Default for YoutubeConfig { 15 | fn default() -> Self { 16 | Self { 17 | args: Vec::new(), 18 | timeout: Duration::from_secs(30), 19 | } 20 | } 21 | } 22 | 23 | impl YoutubeConfig { 24 | /// Creates a new configuration with default values 25 | pub fn new() -> Self { 26 | Self::default() 27 | } 28 | 29 | /// Sets the arguments to pass to yt-dlp 30 | pub fn with_args(mut self, args: Vec) -> Self { 31 | self.args = args; 32 | self 33 | } 34 | 35 | /// Adds a single argument to pass to yt-dlp 36 | pub fn add_arg(mut self, arg: impl Into) -> Self { 37 | self.args.push(arg.into()); 38 | self 39 | } 40 | 41 | /// Sets the timeout for command execution 42 | pub fn with_timeout(mut self, timeout: Duration) -> Self { 43 | self.timeout = timeout; 44 | self 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/download/segment.rs: -------------------------------------------------------------------------------- 1 | //! Segment download module. 2 | //! 3 | //! This module handles downloading individual segments of a file in parallel. 4 | 5 | use std::sync::Arc; 6 | use std::sync::atomic::{AtomicU64, Ordering}; 7 | use tokio::sync::Mutex; 8 | 9 | /// Context for segment download operations 10 | pub struct SegmentContext { 11 | pub file: Arc>, 12 | pub downloaded_bytes: Arc, 13 | pub progress_callback: Option>, 14 | pub total_bytes: u64, 15 | } 16 | 17 | impl SegmentContext { 18 | /// Creates a new segment context 19 | pub fn new( 20 | file: Arc>, 21 | total_bytes: u64, 22 | progress_callback: Option>, 23 | ) -> Self { 24 | Self { 25 | file, 26 | downloaded_bytes: Arc::new(AtomicU64::new(0)), 27 | progress_callback, 28 | total_bytes, 29 | } 30 | } 31 | 32 | /// Updates the progress 33 | pub fn update_progress(&self, bytes: u64) { 34 | let downloaded = self.downloaded_bytes.fetch_add(bytes, Ordering::Relaxed); 35 | if let Some(callback) = &self.progress_callback { 36 | callback(downloaded + bytes, self.total_bytes); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! Prelude module for convenient imports. 2 | //! 3 | //! This module re-exports the most commonly used types and traits, 4 | //! allowing users to import everything they need with a single use statement. 5 | //! 6 | //! # Examples 7 | //! 8 | //! ```rust 9 | //! use yt_dlp::prelude::*; 10 | //! ``` 11 | 12 | // Core types 13 | pub use crate::Youtube; 14 | pub use crate::YoutubeBuilder; 15 | pub use crate::error::{Error, Result}; 16 | 17 | // Client types (new architecture) 18 | pub use crate::client::{DownloadBuilder, Libraries, LibraryInstaller, YoutubeConfig}; 19 | 20 | // Download types (new architecture) 21 | pub use crate::download::{DownloadManager, DownloadPriority, DownloadStatus, ManagerConfig}; 22 | pub use crate::download::{Fetcher, ProgressTracker}; 23 | 24 | // Model types 25 | pub use crate::model::Video; 26 | pub use crate::model::selector::{ 27 | AudioCodecPreference, AudioQuality, VideoCodecPreference, VideoQuality, 28 | }; 29 | 30 | // Cache types (if enabled) 31 | #[cfg(feature = "cache")] 32 | pub use crate::cache::{DownloadCache, VideoCache}; 33 | 34 | // Utility types 35 | pub use crate::utils::platform::Platform; 36 | pub use crate::utils::retry::{RetryPolicy, is_http_error_retryable}; 37 | pub use crate::utils::validation::{sanitize_filename, sanitize_path, validate_youtube_url}; 38 | 39 | // Re-export common traits 40 | pub use crate::model::utils::{AllTraits, CommonTraits}; 41 | -------------------------------------------------------------------------------- /src/model/thumbnail.rs: -------------------------------------------------------------------------------- 1 | //! Thumbnails-related models. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::hash::{Hash, Hasher}; 6 | 7 | /// Represents a thumbnail of a YouTube video. 8 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 9 | pub struct Thumbnail { 10 | /// The URL of the thumbnail. 11 | pub url: String, 12 | /// The preference index of the thumbnail, e.g. '-35' or '0'. 13 | pub preference: i64, 14 | 15 | /// The ID of the thumbnail. 16 | pub id: String, 17 | /// The height of the thumbnail, can be `None`. 18 | pub height: Option, 19 | /// The width of the thumbnail, can be `None`. 20 | pub width: Option, 21 | /// The resolution of the thumbnail, can be `None`, e.g. '1920x1080'. 22 | pub resolution: Option, 23 | } 24 | 25 | // Implementation of the Display trait for Thumbnail 26 | impl fmt::Display for Thumbnail { 27 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 28 | write!( 29 | f, 30 | "Thumbnail(id={}, resolution={})", 31 | self.id, 32 | self.resolution.as_deref().unwrap_or("unknown") 33 | ) 34 | } 35 | } 36 | 37 | // Implementation of Eq for Thumbnail 38 | impl Eq for Thumbnail {} 39 | 40 | // Implementation of Hash for Thumbnail 41 | impl Hash for Thumbnail { 42 | fn hash(&self, state: &mut H) { 43 | self.id.hash(state); 44 | self.url.hash(state); 45 | self.preference.hash(state); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # 🔐 Reporting vulnerabilities and security issues 2 | 3 | ## Report a security issue 4 | 5 | The project team welcomes security reports and is committed to providing prompt attention to security issues. Security issues should be reported privately via [contact@boul2gom.com](mailto:contact@boul2gom.com). Security issues should not be reported via the public GitHub Issue tracker. 6 | 7 | ## Vulnerability coordination 8 | 9 | Remediation of security vulnerabilities is prioritized by the project team. The project team coordinates remediation with third-party project stakeholders via [GitHub Security Advisories](https://help.github.com/en/github/managing-security-vulnerabilities/about-github-security-advisories). Third-party stakeholders may include the reporter of the issue, affected direct or indirect users of yt-dlp, and maintainers of upstream dependencies if applicable. 10 | 11 | Downstream project maintainers and users can request participation in coordination of applicable security issues by sending your contact email address, GitHub username(s) and any other salient information to [contact@boul2gom.com](mailto:contact@boul2gom.com). Participation in security issue coordination processes is at the discretion of boul2gom, the project team, and the security issue reporter. 12 | 13 | ## Security advisories 14 | 15 | The project team is committed to transparency in the security issue disclosure process. The team announces security issues via README.md and the [RustSec advisory database](https://github.com/RustSec/advisory-db) (i.e. `cargo-audit`). -------------------------------------------------------------------------------- /src/utils/http.rs: -------------------------------------------------------------------------------- 1 | //! HTTP utilities and connection pooling. 2 | //! 3 | //! This module provides HTTP client utilities with connection pooling 4 | //! and optimal configuration for the library. 5 | 6 | use crate::client::proxy::ProxyConfig; 7 | use reqwest::Client; 8 | use std::sync::Arc; 9 | use std::time::Duration; 10 | 11 | // HTTP connection pool configuration 12 | const HTTP_POOL_IDLE_TIMEOUT_SECS: u64 = 90; 13 | const HTTP_POOL_MAX_IDLE_PER_HOST: usize = 32; 14 | const HTTP_TCP_KEEPALIVE_SECS: u64 = 60; 15 | const REQUEST_TIMEOUT_SECS: u64 = 60; 16 | 17 | /// Creates a new HTTP client with optimal pooling configuration 18 | /// 19 | /// # Arguments 20 | /// 21 | /// * `proxy` - Optional proxy configuration 22 | /// 23 | /// # Returns 24 | /// 25 | /// An Arc-wrapped HTTP client configured with connection pooling 26 | pub fn create_http_client(proxy: Option<&ProxyConfig>) -> Arc { 27 | let mut builder = Client::builder() 28 | .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) 29 | .pool_idle_timeout(Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS)) 30 | .pool_max_idle_per_host(HTTP_POOL_MAX_IDLE_PER_HOST) 31 | .tcp_keepalive(Duration::from_secs(HTTP_TCP_KEEPALIVE_SECS)) 32 | .user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); 33 | 34 | // Add proxy if configured 35 | if let Some(proxy_config) = proxy 36 | && let Ok(proxy) = proxy_config.to_reqwest_proxy() 37 | { 38 | builder = builder.proxy(proxy); 39 | } 40 | 41 | let client = builder.build().expect("Failed to build HTTP client"); 42 | 43 | Arc::new(client) 44 | } 45 | -------------------------------------------------------------------------------- /src/executor/mod.rs: -------------------------------------------------------------------------------- 1 | //! Command execution module. 2 | //! 3 | //! This module provides tools for executing commands with timeout support. 4 | 5 | pub mod process; 6 | 7 | pub use process::{ProcessOutput, execute_command}; 8 | 9 | use crate::error::Result; 10 | use std::path::PathBuf; 11 | use std::time::Duration; 12 | 13 | /// Represents a command executor. 14 | /// 15 | /// # Example 16 | /// 17 | /// ```rust,no_run 18 | /// # use yt_dlp::utils; 19 | /// # use std::path::PathBuf; 20 | /// # use std::time::Duration; 21 | /// # use yt_dlp::executor::Executor; 22 | /// # #[tokio::main] 23 | /// # async fn main() -> Result<(), Box> { 24 | /// let args = vec!["--update"]; 25 | /// 26 | /// let executor = Executor { 27 | /// executable_path: PathBuf::from("yt-dlp"), 28 | /// timeout: Duration::from_secs(30), 29 | /// args: utils::to_owned(args), 30 | /// }; 31 | /// 32 | /// let output = executor.execute().await?; 33 | /// println!("Output: {}", output.stdout); 34 | /// 35 | /// # Ok(()) 36 | /// # } 37 | /// ``` 38 | #[derive(Debug, Clone, PartialEq)] 39 | pub struct Executor { 40 | /// The path to the command executable. 41 | pub executable_path: PathBuf, 42 | /// The timeout for the process. 43 | pub timeout: Duration, 44 | /// The arguments to pass to the command. 45 | pub args: Vec, 46 | } 47 | 48 | impl Executor { 49 | /// Executes the command and returns the output. 50 | /// 51 | /// # Errors 52 | /// 53 | /// This function will return an error if the command could not be executed, or if the process timed out. 54 | pub async fn execute(&self) -> Result { 55 | execute_command(&self.executable_path, &self.args, self.timeout).await 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.github/workflows/ci-prod.yml: -------------------------------------------------------------------------------- 1 | name: Github CI - Production 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | inputs: 8 | tag: 9 | description: 'The tag of the release to analyze' 10 | required: false 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | sonarcloud-analysis: 17 | name: 🔍 SonarCloud analysis 18 | runs-on: ubuntu-latest 19 | 20 | steps: 21 | - name: 📁 Check out the repository 22 | uses: actions/checkout@v6.0.1 23 | with: 24 | ref: ${{ github.event.inputs.tag || github.ref }} 25 | 26 | - name: 🔍 Scan with SonarCloud 27 | uses: SonarSource/sonarcloud-github-action@v5.0.0 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 31 | 32 | scorecard-analysis: 33 | name: 🔍 Scorecard analysis 34 | runs-on: ubuntu-latest 35 | permissions: 36 | security-events: write 37 | id-token: write 38 | 39 | steps: 40 | - name: 📁 Check out the repository 41 | uses: actions/checkout@v6.0.1 42 | with: 43 | ref: ${{ github.event.inputs.tag || github.ref }} 44 | 45 | - name: 🔍 Scan with Scorecard 46 | uses: ossf/scorecard-action@v2.4.3 47 | with: 48 | results_file: results.sarif 49 | results_format: sarif 50 | publish_results: true 51 | 52 | - name: 📦 Upload Scorecard scan results artifact 53 | uses: actions/upload-artifact@v5.0.0 54 | with: 55 | name: sarif-results 56 | path: results.sarif 57 | retention-days: 5 58 | 59 | - name: 📦 Upload Scorecard scan results to GitHub dashboard 60 | uses: github/codeql-action/upload-sarif@v4 61 | with: 62 | sarif_file: results.sarif -------------------------------------------------------------------------------- /.github/workflows/ci-dev.yml: -------------------------------------------------------------------------------- 1 | name: Github CI - Development 2 | 3 | on: 4 | push: 5 | branches: [ develop, master ] 6 | pull_request: 7 | branches: [ develop, master ] 8 | types: [ opened, synchronize, reopened ] 9 | workflow_dispatch: 10 | 11 | permissions: 12 | contents: read 13 | 14 | env: 15 | CLICOLOR: 1 16 | 17 | jobs: 18 | setup-and-build: 19 | name: 🦀 Setup and build 20 | runs-on: ubuntu-latest 21 | 22 | steps: 23 | - name: 📁 Check out the repository 24 | uses: actions/checkout@v6.0.1 25 | 26 | - name: 📦 Cache Rust toolchain 27 | uses: Swatinem/rust-cache@v2 28 | with: 29 | prefix-key: rust-toolchain- 30 | 31 | - name: 🛠️ Set up Rust toolchain 32 | uses: actions-rs/toolchain@v1 33 | with: 34 | profile: minimal 35 | toolchain: stable 36 | components: rustfmt, clippy 37 | 38 | - name: ✅ Check with Clippy linter 39 | uses: actions-rs/cargo@v1 40 | with: 41 | command: clippy 42 | args: --all-features --all-targets -- -D warnings 43 | 44 | - name: 💄 Format code with Cargo formatter 45 | uses: actions-rs/cargo@v1 46 | with: 47 | command: fmt 48 | args: --all -- --check 49 | 50 | - name: 🦀 Install Rust dependencies and build library 51 | uses: actions-rs/cargo@v1 52 | with: 53 | command: build 54 | args: --all-features --all-targets 55 | 56 | check-spell: 57 | name: 📝 Check spelling 58 | runs-on: ubuntu-latest 59 | 60 | steps: 61 | - name: 📁 Check out the repository 62 | uses: actions/checkout@v6.0.1 63 | 64 | - name: 🛠️ Install dependencies 65 | run: | 66 | sudo apt-get update 67 | sudo apt-get install -y wget 68 | 69 | - name: 📝 Check spelling 70 | uses: crate-ci/typos@v1.40.0 -------------------------------------------------------------------------------- /src/download/progress.rs: -------------------------------------------------------------------------------- 1 | //! Progress tracking module. 2 | //! 3 | //! This module provides stream-based progress tracking for downloads. 4 | 5 | use tokio::sync::broadcast; 6 | use tokio_stream::wrappers::BroadcastStream; 7 | 8 | /// Progress information for a download 9 | #[derive(Debug, Clone, Copy)] 10 | pub struct ProgressInfo { 11 | /// Downloaded bytes 12 | pub downloaded: u64, 13 | /// Total bytes 14 | pub total: u64, 15 | } 16 | 17 | impl ProgressInfo { 18 | /// Creates a new progress info 19 | pub fn new(downloaded: u64, total: u64) -> Self { 20 | Self { downloaded, total } 21 | } 22 | 23 | /// Returns the progress as a percentage (0.0 to 1.0) 24 | pub fn percentage(&self) -> f64 { 25 | if self.total == 0 { 26 | 0.0 27 | } else { 28 | self.downloaded as f64 / self.total as f64 29 | } 30 | } 31 | } 32 | 33 | /// Progress tracker for downloads 34 | #[derive(Debug)] 35 | pub struct ProgressTracker { 36 | tx: broadcast::Sender, 37 | } 38 | 39 | impl ProgressTracker { 40 | /// Creates a new progress tracker 41 | pub fn new() -> Self { 42 | let (tx, _) = broadcast::channel(100); 43 | Self { tx } 44 | } 45 | 46 | /// Updates the progress 47 | pub fn update(&self, downloaded: u64, total: u64) { 48 | let _ = self.tx.send(ProgressInfo::new(downloaded, total)); 49 | } 50 | 51 | /// Creates a stream of progress updates 52 | pub fn stream(&self) -> BroadcastStream { 53 | BroadcastStream::new(self.tx.subscribe()) 54 | } 55 | 56 | /// Creates a callback function for progress updates 57 | pub fn callback(&self) -> impl Fn(u64, u64) + Send + Sync + 'static { 58 | let tx = self.tx.clone(); 59 | move |downloaded, total| { 60 | let _ = tx.send(ProgressInfo::new(downloaded, total)); 61 | } 62 | } 63 | } 64 | 65 | impl Default for ProgressTracker { 66 | fn default() -> Self { 67 | Self::new() 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/model/mod.rs: -------------------------------------------------------------------------------- 1 | //! The models used to represent the data fetched by 'yt-dlp'. 2 | //! 3 | //! The represented data is the video information, thumbnails, automatic captions, and formats. 4 | 5 | use serde::{Deserialize, Serialize}; 6 | use std::fmt; 7 | 8 | pub mod caption; 9 | pub mod chapter; 10 | pub mod format; 11 | pub mod heatmap; 12 | pub mod playlist; 13 | pub mod selector; 14 | pub mod thumbnail; 15 | pub mod utils; // Keep for traits 16 | pub mod video; 17 | 18 | // Re-export main types 19 | pub use video::Video; 20 | 21 | // Re-export chapter types 22 | pub use chapter::{ChapterList, ChapterValidation}; 23 | 24 | // Re-export selector types 25 | pub use selector::{AudioCodecPreference, AudioQuality, VideoCodecPreference, VideoQuality}; 26 | 27 | // Re-export utility traits 28 | pub use utils::{AllTraits, CommonTraits}; 29 | 30 | /// DRM status of a video or format 31 | #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] 32 | pub enum DrmStatus { 33 | Yes, 34 | No, 35 | Maybe, 36 | } 37 | 38 | impl<'de> Deserialize<'de> for DrmStatus { 39 | fn deserialize(deserializer: D) -> Result 40 | where 41 | D: serde::Deserializer<'de>, 42 | { 43 | struct DrmStatusVisitor; 44 | 45 | impl<'de> serde::de::Visitor<'de> for DrmStatusVisitor { 46 | type Value = DrmStatus; 47 | 48 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 49 | formatter.write_str("A boolean or the string \"maybe\"") 50 | } 51 | 52 | fn visit_bool(self, value: bool) -> Result 53 | where 54 | E: serde::de::Error, 55 | { 56 | Ok(if value { DrmStatus::Yes } else { DrmStatus::No }) 57 | } 58 | 59 | fn visit_str(self, value: &str) -> Result 60 | where 61 | E: serde::de::Error, 62 | { 63 | match value { 64 | "Yes" => Ok(DrmStatus::Yes), 65 | "No" => Ok(DrmStatus::No), 66 | "maybe" => Ok(DrmStatus::Maybe), 67 | _ => Err(E::custom(format!("Expected \"maybe\", got \"{}\"", value))), 68 | } 69 | } 70 | } 71 | 72 | deserializer.deserialize_any(DrmStatusVisitor) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "yt-dlp" 3 | edition = "2024" 4 | version = "1.4.7" 5 | 6 | authors = ["boul2gom "] 7 | description = "🎬️ A Rust library (with auto dependencies installation) for Youtube downloading" 8 | 9 | license = "GPL-3.0" 10 | readme = "README.md" 11 | documentation = "https://docs.rs/yt-dlp" 12 | homepage = "https://github.com/boul2gom/yt-dlp" 13 | repository = "https://github.com/boul2gom/yt-dlp" 14 | 15 | categories = ["api-bindings", "asynchronous", "multimedia", "multimedia::audio", "multimedia::video"] 16 | keywords = ["youtube", "downloader", "async", "yt-dlp", "youtube-dl"] 17 | 18 | [package.metadata.docs.rs] 19 | all-features = true 20 | 21 | [workspace.metadata.release] 22 | shared-version = true 23 | tag-message = "🔖 v{{version}}" 24 | pre-release-commit-message = "🚧 v{{version}}" 25 | pre-release-replacements = [ 26 | {file="README.md", search="yt-dlp = \"[^\"]*\"", replace="yt-dlp = \"{{version}}\"", exactly=1}, 27 | {file="README.md", search="yt-dlp = \\{ version = \"[^\"]*\", features = \\[\"tracing\"\\], default-features = false \\}", replace="yt-dlp = { version = \"{{version}}\", features = [\"tracing\"], default-features = false }", exactly=1}, 28 | ] 29 | 30 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 31 | 32 | [features] 33 | default = ["reqwest/default", "cache", "tracing"] 34 | cache = ["dep:sqlx"] 35 | tracing = ["dep:tracing"] 36 | rustls = ["reqwest/rustls-tls"] 37 | 38 | [dependencies] 39 | # Runtime and async dependencies 40 | tokio = { version = "1.48.0", features = ["rt-multi-thread", "macros", "fs", "time", "process"], default-features = false } 41 | tokio-stream = { version = "0.1", features = ["sync"] } 42 | tokio-util = "0.7" 43 | async-trait = "0.1" 44 | reqwest = { version = "0.12.24", features = ["json", "stream"], default-features = false } 45 | sqlx = { version = "0.8", optional = true, features = ["sqlite", "runtime-tokio"] } 46 | futures-util = "0.3.31" 47 | 48 | # Serialization dependencies 49 | serde = { version = "1.0.228", features = ["derive"] } 50 | serde_json = "1.0.145" 51 | 52 | # Error handling dependencies 53 | derive_more = { version = "2.0.1", features = ["constructor"] } 54 | thiserror = "2.0.17" 55 | 56 | # Audio and video dependencies 57 | mp4ameta = "0.13.0" 58 | id3 = "1.16.3" 59 | 60 | # Misc and compression dependencies 61 | ordered-float = { version = "5.1.0", features = ["serde"] } 62 | uuid = { version = "1.18.1", features = ["v4"] } 63 | url = "2.5" 64 | regex = "1.12.2" 65 | sha2 = "0.10.9" 66 | chrono = "0.4.42" 67 | tar = "0.4.44" 68 | zip = "6.0.0" 69 | xz2 = "0.1.7" 70 | rand = "0.9.2" 71 | 72 | # Logging dependencies 73 | tracing = { version = "0.1.41", optional = true } 74 | cfg-if = "1.0.4" 75 | serde_with = "3.16.0" 76 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! Utility functions and types used throughout the application. 2 | //! 3 | //! This module contains various utilities for file system operations, 4 | //! HTTP connections, retry logic, and validation. 5 | 6 | use crate::error::Result; 7 | use tokio::task::JoinHandle; 8 | 9 | pub mod fs; 10 | pub mod http; 11 | pub mod platform; 12 | pub mod retry; 13 | pub mod subtitle; 14 | pub mod url_expiry; 15 | pub mod validation; 16 | 17 | // Re-export commonly used functions from fs 18 | pub use fs::*; 19 | pub use platform::Platform; 20 | pub use subtitle::subtitle_converter::convert_subtitle; 21 | pub use subtitle::subtitle_validator::{ValidationResult, is_format_compatible, validate_subtitle}; 22 | pub use url_expiry::{ExpiryConfig, UrlStatus, check_download_error, should_refresh_url}; 23 | 24 | /// Converts a vector of string slices to a vector of owned strings. 25 | pub fn to_owned(vec: Vec>) -> Vec { 26 | vec.into_iter().map(|s| s.as_ref().to_owned()).collect() 27 | } 28 | 29 | /// Find the name of the executable for the given platform. 30 | pub fn find_executable(name: impl AsRef) -> String { 31 | let platform = Platform::detect(); 32 | 33 | match platform { 34 | Platform::Windows => format!("{}.exe", name.as_ref()), 35 | _ => name.as_ref().to_string(), 36 | } 37 | } 38 | 39 | /// Awaits two futures and returns a tuple of their results. 40 | /// If either future returns an error, the error is propagated. 41 | /// 42 | /// # Arguments 43 | /// 44 | /// * `first` - The first future to await. 45 | /// * `second` - The second future to await. 46 | pub async fn await_two( 47 | first: JoinHandle>, 48 | second: JoinHandle>, 49 | ) -> Result<(T, T)> { 50 | #[cfg(feature = "tracing")] 51 | tracing::debug!("Awaiting two futures"); 52 | 53 | let (first_result, second_result) = tokio::try_join!(first, second)?; 54 | 55 | let first = first_result?; 56 | let second = second_result?; 57 | 58 | Ok((first, second)) 59 | } 60 | 61 | /// Awaits all futures and returns a vector of their results. 62 | /// If any future returns an error, the error is propagated. 63 | /// 64 | /// # Arguments 65 | /// 66 | /// * `handles` - The futures to await. 67 | pub async fn await_all(handles: I) -> Result> 68 | where 69 | I: IntoIterator>> + std::fmt::Debug, 70 | T: Send + 'static, 71 | { 72 | #[cfg(feature = "tracing")] 73 | tracing::debug!("Awaiting multiple futures"); 74 | 75 | let results = futures_util::future::try_join_all(handles).await?; 76 | 77 | results.into_iter().collect() 78 | } 79 | 80 | /// A macro to mimic the ternary operator in Rust. 81 | #[macro_export] 82 | macro_rules! ternary { 83 | ($condition:expr, $true:expr, $false:expr) => { 84 | if $condition { $true } else { $false } 85 | }; 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/platform.rs: -------------------------------------------------------------------------------- 1 | //! Platform and architecture detection. 2 | 3 | use std::fmt; 4 | 5 | /// Represents the operating system where the program is running. 6 | #[derive(Clone, Debug)] 7 | pub enum Platform { 8 | /// The Windows operating system. 9 | Windows, 10 | /// The Linux operating system. 11 | Linux, 12 | /// The macOS operating system. 13 | Mac, 14 | 15 | /// An unknown operating system. 16 | Unknown(String), 17 | } 18 | 19 | /// Represents the architecture of the CPU where the program is running. 20 | #[derive(Clone, Debug)] 21 | pub enum Architecture { 22 | /// The x64 architecture. 23 | X64, 24 | /// The x86_64 architecture. 25 | X86, 26 | /// The ARMv7l architecture. 27 | Armv7l, 28 | /// The Aarch64 (Arm64) architecture. 29 | Aarch64, 30 | 31 | /// An unknown architecture. 32 | Unknown(String), 33 | } 34 | 35 | impl fmt::Display for Platform { 36 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 37 | match self { 38 | Platform::Windows => write!(f, "Windows"), 39 | Platform::Linux => write!(f, "Linux"), 40 | Platform::Mac => write!(f, "MacOS"), 41 | Platform::Unknown(os) => write!(f, "Unknown: {}", os), 42 | } 43 | } 44 | } 45 | 46 | impl fmt::Display for Architecture { 47 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 48 | match self { 49 | Architecture::X64 => write!(f, "x64"), 50 | Architecture::X86 => write!(f, "x86"), 51 | Architecture::Armv7l => write!(f, "armv7l"), 52 | Architecture::Aarch64 => write!(f, "aarch64"), 53 | Architecture::Unknown(arch) => write!(f, "Unknown: {}", arch), 54 | } 55 | } 56 | } 57 | 58 | impl Platform { 59 | /// Detects the current platform where the program is running. 60 | pub fn detect() -> Self { 61 | #[cfg(feature = "tracing")] 62 | tracing::debug!("Detecting current platform"); 63 | 64 | let os = std::env::consts::OS; 65 | 66 | #[cfg(feature = "tracing")] 67 | tracing::debug!("Detected platform: {}", os); 68 | 69 | match os { 70 | "windows" => Platform::Windows, 71 | "linux" => Platform::Linux, 72 | "macos" => Platform::Mac, 73 | _ => Platform::Unknown(os.to_string()), 74 | } 75 | } 76 | } 77 | 78 | impl Architecture { 79 | /// Detects the current architecture of the CPU where the program is running. 80 | pub fn detect() -> Self { 81 | #[cfg(feature = "tracing")] 82 | tracing::debug!("Detecting current architecture"); 83 | 84 | let arch = std::env::consts::ARCH; 85 | 86 | #[cfg(feature = "tracing")] 87 | tracing::debug!("Detected architecture: {}", arch); 88 | 89 | match arch { 90 | "x86_64" => Architecture::X64, 91 | "x86" => Architecture::X86, 92 | "armv7l" => Architecture::Armv7l, 93 | "aarch64" => Architecture::Aarch64, 94 | _ => Architecture::Unknown(arch.to_string()), 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/cache/backend/mod.rs: -------------------------------------------------------------------------------- 1 | //! Cache backend implementations. 2 | //! 3 | //! This module provides different backend implementations for caching video metadata and files. 4 | //! Each backend must implement the appropriate traits for video and file caching. 5 | 6 | use crate::cache::video::{CachedFile, CachedVideo}; 7 | use crate::error::Result; 8 | use crate::model::Video; 9 | use std::path::PathBuf; 10 | 11 | #[cfg(feature = "cache")] 12 | use crate::model::selector::{ 13 | AudioCodecPreference, AudioQuality, VideoCodecPreference, VideoQuality, 14 | }; 15 | 16 | pub mod memory; 17 | pub mod sqlite; 18 | 19 | /// Trait for video cache backend implementations. 20 | #[async_trait::async_trait] 21 | pub trait VideoBackend: Send + Sync { 22 | /// Create a new video cache backend. 23 | /// 24 | /// # Arguments 25 | /// 26 | /// * `cache_dir` - The directory where to store the cache. 27 | /// * `ttl` - The time-to-live for cache entries in seconds. 28 | async fn new(cache_dir: PathBuf, ttl: Option) -> Result 29 | where 30 | Self: Sized; 31 | 32 | /// Retrieve a video from the cache by its URL. 33 | async fn get(&self, url: &str) -> Result>; 34 | 35 | /// Store a video in the cache. 36 | async fn put(&self, url: String, video: Video) -> Result<()>; 37 | 38 | /// Remove a video from the cache by its URL. 39 | async fn remove(&self, url: &str) -> Result<()>; 40 | 41 | /// Clean expired entries from the cache. 42 | async fn clean(&self) -> Result<()>; 43 | 44 | /// Retrieve a video from the cache by its ID. 45 | async fn get_by_id(&self, id: &str) -> Result; 46 | } 47 | 48 | /// Trait for file cache backend implementations. 49 | #[async_trait::async_trait] 50 | pub trait FileBackend: Send + Sync { 51 | /// Create a new file cache backend. 52 | /// 53 | /// # Arguments 54 | /// 55 | /// * `cache_dir` - The directory where to store the cache. 56 | /// * `ttl` - The time-to-live for cache entries in seconds. 57 | async fn new(cache_dir: PathBuf, ttl: Option) -> Result 58 | where 59 | Self: Sized; 60 | 61 | /// Retrieve a file from the cache by its hash. 62 | async fn get_by_hash(&self, hash: &str) -> Option<(CachedFile, PathBuf)>; 63 | 64 | /// Retrieve a file from the cache by video ID and format ID. 65 | async fn get_by_video_and_format( 66 | &self, 67 | video_id: &str, 68 | format_id: &str, 69 | ) -> Option<(CachedFile, PathBuf)>; 70 | 71 | /// Retrieve a file from the cache by video ID and quality preferences. 72 | #[cfg(feature = "cache")] 73 | async fn get_by_video_and_preferences( 74 | &self, 75 | video_id: &str, 76 | video_quality: Option, 77 | audio_quality: Option, 78 | video_codec: Option, 79 | audio_codec: Option, 80 | ) -> Option<(CachedFile, PathBuf)>; 81 | 82 | /// Store a file in the cache. 83 | async fn put(&self, file: CachedFile, content: &[u8]) -> Result; 84 | 85 | /// Remove a file from the cache. 86 | async fn remove(&self, id: &str) -> Result<()>; 87 | 88 | /// Clean expired entries from the cache. 89 | async fn clean(&self) -> Result<()>; 90 | } 91 | -------------------------------------------------------------------------------- /src/model/heatmap.rs: -------------------------------------------------------------------------------- 1 | //! Heatmap-related models. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | 6 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 7 | pub struct Heatmap(pub Vec); 8 | 9 | /// Represents the complete heatmap data for a video. 10 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 11 | pub struct HeatmapPoint { 12 | /// The start time of this heatmap segment in seconds. 13 | pub start_time: f64, 14 | /// The end time of this heatmap segment in seconds. 15 | pub end_time: f64, 16 | /// The normalized engagement value for this segment (typically 0.0 to 1.0). 17 | /// Higher values indicate more viewer engagement (replays, watches). 18 | pub value: f64, 19 | } 20 | 21 | impl Heatmap { 22 | fn points(&self) -> &[HeatmapPoint] { 23 | &self.0 24 | } 25 | 26 | /// Returns the heatmap point with the highest engagement value. 27 | /// This represents the most replayed segment of the video. 28 | pub fn most_engaged_segment(&self) -> Option<&HeatmapPoint> { 29 | self.points().iter().max_by(|a, b| { 30 | a.value 31 | .partial_cmp(&b.value) 32 | .unwrap_or(std::cmp::Ordering::Equal) 33 | }) 34 | } 35 | 36 | /// Returns all heatmap points with an engagement value above the threshold. 37 | /// 38 | /// # Arguments 39 | /// 40 | /// * `threshold` - The minimum engagement value (0.0 to 1.0) 41 | /// 42 | /// # Returns 43 | /// 44 | /// A vector of references to highly engaged segments 45 | pub fn get_highly_engaged_segments(&self, threshold: f64) -> Vec<&HeatmapPoint> { 46 | self.points() 47 | .iter() 48 | .filter(|p| p.value >= threshold) 49 | .collect() 50 | } 51 | 52 | /// Returns the heatmap point at a specific timestamp. 53 | /// 54 | /// # Arguments 55 | /// 56 | /// * `timestamp` - The timestamp in seconds 57 | /// 58 | /// # Returns 59 | /// 60 | /// The heatmap point containing the timestamp, or None if no point matches 61 | pub fn get_point_at_time(&self, timestamp: f64) -> Option<&HeatmapPoint> { 62 | self.points() 63 | .iter() 64 | .find(|p| p.contains_timestamp(timestamp)) 65 | } 66 | 67 | /// Checks if the heatmap is empty. 68 | /// 69 | /// # Returns 70 | /// 71 | /// True if the heatmap is empty, false otherwise 72 | pub fn is_empty(&self) -> bool { 73 | self.0.is_empty() 74 | } 75 | } 76 | 77 | impl HeatmapPoint { 78 | /// Returns the duration of this heatmap segment in seconds. 79 | pub fn duration(&self) -> f64 { 80 | self.end_time - self.start_time 81 | } 82 | 83 | /// Checks if a given timestamp (in seconds) falls within this heatmap segment. 84 | pub fn contains_timestamp(&self, timestamp: f64) -> bool { 85 | timestamp >= self.start_time && timestamp < self.end_time 86 | } 87 | } 88 | 89 | // Implementation of the Display trait for HeatmapPoint 90 | impl fmt::Display for HeatmapPoint { 91 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 92 | write!( 93 | f, 94 | "HeatmapPoint(start={:.2}s, end={:.2}s, value={:.2})", 95 | self.start_time, self.end_time, self.value 96 | ) 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | You are an expert in Rust, async programming, and concurrent systems. 2 | 3 | Key Principles 4 | - All comments and docs must be in english. No french, in any comments or docs. 5 | - The functions documentation must describe it, with arguments, errors and returns. 6 | - Write clear, concise, and idiomatic Rust code with accurate examples. 7 | - Use async programming paradigms effectively, leveraging `tokio` for concurrency. 8 | - Prioritize modularity, clean code organization, and efficient resource management. 9 | - Use expressive variable names that convey intent (e.g., `is_ready`, `has_data`). 10 | - Adhere to Rust's naming conventions: snake_case for variables and functions, PascalCase for types and structs. 11 | - Avoid code duplication; use functions and modules to encapsulate reusable logic. 12 | - Write code with safety, concurrency, and performance in mind, embracing Rust's ownership and type system. 13 | 14 | Async Programming 15 | - Use `tokio` as the async runtime for handling asynchronous tasks and I/O. 16 | - Implement async functions using `async fn` syntax. 17 | - Leverage `tokio::spawn` for task spawning and concurrency. 18 | - Use `tokio::select!` for managing multiple async tasks and cancellations. 19 | - Favor structured concurrency: prefer scoped tasks and clean cancellation paths. 20 | - Implement timeouts, retries, and backoff strategies for robust async operations. 21 | 22 | Channels and Concurrency 23 | - Use Rust's `tokio::sync::mpsc` for asynchronous, multi-producer, single-consumer channels. 24 | - Use `tokio::sync::broadcast` for broadcasting messages to multiple consumers. 25 | - Implement `tokio::sync::oneshot` for one-time communication between tasks. 26 | - Prefer bounded channels for backpressure; handle capacity limits gracefully. 27 | - Use `tokio::sync::Mutex` and `tokio::sync::RwLock` for shared state across tasks, avoiding deadlocks. 28 | 29 | Error Handling and Safety 30 | - Embrace Rust's Result and Option types for error handling. 31 | - Use `?` operator to propagate errors in async functions. 32 | - Implement custom error types using `thiserror` or `anyhow` for more descriptive errors. 33 | - Handle errors and edge cases early, returning errors where appropriate. 34 | - Use `.await` responsibly, ensuring safe points for context switching. 35 | 36 | Testing 37 | - Write unit tests with `tokio::test` for async tests. 38 | - Use `tokio::time::pause` for testing time-dependent code without real delays. 39 | - Implement integration tests to validate async behavior and concurrency. 40 | - Use mocks and fakes for external dependencies in tests. 41 | 42 | Performance Optimization 43 | - Minimize async overhead; use sync code where async is not needed. 44 | - Avoid blocking operations inside async functions; offload to dedicated blocking threads if necessary. 45 | - Use `tokio::task::yield_now` to yield control in cooperative multitasking scenarios. 46 | - Optimize data structures and algorithms for async use, reducing contention and lock duration. 47 | - Use `tokio::time::sleep` and `tokio::time::interval` for efficient time-based operations. 48 | 49 | Key Conventions 50 | 1. Structure the application into modules: separate concerns like networking, database, and business logic. 51 | 2. Use environment variables for configuration management (e.g., `dotenv` crate). 52 | 3. Ensure code is well-documented with inline comments and Rustdoc. 53 | 54 | Async Ecosystem 55 | - Use `tokio` for async runtime and task management. 56 | - Leverage `reqwest` for async HTTP requests. 57 | - Use `serde` for serialization/deserialization. 58 | 59 | Refer to Rust's async book and `tokio` documentation for in-depth information on async patterns, best practices, and advanced features. -------------------------------------------------------------------------------- /src/model/selector.rs: -------------------------------------------------------------------------------- 1 | //! Format selector enumerations for audio and video formats. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Represents video quality preferences for format selection. 6 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 7 | pub enum VideoQuality { 8 | /// Best available video quality (highest resolution, fps, and bitrate) 9 | Best, 10 | /// High quality video (1080p or better if available) 11 | High, 12 | /// Medium quality video (720p if available) 13 | Medium, 14 | /// Low quality video (480p or lower) 15 | Low, 16 | /// Worst available video quality (lowest resolution, fps, and bitrate) 17 | Worst, 18 | /// Custom resolution with preference for specified height 19 | CustomHeight(u32), 20 | /// Custom resolution with preference for specified width 21 | CustomWidth(u32), 22 | } 23 | 24 | /// Represents audio quality preferences for format selection. 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] 26 | pub enum AudioQuality { 27 | /// Best available audio quality (highest bitrate and sample rate) 28 | Best, 29 | /// High quality audio (192kbps or better if available) 30 | High, 31 | /// Medium quality audio (128kbps if available) 32 | Medium, 33 | /// Low quality audio (96kbps or lower) 34 | Low, 35 | /// Worst available audio quality (lowest bitrate and sample rate) 36 | Worst, 37 | /// Custom audio with preference for specified bitrate in kbps 38 | CustomBitrate(u32), 39 | } 40 | 41 | /// Represents codec preferences for video format selection. 42 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 43 | pub enum VideoCodecPreference { 44 | /// Prefer VP9 codec 45 | VP9, 46 | /// Prefer AVC1/H.264 codec 47 | AVC1, 48 | /// Prefer AV01/AV1 codec 49 | AV1, 50 | /// Custom codec preference 51 | Custom(String), 52 | /// No specific codec preference 53 | Any, 54 | } 55 | 56 | /// Represents codec preferences for audio format selection. 57 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 58 | pub enum AudioCodecPreference { 59 | /// Prefer Opus codec 60 | Opus, 61 | /// Prefer AAC codec 62 | AAC, 63 | /// Prefer MP3 codec 64 | MP3, 65 | /// Custom codec preference 66 | Custom(String), 67 | /// No specific codec preference 68 | Any, 69 | } 70 | 71 | /// Helper function to check if a video codec matches the preference 72 | pub fn matches_video_codec(codec: &str, preference: &VideoCodecPreference) -> bool { 73 | let codec_lower = codec.to_lowercase(); 74 | match preference { 75 | VideoCodecPreference::VP9 => codec_lower.contains("vp9"), 76 | VideoCodecPreference::AVC1 => { 77 | codec_lower.contains("avc1") 78 | || codec_lower.contains("h264") 79 | || codec_lower.contains("h.264") 80 | } 81 | VideoCodecPreference::AV1 => codec_lower.contains("av1") || codec_lower.contains("av01"), 82 | VideoCodecPreference::Custom(custom) => codec_lower.contains(&custom.to_lowercase()), 83 | VideoCodecPreference::Any => true, 84 | } 85 | } 86 | 87 | /// Helper function to check if an audio codec matches the preference 88 | pub fn matches_audio_codec(codec: &str, preference: &AudioCodecPreference) -> bool { 89 | let codec_lower = codec.to_lowercase(); 90 | match preference { 91 | AudioCodecPreference::Opus => codec_lower.contains("opus"), 92 | AudioCodecPreference::AAC => codec_lower.contains("aac") || codec_lower.contains("mp4a"), 93 | AudioCodecPreference::MP3 => codec_lower.contains("mp3"), 94 | AudioCodecPreference::Custom(custom) => codec_lower.contains(&custom.to_lowercase()), 95 | AudioCodecPreference::Any => true, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/executor/process.rs: -------------------------------------------------------------------------------- 1 | //! Process execution and output handling. 2 | 3 | use crate::error::{Error, Result}; 4 | use std::path::PathBuf; 5 | use std::time::Duration; 6 | 7 | /// Represents the output of a process. 8 | #[derive(Debug, Clone, PartialEq)] 9 | pub struct ProcessOutput { 10 | /// The stdout of the process. 11 | pub stdout: String, 12 | /// The stderr of the process. 13 | pub stderr: String, 14 | /// The exit code of the process. 15 | pub code: i32, 16 | } 17 | 18 | /// Executes a command with the given arguments and timeout. 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `executable_path` - Path to the executable 23 | /// * `args` - Arguments to pass to the command 24 | /// * `timeout` - Maximum duration to wait for the process 25 | /// 26 | /// # Errors 27 | /// 28 | /// Returns an error if the command fails, times out, or cannot be executed 29 | pub async fn execute_command( 30 | executable_path: &PathBuf, 31 | args: &[String], 32 | timeout: Duration, 33 | ) -> Result { 34 | #[cfg(feature = "tracing")] 35 | tracing::debug!( 36 | "Executing command: {:?} with args: {:?}", 37 | executable_path, 38 | args 39 | ); 40 | 41 | let mut command = tokio::process::Command::new(executable_path); 42 | command.stdout(std::process::Stdio::piped()); 43 | command.stderr(std::process::Stdio::piped()); 44 | 45 | #[cfg(target_os = "windows")] 46 | { 47 | use std::os::windows::process::CommandExt; 48 | command.creation_flags(0x08000000); 49 | } 50 | 51 | command.args(args); 52 | let mut child = command.spawn()?; 53 | 54 | // Read stdout and stderr asynchronously 55 | let stdout_handle = child 56 | .stdout 57 | .take() 58 | .ok_or_else(|| Error::Unknown("Failed to capture stdout".to_string()))?; 59 | let stderr_handle = child 60 | .stderr 61 | .take() 62 | .ok_or_else(|| Error::Unknown("Failed to capture stderr".to_string()))?; 63 | 64 | // Create tasks to read stdout and stderr asynchronously 65 | let stdout_task = tokio::spawn(async move { 66 | let mut buffer = Vec::new(); 67 | tokio::io::copy(&mut tokio::io::BufReader::new(stdout_handle), &mut buffer).await?; 68 | Ok::, std::io::Error>(buffer) 69 | }); 70 | 71 | let stderr_task = tokio::spawn(async move { 72 | let mut buffer = Vec::new(); 73 | tokio::io::copy(&mut tokio::io::BufReader::new(stderr_handle), &mut buffer).await?; 74 | Ok::, std::io::Error>(buffer) 75 | }); 76 | 77 | // Wait for the process to finish with timeout 78 | let exit_status = match tokio::time::timeout(timeout, child.wait()).await { 79 | Ok(result) => result?, 80 | Err(_) => { 81 | #[cfg(feature = "tracing")] 82 | tracing::warn!("Process timed out after {:?}, killing it", timeout); 83 | 84 | if let Err(_e) = child.kill().await { 85 | #[cfg(feature = "tracing")] 86 | tracing::error!("Failed to kill process after timeout: {}", _e); 87 | } 88 | 89 | return Err(Error::Timeout { 90 | operation: format!("executing command: {}", executable_path.display()), 91 | duration: timeout, 92 | }); 93 | } 94 | }; 95 | 96 | // Get the results of the read tasks 97 | let stdout_result = match stdout_task.await { 98 | Ok(Ok(buffer)) => buffer, 99 | Ok(Err(e)) => return Err(Error::io("reading command stdout", e)), 100 | Err(e) => return Err(Error::runtime("reading command stdout task", e)), 101 | }; 102 | 103 | let stderr_result = match stderr_task.await { 104 | Ok(Ok(buffer)) => buffer, 105 | Ok(Err(e)) => return Err(Error::io("reading command stderr", e)), 106 | Err(e) => return Err(Error::runtime("reading command stderr task", e)), 107 | }; 108 | 109 | // Convert the buffers to Strings 110 | let stdout = String::from_utf8(stdout_result) 111 | .map_err(|_| Error::Unknown("Failed to parse stdout as UTF-8".to_string()))?; 112 | let stderr = String::from_utf8(stderr_result) 113 | .map_err(|_| Error::Unknown("Failed to parse stderr as UTF-8".to_string()))?; 114 | 115 | let code = exit_status.code().unwrap_or(-1); 116 | if exit_status.success() { 117 | return Ok(ProcessOutput { 118 | stdout, 119 | stderr, 120 | code, 121 | }); 122 | } 123 | 124 | Err(Error::CommandFailed { 125 | command: executable_path.display().to_string(), 126 | exit_code: code, 127 | stderr, 128 | }) 129 | } 130 | -------------------------------------------------------------------------------- /src/metadata/postprocess.rs: -------------------------------------------------------------------------------- 1 | //! Post-processing execution using FFmpeg. 2 | //! 3 | //! This module provides functions to apply post-processing operations 4 | //! to video files using FFmpeg based on PostProcessConfig. 5 | 6 | use crate::client::Libraries; 7 | use crate::download::postprocess::PostProcessConfig; 8 | use crate::error::{Error, Result}; 9 | use crate::executor::Executor; 10 | use std::path::{Path, PathBuf}; 11 | use std::time::Duration; 12 | 13 | /// Applies post-processing to a video file using FFmpeg. 14 | /// 15 | /// # Arguments 16 | /// 17 | /// * `input_path` - Path to the input video file 18 | /// * `output_path` - Path for the output processed file 19 | /// * `config` - Post-processing configuration 20 | /// * `libraries` - Libraries (for FFmpeg path) 21 | /// * `timeout` - Execution timeout 22 | /// 23 | /// # Errors 24 | /// 25 | /// Returns an error if FFmpeg execution fails 26 | /// 27 | /// # Returns 28 | /// 29 | /// The path to the processed video file 30 | pub async fn apply_postprocess( 31 | input_path: impl AsRef, 32 | output_path: impl AsRef, 33 | config: &PostProcessConfig, 34 | libraries: &Libraries, 35 | timeout: Duration, 36 | ) -> Result { 37 | if config.is_empty() { 38 | // No processing needed, just copy or return input 39 | return Ok(input_path.as_ref().to_path_buf()); 40 | } 41 | 42 | let input_str = input_path 43 | .as_ref() 44 | .to_str() 45 | .ok_or_else(|| Error::PathValidation { 46 | path: input_path.as_ref().to_path_buf(), 47 | reason: "Invalid UTF-8 in path".to_string(), 48 | })?; 49 | 50 | let output_str = output_path 51 | .as_ref() 52 | .to_str() 53 | .ok_or_else(|| Error::PathValidation { 54 | path: output_path.as_ref().to_path_buf(), 55 | reason: "Invalid UTF-8 in path".to_string(), 56 | })?; 57 | 58 | let args = build_ffmpeg_command(input_str, output_str, config)?; 59 | 60 | let executor = Executor { 61 | executable_path: libraries.ffmpeg.clone(), 62 | timeout, 63 | args, 64 | }; 65 | 66 | executor.execute().await?; 67 | Ok(output_path.as_ref().to_path_buf()) 68 | } 69 | 70 | /// Builds the FFmpeg command arguments from post-processing configuration. 71 | /// 72 | /// # Arguments 73 | /// 74 | /// * `input` - Input file path 75 | /// * `output` - Output file path 76 | /// * `config` - Post-processing configuration 77 | /// 78 | /// # Errors 79 | /// 80 | /// Returns an error if configuration is invalid 81 | /// 82 | /// # Returns 83 | /// 84 | /// Vector of FFmpeg arguments 85 | pub fn build_ffmpeg_command( 86 | input: &str, 87 | output: &str, 88 | config: &PostProcessConfig, 89 | ) -> Result> { 90 | let mut args = vec!["-i".to_string(), input.to_string()]; 91 | 92 | // Add video codec 93 | if let Some(ref video_codec) = config.video_codec { 94 | args.push("-c:v".to_string()); 95 | args.push(video_codec.to_ffmpeg_name().to_string()); 96 | } 97 | 98 | // Add audio codec 99 | if let Some(ref audio_codec) = config.audio_codec { 100 | args.push("-c:a".to_string()); 101 | args.push(audio_codec.to_ffmpeg_name().to_string()); 102 | } 103 | 104 | // Add video bitrate 105 | if let Some(ref bitrate) = config.video_bitrate { 106 | args.push("-b:v".to_string()); 107 | args.push(bitrate.clone()); 108 | } 109 | 110 | // Add audio bitrate 111 | if let Some(ref bitrate) = config.audio_bitrate { 112 | args.push("-b:a".to_string()); 113 | args.push(bitrate.clone()); 114 | } 115 | 116 | // Add framerate 117 | if let Some(fps) = config.framerate { 118 | args.push("-r".to_string()); 119 | args.push(fps.to_string()); 120 | } 121 | 122 | // Add preset 123 | if let Some(ref preset) = config.preset { 124 | args.push("-preset".to_string()); 125 | args.push(preset.to_ffmpeg_name().to_string()); 126 | } 127 | 128 | // Build video filter chain 129 | let mut filter_chain = Vec::new(); 130 | 131 | // Add resolution/scale filter 132 | if let Some(ref resolution) = config.resolution { 133 | filter_chain.push(format!("scale={}", resolution.to_ffmpeg_scale())); 134 | } 135 | 136 | // Add custom filters 137 | for filter in &config.filters { 138 | filter_chain.push(filter.to_ffmpeg_string()); 139 | } 140 | 141 | // Add filter chain to args 142 | if !filter_chain.is_empty() { 143 | args.push("-vf".to_string()); 144 | args.push(filter_chain.join(",")); 145 | } 146 | 147 | // Add output file 148 | args.push(output.to_string()); 149 | 150 | Ok(args) 151 | } 152 | -------------------------------------------------------------------------------- /src/metadata/mp4.rs: -------------------------------------------------------------------------------- 1 | //! M4A/MP4 metadata support using mp4ameta. 2 | //! 3 | //! This module provides functions to add metadata and thumbnails to M4A/MP4 files 4 | //! using the mp4ameta library. 5 | 6 | use crate::error::{Error, Result}; 7 | use crate::model::Video; 8 | use crate::model::format::Format; 9 | use mp4ameta::Tag as MP4Tag; 10 | use std::fmt::Debug; 11 | use std::fs; 12 | use std::path::Path; 13 | 14 | use super::{BaseMetadata, MetadataManager, PlaylistMetadata}; 15 | 16 | impl MetadataManager { 17 | /// Add metadata to an M4A/MP4 file using mp4ameta. 18 | /// 19 | /// M4A/MP4 metadata includes: Title, artist, album, genre (from tags), release year 20 | /// For MP4 files with video, technical metadata is also included. 21 | /// 22 | /// # Arguments 23 | /// 24 | /// * `file_path` - Path to the M4A/MP4 file 25 | /// * `video` - Video metadata to apply 26 | /// * `audio_format` - Optional audio format for technical metadata 27 | /// * `video_format` - Optional video format for technical metadata 28 | /// 29 | /// # Errors 30 | /// 31 | /// Returns an error if MP4 tags cannot be read or written 32 | pub(super) fn add_metadata_to_m4a + Debug + Copy>( 33 | file_path: P, 34 | video: &Video, 35 | audio_format: Option<&Format>, 36 | video_format: Option<&Format>, 37 | _playlist: Option<&PlaylistMetadata>, 38 | ) -> Result<()> { 39 | #[cfg(feature = "tracing")] 40 | tracing::trace!("Adding metadata to M4A/MP4 file: {:?}", file_path); 41 | 42 | Self::log_metadata_debug(format!("Adding metadata to M4A/MP4 file: {:?}", file_path)); 43 | 44 | // Load existing tag 45 | let mut tag = MP4Tag::read_from_path(file_path.as_ref()) 46 | .map_err(|e| Error::Unknown(format!("Failed to read MP4 tags: {}", e)))?; 47 | 48 | // Add basic metadata 49 | let metadata = Self::extract_basic_metadata(video); 50 | for (key, value) in metadata { 51 | match key.as_str() { 52 | "title" => tag.set_title(value), 53 | "artist" => tag.set_artist(value), 54 | "album" => tag.set_album(value), 55 | "album_artist" => tag.set_album_artist(value), 56 | "genre" => tag.set_genre(value), 57 | "year" => { 58 | if let Ok(year) = value.parse::() { 59 | tag.set_year(year.to_string()); 60 | } 61 | } 62 | _ => { 63 | Self::log_metadata_debug(format!("Skipping MP4 metadata: {} = {}", key, value)); 64 | } 65 | } 66 | } 67 | 68 | // MP4 format has limited metadata support compared to ID3 69 | if audio_format.is_some() || video_format.is_some() { 70 | Self::log_metadata_debug( 71 | "Format info available but MP4 tag has limited support for technical metadata", 72 | ); 73 | } 74 | 75 | // Save the changes 76 | tag.write_to_path(file_path.as_ref()) 77 | .map_err(|e| Error::Unknown(format!("Failed to write MP4 tags: {}", e)))?; 78 | 79 | Ok(()) 80 | } 81 | 82 | /// Add thumbnail to an M4A/MP4 file. 83 | /// 84 | /// # Arguments 85 | /// 86 | /// * `file_path` - Path to the M4A/MP4 file 87 | /// * `thumbnail_path` - Path to the thumbnail image 88 | /// 89 | /// # Errors 90 | /// 91 | /// Returns an error if the thumbnail cannot be read or the MP4 tags cannot be written 92 | pub(super) fn add_thumbnail_to_m4a + Debug + Copy>( 93 | file_path: P, 94 | thumbnail_path: &Path, 95 | ) -> Result<()> { 96 | #[cfg(feature = "tracing")] 97 | tracing::trace!("Adding thumbnail to M4A/MP4 file: {:?}", file_path); 98 | 99 | // Read the tag 100 | let mut tag = MP4Tag::read_from_path(file_path.as_ref()) 101 | .map_err(|e| Error::Unknown(format!("Failed to read MP4 tags: {}", e)))?; 102 | 103 | // Read the image file content 104 | let image_data = fs::read(thumbnail_path) 105 | .map_err(|e| Error::io_with_path("read thumbnail", thumbnail_path, e))?; 106 | 107 | // Determine image format from file extension 108 | let fmt = match thumbnail_path.extension().and_then(|ext| ext.to_str()) { 109 | Some("png") => mp4ameta::ImgFmt::Png, 110 | Some("jpg") | Some("jpeg") => mp4ameta::ImgFmt::Jpeg, 111 | Some("bmp") => mp4ameta::ImgFmt::Bmp, 112 | _ => mp4ameta::ImgFmt::Jpeg, 113 | }; 114 | 115 | // Create an Img object with the correct format 116 | let artwork = mp4ameta::Img::new(fmt, image_data); 117 | tag.set_artwork(artwork); 118 | 119 | // Write the tag back to the file 120 | tag.write_to_path(file_path.as_ref()) 121 | .map_err(|e| Error::Unknown(format!("Failed to write MP4 tags: {}", e)))?; 122 | 123 | #[cfg(feature = "tracing")] 124 | tracing::debug!("Added thumbnail to M4A/MP4 file: {:?}", file_path); 125 | 126 | Ok(()) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/macros.rs: -------------------------------------------------------------------------------- 1 | //! Convenience macros for common operations. 2 | //! 3 | //! This module provides macros that simplify common tasks when working with yt-dlp. 4 | 5 | /// Create a Youtube instance with sensible defaults. 6 | /// 7 | /// # Examples 8 | /// 9 | /// ```rust,no_run 10 | /// # use yt_dlp::youtube; 11 | /// # #[tokio::main] 12 | /// # async fn main() -> Result<(), Box> { 13 | /// let yt = youtube!("libs/yt-dlp", "libs/ffmpeg", "output").await?; 14 | /// # Ok(()) 15 | /// # } 16 | /// ``` 17 | #[macro_export] 18 | macro_rules! youtube { 19 | ($yt_dlp:expr, $ffmpeg:expr, $output:expr) => {{ 20 | let libraries = $crate::client::Libraries::new($yt_dlp, $ffmpeg); 21 | $crate::Youtube::builder(libraries, $output).build() 22 | }}; 23 | 24 | ($yt_dlp:expr, $ffmpeg:expr, $output:expr, cache: $cache:expr) => {{ 25 | let libraries = $crate::client::Libraries::new($yt_dlp, $ffmpeg); 26 | $crate::Youtube::builder(libraries, $output) 27 | .with_cache($cache) 28 | .build() 29 | }}; 30 | } 31 | 32 | /// Quick video download macro. 33 | /// 34 | /// # Examples 35 | /// 36 | /// ```rust,no_run 37 | /// # use yt_dlp::{download_video, prelude::*}; 38 | /// # #[tokio::main] 39 | /// # async fn main() -> Result<(), Box> { 40 | /// let yt = youtube!("libs/yt-dlp", "libs/ffmpeg", "output").await?; 41 | /// download_video!(yt, "https://youtube.com/watch?v=dQw4w9WgXcQ", "video.mp4").await?; 42 | /// # Ok(()) 43 | /// # } 44 | /// ``` 45 | #[macro_export] 46 | macro_rules! download_video { 47 | ($yt:expr, $url:expr, $output:expr) => {{ 48 | let video = $yt.fetch_video_infos($url).await?; 49 | $yt.download_video(&video, $output).await 50 | }}; 51 | 52 | ($yt:expr, $url:expr, $output:expr, quality: $quality:expr) => {{ 53 | $yt.download_video_with_quality( 54 | $url, 55 | $output, 56 | $quality, 57 | $crate::model::selector::VideoCodecPreference::Any, 58 | $crate::model::selector::AudioQuality::Best, 59 | $crate::model::selector::AudioCodecPreference::Any, 60 | ) 61 | .await 62 | }}; 63 | } 64 | 65 | /// Quick audio download macro. 66 | /// 67 | /// # Examples 68 | /// 69 | /// ```rust,no_run 70 | /// # use yt_dlp::{download_audio, prelude::*}; 71 | /// # #[tokio::main] 72 | /// # async fn main() -> Result<(), Box> { 73 | /// let yt = youtube!("libs/yt-dlp", "libs/ffmpeg", "output").await?; 74 | /// download_audio!(yt, "https://youtube.com/watch?v=dQw4w9WgXcQ", "audio.m4a").await?; 75 | /// # Ok(()) 76 | /// # } 77 | /// ``` 78 | #[macro_export] 79 | macro_rules! download_audio { 80 | ($yt:expr, $url:expr, $output:expr) => {{ 81 | let video = $yt.fetch_video_infos($url).await?; 82 | $yt.download_audio(&video, $output).await 83 | }}; 84 | 85 | ($yt:expr, $url:expr, $output:expr, quality: $quality:expr) => {{ 86 | $yt.download_audio_stream_with_quality( 87 | $url, 88 | $output, 89 | $quality, 90 | $crate::model::selector::AudioCodecPreference::Any, 91 | ) 92 | .await 93 | }}; 94 | } 95 | 96 | /// Configure yt-dlp arguments easily. 97 | /// 98 | /// # Examples 99 | /// 100 | /// ```rust,ignore 101 | /// let args = ytdlp_args![ 102 | /// "--no-playlist", 103 | /// "--extract-audio", 104 | /// format: "bestvideo+bestaudio" 105 | /// ]; 106 | /// ``` 107 | #[macro_export] 108 | macro_rules! ytdlp_args { 109 | ($($arg:expr),* $(,)?) => {{ 110 | vec![$($arg.to_string()),*] 111 | }}; 112 | 113 | ($($key:ident: $value:expr),* $(,)?) => {{ 114 | vec![$(format!("--{}={}", stringify!($key).replace('_', "-"), $value)),*] 115 | }}; 116 | } 117 | 118 | /// Create a Libraries instance with automatic binary installation. 119 | /// 120 | /// # Examples 121 | /// 122 | /// ```rust,no_run 123 | /// # use yt_dlp::install_libraries; 124 | /// # #[tokio::main] 125 | /// # async fn main() -> Result<(), Box> { 126 | /// let libs = install_libraries!("libs").await?; 127 | /// # Ok(()) 128 | /// # } 129 | /// ``` 130 | #[macro_export] 131 | macro_rules! install_libraries { 132 | ($dir:expr) => {{ 133 | use std::path::PathBuf; 134 | use $crate::client::Libraries; 135 | use $crate::client::deps::LibraryInstaller; 136 | 137 | let dir = PathBuf::from($dir); 138 | let yt_dlp = dir.join("yt-dlp"); 139 | let ffmpeg = dir.join("ffmpeg"); 140 | 141 | let libraries = Libraries::new(yt_dlp, ffmpeg); 142 | libraries.install(None).await?; 143 | 144 | Ok::(libraries) 145 | }}; 146 | 147 | ($dir:expr, token: $token:expr) => {{ 148 | use std::path::PathBuf; 149 | use $crate::client::Libraries; 150 | use $crate::client::deps::LibraryInstaller; 151 | 152 | let dir = PathBuf::from($dir); 153 | let yt_dlp = dir.join("yt-dlp"); 154 | let ffmpeg = dir.join("ffmpeg"); 155 | 156 | let libraries = Libraries::new(yt_dlp, ffmpeg); 157 | libraries.install(Some($token)).await?; 158 | 159 | Ok::(libraries) 160 | }}; 161 | } 162 | -------------------------------------------------------------------------------- /src/utils/url_expiry.rs: -------------------------------------------------------------------------------- 1 | //! URL expiry detection and handling utilities. 2 | //! 3 | //! This module provides functionality to detect when URLs (for videos, audio, subtitles) 4 | //! have expired and need to be refreshed from yt-dlp. 5 | 6 | use crate::error::Error; 7 | use reqwest::StatusCode; 8 | 9 | /// Represents the result of a URL expiry check. 10 | #[derive(Debug, Clone, PartialEq)] 11 | pub enum UrlStatus { 12 | /// The URL is valid and accessible 13 | Valid, 14 | /// The URL has expired and needs to be refreshed 15 | Expired(ExpiredReason), 16 | /// The URL status could not be determined 17 | Unknown, 18 | } 19 | 20 | /// Reasons why a URL might be considered expired. 21 | #[derive(Debug, Clone, PartialEq)] 22 | pub enum ExpiredReason { 23 | /// HTTP 403 Forbidden - typically means the temporary URL has expired 24 | Forbidden, 25 | /// HTTP 404 Not Found - resource no longer exists at this URL 26 | NotFound, 27 | /// HTTP 410 Gone - resource explicitly marked as permanently deleted 28 | Gone, 29 | /// HTTP 401 Unauthorized - authentication/authorization failed 30 | Unauthorized, 31 | /// Other expiry-related error 32 | Other(String), 33 | } 34 | 35 | impl UrlStatus { 36 | /// Checks if the URL is expired. 37 | pub fn is_expired(&self) -> bool { 38 | matches!(self, UrlStatus::Expired(_)) 39 | } 40 | 41 | /// Gets the expiry reason if the URL is expired. 42 | pub fn expired_reason(&self) -> Option<&ExpiredReason> { 43 | match self { 44 | UrlStatus::Expired(reason) => Some(reason), 45 | _ => None, 46 | } 47 | } 48 | } 49 | 50 | /// Check if an HTTP status code indicates an expired URL. 51 | /// 52 | /// # Arguments 53 | /// 54 | /// * `status` - The HTTP status code to check 55 | /// 56 | /// # Returns 57 | /// 58 | /// Returns `UrlStatus::Expired` if the status indicates expiry, `UrlStatus::Valid` otherwise 59 | pub fn check_http_status(status: StatusCode) -> UrlStatus { 60 | match status { 61 | StatusCode::FORBIDDEN => UrlStatus::Expired(ExpiredReason::Forbidden), 62 | StatusCode::NOT_FOUND => UrlStatus::Expired(ExpiredReason::NotFound), 63 | StatusCode::GONE => UrlStatus::Expired(ExpiredReason::Gone), 64 | StatusCode::UNAUTHORIZED => UrlStatus::Expired(ExpiredReason::Unauthorized), 65 | _ if status.is_success() => UrlStatus::Valid, 66 | _ => UrlStatus::Unknown, 67 | } 68 | } 69 | 70 | /// Check if a reqwest error indicates an expired URL. 71 | /// 72 | /// # Arguments 73 | /// 74 | /// * `error` - The reqwest error to analyze 75 | /// 76 | /// # Returns 77 | /// 78 | /// Returns `UrlStatus::Expired` if the error indicates expiry 79 | pub fn check_error(error: &reqwest::Error) -> UrlStatus { 80 | if let Some(status) = error.status() { 81 | check_http_status(status) 82 | } else { 83 | UrlStatus::Unknown 84 | } 85 | } 86 | 87 | /// Check if a crate Error indicates an expired URL. 88 | /// 89 | /// # Arguments 90 | /// 91 | /// * `error` - The Error to analyze 92 | /// 93 | /// # Returns 94 | /// 95 | /// Returns `UrlStatus::Expired` if the error indicates expiry 96 | pub fn check_download_error(error: &Error) -> UrlStatus { 97 | match error { 98 | Error::Http { source, .. } => check_error(source), 99 | _ => UrlStatus::Unknown, 100 | } 101 | } 102 | 103 | /// Determine if a URL should be refreshed based on the error. 104 | /// 105 | /// # Arguments 106 | /// 107 | /// * `error` - The error that occurred during download/access 108 | /// 109 | /// # Returns 110 | /// 111 | /// Returns `true` if the URL should be refreshed from yt-dlp 112 | pub fn should_refresh_url(error: &Error) -> bool { 113 | check_download_error(error).is_expired() 114 | } 115 | 116 | /// Determine if an HTTP error should trigger URL refresh. 117 | /// 118 | /// # Arguments 119 | /// 120 | /// * `error` - The reqwest error that occurred 121 | /// 122 | /// # Returns 123 | /// 124 | /// Returns `true` if the URL should be refreshed 125 | pub fn should_refresh_url_from_http_error(error: &reqwest::Error) -> bool { 126 | check_error(error).is_expired() 127 | } 128 | 129 | /// Configuration for URL expiry handling. 130 | #[derive(Debug, Clone)] 131 | pub struct ExpiryConfig { 132 | /// Maximum number of refresh attempts before giving up 133 | pub max_refresh_attempts: usize, 134 | /// Whether to automatically refresh URLs when they expire 135 | pub auto_refresh: bool, 136 | } 137 | 138 | impl Default for ExpiryConfig { 139 | fn default() -> Self { 140 | Self { 141 | max_refresh_attempts: 2, 142 | auto_refresh: true, 143 | } 144 | } 145 | } 146 | 147 | impl ExpiryConfig { 148 | /// Creates a new expiry configuration. 149 | pub fn new() -> Self { 150 | Self::default() 151 | } 152 | 153 | /// Sets the maximum number of refresh attempts. 154 | pub fn with_max_refresh_attempts(mut self, attempts: usize) -> Self { 155 | self.max_refresh_attempts = attempts; 156 | self 157 | } 158 | 159 | /// Sets whether to automatically refresh expired URLs. 160 | pub fn with_auto_refresh(mut self, enabled: bool) -> Self { 161 | self.auto_refresh = enabled; 162 | self 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/download/speed_profile.rs: -------------------------------------------------------------------------------- 1 | //! Speed profiles for download optimization. 2 | //! 3 | //! This module provides different speed profiles that automatically configure 4 | //! download parameters based on the user's bandwidth and use case. 5 | 6 | use std::fmt; 7 | 8 | /// Download speed profile 9 | /// 10 | /// Different profiles optimize download parameters for various network conditions 11 | /// and use cases. Each profile adjusts concurrent downloads, parallel segments, 12 | /// segment size, and buffer size to match the expected bandwidth. 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] 14 | pub enum SpeedProfile { 15 | /// Conservative profile for slower connections (< 50 Mbps) 16 | /// 17 | /// Best for: 18 | /// - Standard internet connections 19 | /// - Avoiding network congestion 20 | /// - Limited bandwidth scenarios 21 | Conservative, 22 | 23 | /// Balanced profile for medium-speed connections (50-500 Mbps) 24 | /// 25 | /// Best for: 26 | /// - Most modern internet connections 27 | /// - General use cases 28 | /// - Balance between speed and resource usage 29 | #[default] 30 | Balanced, 31 | 32 | /// Aggressive profile for high-speed connections (> 500 Mbps) 33 | /// 34 | /// Best for: 35 | /// - High-bandwidth connections (fiber, gigabit) 36 | /// - Maximizing download speed 37 | /// - Systems with ample resources 38 | Aggressive, 39 | } 40 | 41 | impl fmt::Display for SpeedProfile { 42 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 | match self { 44 | Self::Conservative => write!(f, "Conservative"), 45 | Self::Balanced => write!(f, "Balanced"), 46 | Self::Aggressive => write!(f, "Aggressive"), 47 | } 48 | } 49 | } 50 | 51 | impl SpeedProfile { 52 | /// Get the maximum number of concurrent downloads for this profile 53 | pub fn max_concurrent_downloads(&self) -> usize { 54 | match self { 55 | Self::Conservative => 3, 56 | Self::Balanced => 5, 57 | Self::Aggressive => 8, 58 | } 59 | } 60 | 61 | /// Get the segment size in bytes for this profile 62 | pub fn segment_size(&self) -> usize { 63 | match self { 64 | Self::Conservative => 5 * 1024 * 1024, // 5 MB 65 | Self::Balanced => 8 * 1024 * 1024, // 8 MB 66 | Self::Aggressive => 10 * 1024 * 1024, // 10 MB 67 | } 68 | } 69 | 70 | /// Get the number of parallel segments per download for this profile 71 | pub fn parallel_segments(&self) -> usize { 72 | match self { 73 | Self::Conservative => 4, 74 | Self::Balanced => 8, 75 | Self::Aggressive => 12, 76 | } 77 | } 78 | 79 | /// Get the maximum buffer size in bytes for this profile 80 | pub fn max_buffer_size(&self) -> usize { 81 | match self { 82 | Self::Conservative => 10 * 1024 * 1024, // 10 MB 83 | Self::Balanced => 20 * 1024 * 1024, // 20 MB 84 | Self::Aggressive => 30 * 1024 * 1024, // 30 MB 85 | } 86 | } 87 | 88 | /// Get the maximum parallel segments for large files (> 2 GB) 89 | /// 90 | /// This is used by the dynamic segment calculation in Fetcher 91 | pub fn max_parallel_segments_for_large_files(&self) -> usize { 92 | match self { 93 | Self::Conservative => 24, 94 | Self::Balanced => 32, 95 | Self::Aggressive => 48, 96 | } 97 | } 98 | 99 | /// Calculate optimal number of segments based on file size and profile 100 | /// 101 | /// # Arguments 102 | /// 103 | /// * `file_size` - The total size of the file in bytes 104 | /// * `segment_size` - The size of each segment in bytes 105 | /// 106 | /// # Returns 107 | /// 108 | /// The optimal number of parallel segments for this file size and profile 109 | pub fn calculate_optimal_segments(&self, file_size: u64, segment_size: u64) -> usize { 110 | let total_segments = file_size.div_ceil(segment_size); 111 | let file_size_mb = file_size / (1024 * 1024); 112 | 113 | let max_parallel_segments = match self { 114 | Self::Conservative => match file_size_mb { 115 | size if size < 10 => 1, 116 | size if size < 50 => 2, 117 | size if size < 100 => 4, 118 | size if size < 500 => 8, 119 | size if size < 1000 => 12, 120 | size if size < 2000 => 16, 121 | _ => 24, 122 | }, 123 | Self::Balanced => match file_size_mb { 124 | size if size < 10 => 2, 125 | size if size < 50 => 4, 126 | size if size < 100 => 8, 127 | size if size < 500 => 12, 128 | size if size < 1000 => 16, 129 | size if size < 2000 => 24, 130 | _ => 32, 131 | }, 132 | Self::Aggressive => match file_size_mb { 133 | size if size < 10 => 4, 134 | size if size < 50 => 8, 135 | size if size < 100 => 12, 136 | size if size < 500 => 16, 137 | size if size < 1000 => 24, 138 | size if size < 2000 => 32, 139 | _ => 48, 140 | }, 141 | }; 142 | 143 | std::cmp::min(total_segments as usize, max_parallel_segments) 144 | } 145 | 146 | /// Get the maximum number of concurrent downloads for playlists 147 | /// 148 | /// This limits how many videos can be downloaded simultaneously in a playlist 149 | pub fn max_playlist_concurrent_downloads(&self) -> usize { 150 | match self { 151 | Self::Conservative => 2, 152 | Self::Balanced => 3, 153 | Self::Aggressive => 5, 154 | } 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/metadata/api.rs: -------------------------------------------------------------------------------- 1 | //! Public API methods for metadata management. 2 | //! 3 | //! This module provides the high-level public API for adding metadata 4 | //! and thumbnails to downloaded files. 5 | 6 | use crate::error::Result; 7 | use crate::model::Video; 8 | use crate::model::format::Format; 9 | use std::fmt::Debug; 10 | use std::path::Path; 11 | 12 | use super::MetadataManager; 13 | 14 | impl MetadataManager { 15 | /// Add metadata to a file based on its format. 16 | /// 17 | /// This method automatically detects the file format and applies appropriate metadata. 18 | /// Use this for standalone files when you don't have format details. 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `file_path` - Path to the file to add metadata to 23 | /// * `video` - Video metadata to apply 24 | /// 25 | /// # Errors 26 | /// 27 | /// Returns an error if the file format is unsupported or if metadata writing fails 28 | pub async fn add_metadata( 29 | file_path: impl AsRef + Send + Sync, 30 | video: &Video, 31 | ) -> Result<()> { 32 | #[cfg(feature = "tracing")] 33 | tracing::trace!("Adding metadata to file: {:?}", file_path.as_ref()); 34 | 35 | let file_format = Self::get_file_extension(file_path.as_ref())?; 36 | 37 | match file_format.as_str() { 38 | "mp3" => Self::add_metadata_to_mp3(file_path.as_ref(), video, None, None), 39 | "m4a" | "m4b" | "m4p" | "m4v" | "mp4" => { 40 | Self::add_metadata_to_m4a(file_path.as_ref(), video, None, None, None) 41 | } 42 | "webm" | "mkv" => { 43 | Self::add_metadata_to_webm(file_path.as_ref(), video, None, None, None).await 44 | } 45 | _ => { 46 | Self::add_ffmpeg_metadata(file_path.as_ref(), video, &file_format, None, None, None) 47 | .await 48 | } 49 | } 50 | } 51 | 52 | /// Add metadata to a file with format details for audio and video. 53 | /// 54 | /// This method should be used when you have detailed format information, 55 | /// typically for combined audio+video files. Technical metadata (resolution, 56 | /// codecs, bitrates) will be included for MP4 and WebM formats. 57 | /// 58 | /// # Arguments 59 | /// 60 | /// * `file_path` - Path to the file to add metadata to 61 | /// * `video` - Video metadata to apply 62 | /// * `video_format` - Optional video format details (for technical metadata) 63 | /// * `audio_format` - Optional audio format details (for technical metadata) 64 | /// 65 | /// # Errors 66 | /// 67 | /// Returns an error if the file format is unsupported or if metadata writing fails 68 | pub async fn add_metadata_with_format( 69 | file_path: impl AsRef, 70 | video: &Video, 71 | video_format: Option<&Format>, 72 | audio_format: Option<&Format>, 73 | ) -> Result<()> { 74 | #[cfg(feature = "tracing")] 75 | tracing::trace!( 76 | "Adding metadata with format to file: {:?}", 77 | file_path.as_ref() 78 | ); 79 | 80 | let file_format = Self::get_file_extension(file_path.as_ref())?; 81 | 82 | match file_format.as_str() { 83 | "mp3" => Self::add_metadata_to_mp3(file_path.as_ref(), video, audio_format, None), 84 | "m4a" | "m4b" | "m4p" | "m4v" | "mp4" => Self::add_metadata_to_m4a( 85 | file_path.as_ref(), 86 | video, 87 | audio_format, 88 | video_format, 89 | None, 90 | ), 91 | "webm" | "mkv" => { 92 | Self::add_metadata_to_webm( 93 | file_path.as_ref(), 94 | video, 95 | video_format, 96 | audio_format, 97 | None, 98 | ) 99 | .await 100 | } 101 | _ => { 102 | Self::add_ffmpeg_metadata( 103 | file_path.as_ref(), 104 | video, 105 | &file_format, 106 | video_format, 107 | audio_format, 108 | None, 109 | ) 110 | .await 111 | } 112 | } 113 | } 114 | 115 | /// Add a thumbnail to a file based on its format. 116 | /// 117 | /// Thumbnails are embedded in the file metadata. Supported formats: MP3, M4A, MP4, WebM, MKV 118 | /// 119 | /// # Arguments 120 | /// 121 | /// * `file_path` - Path to the file to add thumbnail to 122 | /// * `thumbnail_path` - Path to the thumbnail image file 123 | /// 124 | /// # Errors 125 | /// 126 | /// Returns an error if the file format doesn't support thumbnails or if embedding fails 127 | pub async fn add_thumbnail_to_file( 128 | file_path: impl AsRef + Debug + Copy, 129 | thumbnail_path: impl AsRef, 130 | ) -> Result<()> { 131 | #[cfg(feature = "tracing")] 132 | tracing::trace!("Adding thumbnail to file: {:?}", file_path.as_ref()); 133 | 134 | let file_format = Self::get_file_extension(file_path.as_ref())?; 135 | 136 | match file_format.as_str() { 137 | "mp3" => Self::add_thumbnail_to_mp3(file_path.as_ref(), thumbnail_path.as_ref()), 138 | "m4a" | "m4b" | "m4p" | "m4v" | "mp4" => { 139 | Self::add_thumbnail_to_m4a(file_path.as_ref(), thumbnail_path.as_ref()) 140 | } 141 | "webm" | "mkv" => { 142 | Self::add_thumbnail_to_webm(file_path.as_ref(), thumbnail_path.as_ref()).await 143 | } 144 | _ => { 145 | #[cfg(feature = "tracing")] 146 | tracing::debug!("Thumbnails not supported for file format: {}", file_format); 147 | Ok(()) 148 | } 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/model/caption.rs: -------------------------------------------------------------------------------- 1 | //! Captions-related models. 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::fmt; 5 | use std::hash::{Hash, Hasher}; 6 | 7 | /// Represents an automatic caption of a YouTube video. 8 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 9 | pub struct AutomaticCaption { 10 | /// The extension of the caption file. 11 | #[serde(rename = "ext")] 12 | pub extension: Extension, 13 | /// The URL of the caption file. 14 | pub url: String, 15 | /// The language of the caption file, e.g. 'English'. 16 | pub name: Option, 17 | } 18 | 19 | /// The available extensions for automatic caption files. 20 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 21 | #[serde(rename_all = "snake_case")] 22 | pub enum Extension { 23 | /// The JSON extension. 24 | Json, 25 | Json3, 26 | /// The Srv1 extension. 27 | Srv1, 28 | /// The Srv2 extension. 29 | Srv2, 30 | /// The Srv3 extension. 31 | Srv3, 32 | /// The Ttml extension. 33 | Ttml, 34 | /// The Vtt extension. 35 | Vtt, 36 | /// The Srt extension. 37 | Srt, 38 | /// The ASS (Advanced SubStation Alpha) extension. 39 | Ass, 40 | /// The SSA (SubStation Alpha) extension. 41 | Ssa, 42 | } 43 | 44 | // Implementation of the Display trait for AutomaticCaption 45 | impl fmt::Display for AutomaticCaption { 46 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 47 | write!( 48 | f, 49 | "Caption(lang={}, ext={:?})", 50 | self.name.as_deref().unwrap_or("unknown"), 51 | self.extension 52 | ) 53 | } 54 | } 55 | 56 | // Implementation of Eq for AutomaticCaption 57 | impl Eq for AutomaticCaption {} 58 | 59 | // Implementation of Hash for AutomaticCaption 60 | impl Hash for AutomaticCaption { 61 | fn hash(&self, state: &mut H) { 62 | self.url.hash(state); 63 | self.name.hash(state); 64 | std::mem::discriminant(&self.extension).hash(state); 65 | } 66 | } 67 | 68 | // Implementation of the Display trait for Extension 69 | impl fmt::Display for Extension { 70 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 71 | match self { 72 | Extension::Json => write!(f, "json"), 73 | Extension::Json3 => write!(f, "json3"), 74 | Extension::Srv1 => write!(f, "srv1"), 75 | Extension::Srv2 => write!(f, "srv2"), 76 | Extension::Srv3 => write!(f, "srv3"), 77 | Extension::Ttml => write!(f, "ttml"), 78 | Extension::Vtt => write!(f, "vtt"), 79 | Extension::Srt => write!(f, "srt"), 80 | Extension::Ass => write!(f, "ass"), 81 | Extension::Ssa => write!(f, "ssa"), 82 | } 83 | } 84 | } 85 | 86 | // Implementation of Eq for Extension 87 | impl Eq for Extension {} 88 | 89 | // Implementation of Hash for Extension 90 | impl Hash for Extension { 91 | fn hash(&self, state: &mut H) { 92 | std::mem::discriminant(self).hash(state); 93 | } 94 | } 95 | 96 | /// Represents a subtitle (user-uploaded or automatic caption) for a video. 97 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 98 | pub struct Subtitle { 99 | /// The language code of the subtitle (e.g., 'en', 'fr', 'es'). 100 | pub language_code: Option, 101 | /// The full language name (e.g., 'English', 'French', 'Spanish'). 102 | pub language_name: Option, 103 | /// The URL of the subtitle file. 104 | pub url: String, 105 | /// The file extension/format of the subtitle. 106 | #[serde(rename = "ext")] 107 | pub extension: Extension, 108 | /// Whether this is an automatically generated subtitle. 109 | #[serde(default)] 110 | pub is_automatic: bool, 111 | } 112 | 113 | impl Subtitle { 114 | /// Creates a new Subtitle from an AutomaticCaption. 115 | pub fn from_automatic_caption(caption: &AutomaticCaption, language_code: String) -> Self { 116 | Self { 117 | language_code: Some(language_code), 118 | language_name: caption.name.clone(), 119 | url: caption.url.clone(), 120 | extension: caption.extension.clone(), 121 | is_automatic: true, 122 | } 123 | } 124 | 125 | /// Checks if this subtitle is in a specific format. 126 | pub fn is_format(&self, format: &Extension) -> bool { 127 | &self.extension == format 128 | } 129 | 130 | /// Returns the file extension as a string. 131 | pub fn file_extension(&self) -> &str { 132 | match self.extension { 133 | Extension::Json => "json", 134 | Extension::Json3 => "json3", 135 | Extension::Srv1 => "srv1", 136 | Extension::Srv2 => "srv2", 137 | Extension::Srv3 => "srv3", 138 | Extension::Ttml => "ttml", 139 | Extension::Vtt => "vtt", 140 | Extension::Srt => "srt", 141 | Extension::Ass => "ass", 142 | Extension::Ssa => "ssa", 143 | } 144 | } 145 | } 146 | 147 | // Implementation of the Display trait for Subtitle 148 | impl fmt::Display for Subtitle { 149 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 150 | write!( 151 | f, 152 | "Subtitle(lang={}, format={}, auto={})", 153 | self.language_name 154 | .as_deref() 155 | .or(self.language_code.as_deref()) 156 | .unwrap_or("unknown"), 157 | self.file_extension(), 158 | self.is_automatic 159 | ) 160 | } 161 | } 162 | 163 | // Implementation of Eq for Subtitle 164 | impl Eq for Subtitle {} 165 | 166 | // Implementation of Hash for Subtitle 167 | impl Hash for Subtitle { 168 | fn hash(&self, state: &mut H) { 169 | self.language_code.hash(state); 170 | self.url.hash(state); 171 | std::mem::discriminant(&self.extension).hash(state); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/metadata/mod.rs: -------------------------------------------------------------------------------- 1 | //! Metadata management module for downloaded files. 2 | //! 3 | //! This module provides functionality to add metadata to downloaded files, 4 | //! such as title, artist, album, genre, technical information, and thumbnails. 5 | //! 6 | //! ## Supported Formats 7 | //! 8 | //! - **MP3**: Title, artist, comment, genre (from tags), release year 9 | //! - **M4A**: Title, artist, comment, genre (from tags), release year 10 | //! - **MP4**: All basic metadata, plus technical information (resolution, FPS, video codec, video bitrate, audio codec, audio bitrate, audio channels, sample rate) 11 | //! - **WebM**: All basic metadata (via Matroska format), plus technical information as with MP4 12 | //! 13 | //! ## Intelligent Metadata Management 14 | //! 15 | //! The system intelligently manages metadata application: 16 | //! 17 | //! - **Standalone files** (audio or audio+video): Metadata applied immediately during download 18 | //! - **Separate streams** (to be combined later): NO metadata applied to avoid redundant work 19 | //! - **Combined files**: Complete metadata applied to final file, including info from both streams 20 | 21 | use std::path::{Path, PathBuf}; 22 | 23 | mod api; 24 | mod base; 25 | mod chapters; 26 | mod ffmpeg; 27 | mod mp3; 28 | mod mp4; 29 | pub mod postprocess; 30 | 31 | // Re-export the trait 32 | pub use base::BaseMetadata; 33 | 34 | /// Playlist metadata information for embedding in video files. 35 | #[derive(Debug, Clone)] 36 | pub struct PlaylistMetadata { 37 | /// The playlist title/name 38 | pub title: String, 39 | /// The playlist ID 40 | pub id: String, 41 | /// The track number/index in the playlist (1-based) 42 | pub index: usize, 43 | /// Total number of tracks in the playlist (optional) 44 | pub total: Option, 45 | } 46 | 47 | /// Metadata manager for handling file metadata. 48 | /// 49 | /// This manager provides methods to add metadata and thumbnails to downloaded files 50 | /// in various formats (MP3, M4A, MP4, WebM, MKV, etc.). 51 | #[derive(Debug, Clone, PartialEq, Eq)] 52 | pub struct MetadataManager { 53 | /// Path to ffmpeg executable 54 | ffmpeg_path: PathBuf, 55 | } 56 | 57 | impl MetadataManager { 58 | /// Create a new MetadataManager with default ffmpeg path. 59 | /// 60 | /// The default ffmpeg path is "ffmpeg" unless overridden by the `FFMPEG_PATH` 61 | /// environment variable. 62 | pub fn new() -> Self { 63 | Self { 64 | ffmpeg_path: Self::default_ffmpeg_path(), 65 | } 66 | } 67 | 68 | /// Create a new MetadataManager with custom ffmpeg path. 69 | /// 70 | /// # Arguments 71 | /// 72 | /// * `ffmpeg_path` - Path to the ffmpeg executable 73 | pub fn with_ffmpeg_path(ffmpeg_path: impl AsRef) -> Self { 74 | Self { 75 | ffmpeg_path: ffmpeg_path.as_ref().to_path_buf(), 76 | } 77 | } 78 | 79 | /// Get the default ffmpeg path. 80 | /// 81 | /// Can be overridden via the `FFMPEG_PATH` environment variable. 82 | pub(crate) fn default_ffmpeg_path() -> PathBuf { 83 | std::env::var("FFMPEG_PATH") 84 | .map(PathBuf::from) 85 | .unwrap_or_else(|_| PathBuf::from("ffmpeg")) 86 | } 87 | 88 | /// Get the file extension from a path. 89 | /// 90 | /// # Arguments 91 | /// 92 | /// * `file_path` - Path to extract extension from 93 | /// 94 | /// # Returns 95 | /// 96 | /// Lowercase file extension 97 | /// 98 | /// # Errors 99 | /// 100 | /// Returns an error if the file has no extension or contains invalid characters 101 | pub(crate) fn get_file_extension(file_path: impl AsRef) -> crate::error::Result { 102 | #[cfg(feature = "tracing")] 103 | tracing::trace!("Getting file extension for {:?}", file_path.as_ref()); 104 | 105 | let path = file_path.as_ref(); 106 | let ext = path 107 | .extension() 108 | .ok_or_else(|| crate::error::Error::path_validation(path, "File has no extension"))? 109 | .to_str() 110 | .ok_or_else(|| { 111 | crate::error::Error::path_validation(path, "Invalid characters in file extension") 112 | })? 113 | .to_lowercase(); 114 | 115 | Ok(ext) 116 | } 117 | 118 | /// Create a temporary output path for metadata processing. 119 | /// 120 | /// # Arguments 121 | /// 122 | /// * `file_path` - Original file path 123 | /// * `file_format` - File extension for the temporary file 124 | /// 125 | /// # Returns 126 | /// 127 | /// PathBuf to a unique temporary file in the same directory 128 | /// 129 | /// # Errors 130 | /// 131 | /// Returns an error if the path cannot be created 132 | pub(crate) fn create_temp_output_path( 133 | file_path: impl AsRef, 134 | file_format: &str, 135 | ) -> crate::error::Result { 136 | #[cfg(feature = "tracing")] 137 | tracing::trace!( 138 | "Creating temporary output path for {:?}", 139 | file_path.as_ref() 140 | ); 141 | 142 | let path = file_path.as_ref(); 143 | let parent_dir = path.parent().unwrap_or_else(|| Path::new("")); 144 | let uuid = uuid::Uuid::new_v4(); 145 | 146 | if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) { 147 | Ok(parent_dir.join(format!("{}_{}_temp.{}", file_stem, uuid, file_format))) 148 | } else { 149 | Ok(parent_dir.join(format!("output_{}_temp.{}", uuid, file_format))) 150 | } 151 | } 152 | 153 | /// Log metadata debug messages if tracing is enabled. 154 | pub(crate) fn log_metadata_debug>(_message: S) { 155 | #[cfg(feature = "tracing")] 156 | tracing::debug!("{}", _message.as_ref()); 157 | } 158 | } 159 | 160 | impl Default for MetadataManager { 161 | fn default() -> Self { 162 | Self::new() 163 | } 164 | } 165 | 166 | impl BaseMetadata for MetadataManager {} 167 | -------------------------------------------------------------------------------- /src/client/proxy.rs: -------------------------------------------------------------------------------- 1 | //! Proxy configuration for HTTP/HTTPS/SOCKS5 proxies. 2 | //! 3 | //! This module provides proxy configuration for both reqwest HTTP client 4 | //! and yt-dlp command-line tool. 5 | 6 | use std::fmt; 7 | 8 | /// Proxy configuration supporting HTTP, HTTPS, and SOCKS5 proxies. 9 | /// 10 | /// # Examples 11 | /// 12 | /// ```rust,no_run 13 | /// use yt_dlp::client::proxy::{ProxyConfig, ProxyType}; 14 | /// 15 | /// // Simple HTTP proxy 16 | /// let proxy = ProxyConfig::new(ProxyType::Http, "http://proxy.example.com:8080"); 17 | /// 18 | /// // SOCKS5 proxy with authentication 19 | /// let proxy = ProxyConfig::new(ProxyType::Socks5, "socks5://proxy.example.com:1080") 20 | /// .with_auth("username", "password"); 21 | /// 22 | /// // With no-proxy list 23 | /// let proxy = ProxyConfig::new(ProxyType::Http, "http://proxy.example.com:8080") 24 | /// .with_no_proxy(vec!["localhost".to_string(), "127.0.0.1".to_string()]); 25 | /// ``` 26 | #[derive(Clone, Debug)] 27 | pub struct ProxyConfig { 28 | proxy_type: ProxyType, 29 | url: String, 30 | username: Option, 31 | password: Option, 32 | no_proxy: Vec, 33 | } 34 | 35 | /// Type of proxy to use. 36 | #[derive(Clone, Debug, PartialEq, Eq)] 37 | pub enum ProxyType { 38 | /// HTTP proxy 39 | Http, 40 | /// HTTPS proxy 41 | Https, 42 | /// SOCKS5 proxy 43 | Socks5, 44 | } 45 | 46 | impl ProxyConfig { 47 | /// Creates a new proxy configuration. 48 | /// 49 | /// # Arguments 50 | /// 51 | /// * `proxy_type` - The type of proxy (HTTP, HTTPS, or SOCKS5) 52 | /// * `url` - The proxy URL (e.g., "http://proxy.example.com:8080") 53 | /// 54 | /// # Returns 55 | /// 56 | /// A new ProxyConfig instance 57 | pub fn new(proxy_type: ProxyType, url: impl Into) -> Self { 58 | Self { 59 | proxy_type, 60 | url: url.into(), 61 | username: None, 62 | password: None, 63 | no_proxy: Vec::new(), 64 | } 65 | } 66 | 67 | /// Adds authentication credentials to the proxy. 68 | /// 69 | /// # Arguments 70 | /// 71 | /// * `username` - The proxy username 72 | /// * `password` - The proxy password 73 | /// 74 | /// # Returns 75 | /// 76 | /// Self for method chaining 77 | pub fn with_auth(mut self, username: impl Into, password: impl Into) -> Self { 78 | self.username = Some(username.into()); 79 | self.password = Some(password.into()); 80 | self 81 | } 82 | 83 | /// Sets the list of domains that should bypass the proxy. 84 | /// 85 | /// # Arguments 86 | /// 87 | /// * `no_proxy` - List of domains to bypass (e.g., ["localhost", "127.0.0.1"]) 88 | /// 89 | /// # Returns 90 | /// 91 | /// Self for method chaining 92 | pub fn with_no_proxy(mut self, no_proxy: Vec) -> Self { 93 | self.no_proxy = no_proxy; 94 | self 95 | } 96 | 97 | /// Returns the proxy type. 98 | pub fn proxy_type(&self) -> &ProxyType { 99 | &self.proxy_type 100 | } 101 | 102 | /// Returns the proxy URL. 103 | pub fn url(&self) -> &str { 104 | &self.url 105 | } 106 | 107 | /// Returns the username if authentication is configured. 108 | pub fn username(&self) -> Option<&str> { 109 | self.username.as_deref() 110 | } 111 | 112 | /// Returns the password if authentication is configured. 113 | pub fn password(&self) -> Option<&str> { 114 | self.password.as_deref() 115 | } 116 | 117 | /// Returns the no-proxy list. 118 | pub fn no_proxy(&self) -> &[String] { 119 | &self.no_proxy 120 | } 121 | 122 | /// Builds the complete proxy URL with authentication if configured. 123 | /// 124 | /// # Returns 125 | /// 126 | /// The proxy URL with embedded authentication credentials if provided 127 | pub fn build_url(&self) -> String { 128 | if let (Some(username), Some(password)) = (&self.username, &self.password) { 129 | // Extract scheme and host from URL 130 | if let Some(idx) = self.url.find("://") { 131 | let scheme = &self.url[..idx]; 132 | let rest = &self.url[idx + 3..]; 133 | format!("{}://{}:{}@{}", scheme, username, password, rest) 134 | } else { 135 | // No scheme, just add auth 136 | format!("{}:{}@{}", username, password, self.url) 137 | } 138 | } else { 139 | self.url.clone() 140 | } 141 | } 142 | 143 | /// Converts to reqwest proxy format. 144 | /// 145 | /// # Returns 146 | /// 147 | /// Result containing the reqwest Proxy instance 148 | /// 149 | /// # Errors 150 | /// 151 | /// Returns error if the proxy URL is invalid 152 | pub fn to_reqwest_proxy(&self) -> reqwest::Result { 153 | let url = self.build_url(); 154 | 155 | match self.proxy_type { 156 | ProxyType::Http => reqwest::Proxy::http(&url), 157 | ProxyType::Https => reqwest::Proxy::https(&url), 158 | ProxyType::Socks5 => reqwest::Proxy::all(&url), 159 | } 160 | .map(|mut proxy| { 161 | // Add no-proxy domains 162 | if !self.no_proxy.is_empty() { 163 | proxy = proxy.no_proxy(reqwest::NoProxy::from_string(&self.no_proxy.join(","))); 164 | } 165 | proxy 166 | }) 167 | } 168 | 169 | /// Converts to yt-dlp proxy argument format. 170 | /// 171 | /// # Returns 172 | /// 173 | /// The proxy URL in yt-dlp format 174 | pub fn to_ytdlp_arg(&self) -> String { 175 | self.build_url() 176 | } 177 | } 178 | 179 | impl fmt::Display for ProxyConfig { 180 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 181 | write!( 182 | f, 183 | "ProxyConfig: type={:?}, url={}, auth={}", 184 | self.proxy_type, 185 | self.url, 186 | self.username.is_some() 187 | ) 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/metadata/base.rs: -------------------------------------------------------------------------------- 1 | //! Base metadata trait and common operations. 2 | //! 3 | //! This module provides the BaseMetadata trait with methods to extract and format 4 | //! metadata from Video and Format objects. 5 | 6 | use crate::model::{Video, format::Format}; 7 | use chrono::DateTime; 8 | 9 | /// Common metadata operations shared across different file formats. 10 | /// 11 | /// This trait provides methods to extract and format metadata from Video and Format objects. 12 | pub trait BaseMetadata { 13 | /// Format a timestamp into a string according to a specified format. 14 | /// 15 | /// # Arguments 16 | /// 17 | /// * `timestamp` - Unix timestamp to format 18 | /// * `format_str` - Format string (e.g., "%Y-%m-%d" for date, "%Y" for year) 19 | /// 20 | /// # Returns 21 | /// 22 | /// Formatted string if the timestamp is valid, None otherwise 23 | fn format_timestamp(timestamp: i64, format_str: &str) -> Option { 24 | #[cfg(feature = "tracing")] 25 | tracing::trace!("Formatting timestamp: {}", timestamp); 26 | 27 | DateTime::from_timestamp(timestamp, 0).map(|dt| dt.format(format_str).to_string()) 28 | } 29 | 30 | /// Add metadata to a vector if the value exists. 31 | /// 32 | /// # Arguments 33 | /// 34 | /// * `metadata` - Vector to add the metadata to 35 | /// * `key` - Metadata key 36 | /// * `value` - Optional value to add 37 | fn add_metadata_if_some( 38 | metadata: &mut Vec<(String, String)>, 39 | key: &str, 40 | value: Option, 41 | ) { 42 | #[cfg(feature = "tracing")] 43 | tracing::trace!("Adding metadata if some: {}", key); 44 | 45 | if let Some(value) = value { 46 | metadata.push((key.to_string(), value.to_string())); 47 | } 48 | } 49 | 50 | /// Extract basic metadata from a video. 51 | /// 52 | /// Basic metadata includes: title, artist (channel), album, genre (from tags), date/year 53 | /// 54 | /// # Arguments 55 | /// 56 | /// * `video` - The video to extract metadata from 57 | /// 58 | /// # Returns 59 | /// 60 | /// Vector of (key, value) metadata pairs 61 | fn extract_basic_metadata(video: &Video) -> Vec<(String, String)> { 62 | #[cfg(feature = "tracing")] 63 | tracing::trace!("Extracting basic metadata for video: {}", video.id); 64 | 65 | let mut metadata = vec![ 66 | ("title".to_string(), video.title.clone()), 67 | ("artist".to_string(), video.channel.clone()), 68 | ("album_artist".to_string(), video.channel.clone()), 69 | ("album".to_string(), video.channel.clone()), 70 | ]; 71 | 72 | // Add tags as genre 73 | if !video.tags.is_empty() { 74 | metadata.push(("genre".to_string(), video.tags.join(", "))); 75 | } 76 | 77 | // Add dates 78 | if video.upload_date > 0 79 | && let Some(date_str) = Self::format_timestamp(video.upload_date, "%Y-%m-%d") 80 | { 81 | metadata.push(("date".to_string(), date_str)); 82 | 83 | if let Some(year_str) = Self::format_timestamp(video.upload_date, "%Y") { 84 | metadata.push(("year".to_string(), year_str)); 85 | } 86 | } 87 | 88 | metadata 89 | } 90 | 91 | /// Extract video format metadata. 92 | /// 93 | /// Video format metadata includes: resolution, FPS, video codec, video bitrate 94 | /// 95 | /// # Arguments 96 | /// 97 | /// * `format` - The format to extract metadata from 98 | /// 99 | /// # Returns 100 | /// 101 | /// Vector of (key, value) metadata pairs 102 | fn extract_video_format_metadata(format: &Format) -> Vec<(String, String)> { 103 | #[cfg(feature = "tracing")] 104 | tracing::trace!("Extracting video format metadata: {}", format.format_id); 105 | 106 | let mut metadata = Vec::new(); 107 | 108 | // Resolution 109 | if let (Some(width), Some(height)) = ( 110 | format.video_resolution.width, 111 | format.video_resolution.height, 112 | ) { 113 | metadata.push(("resolution".to_string(), format!("{}x{}", width, height))); 114 | } 115 | 116 | // FPS 117 | Self::add_metadata_if_some(&mut metadata, "framerate", format.video_resolution.fps); 118 | 119 | // Video codec 120 | Self::add_metadata_if_some( 121 | &mut metadata, 122 | "video_codec", 123 | format.codec_info.video_codec.clone(), 124 | ); 125 | 126 | // Video bitrate 127 | Self::add_metadata_if_some(&mut metadata, "video_bitrate", format.rates_info.video_rate); 128 | 129 | metadata 130 | } 131 | 132 | /// Extract audio format metadata. 133 | /// 134 | /// Audio format metadata includes: audio bitrate, audio codec, audio channels, sample rate 135 | /// 136 | /// # Arguments 137 | /// 138 | /// * `format` - The format to extract metadata from 139 | /// 140 | /// # Returns 141 | /// 142 | /// Vector of (key, value) metadata pairs 143 | fn extract_audio_format_metadata(format: &Format) -> Vec<(String, String)> { 144 | #[cfg(feature = "tracing")] 145 | tracing::trace!("Extracting audio format metadata: {}", format.format_id); 146 | 147 | let mut metadata = Vec::new(); 148 | 149 | // Audio bitrate 150 | Self::add_metadata_if_some(&mut metadata, "audio_bitrate", format.rates_info.audio_rate); 151 | 152 | // Audio codec 153 | Self::add_metadata_if_some( 154 | &mut metadata, 155 | "audio_codec", 156 | format.codec_info.audio_codec.clone(), 157 | ); 158 | 159 | // Audio channels 160 | Self::add_metadata_if_some( 161 | &mut metadata, 162 | "audio_channels", 163 | format.codec_info.audio_channels, 164 | ); 165 | 166 | // Sample rate 167 | Self::add_metadata_if_some(&mut metadata, "audio_sample_rate", format.codec_info.asr); 168 | 169 | metadata 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/metadata/mp3.rs: -------------------------------------------------------------------------------- 1 | //! MP3 metadata support using ID3 tags. 2 | //! 3 | //! This module provides functions to add metadata and thumbnails to MP3 files 4 | //! using the ID3 tag format. 5 | 6 | use crate::error::{Error, Result}; 7 | use crate::model::Video; 8 | use crate::model::format::Format; 9 | use id3::{Frame as ID3Frame, Tag as ID3Tag, TagLike, Version as ID3Version}; 10 | use std::fmt::Debug; 11 | use std::path::Path; 12 | 13 | use super::{BaseMetadata, MetadataManager, PlaylistMetadata}; 14 | 15 | impl MetadataManager { 16 | /// Add metadata to an MP3 file using ID3 tags. 17 | /// 18 | /// MP3 metadata includes: Title, artist, album, genre (from tags), release year 19 | /// 20 | /// # Arguments 21 | /// 22 | /// * `file_path` - Path to the MP3 file 23 | /// * `video` - Video metadata to apply 24 | /// * `audio_format` - Optional audio format for technical metadata 25 | /// * `playlist` - Optional playlist metadata (album=playlist title, track=index) 26 | /// 27 | /// # Errors 28 | /// 29 | /// Returns an error if ID3 tags cannot be read or written 30 | pub(super) fn add_metadata_to_mp3 + Debug + Copy>( 31 | file_path: P, 32 | video: &Video, 33 | audio_format: Option<&Format>, 34 | playlist: Option<&PlaylistMetadata>, 35 | ) -> Result<()> { 36 | #[cfg(feature = "tracing")] 37 | tracing::trace!("Adding metadata to MP3 file: {:?}", file_path); 38 | 39 | Self::log_metadata_debug(format!("Adding metadata to MP3 file: {:?}", file_path)); 40 | 41 | // Load existing tag or create a new one 42 | let mut tag = match ID3Tag::read_from_path(file_path.as_ref()) { 43 | Ok(tag) => tag, 44 | Err(_) => ID3Tag::new(), 45 | }; 46 | 47 | // Add basic metadata 48 | let metadata = Self::extract_basic_metadata(video); 49 | for (key, value) in metadata { 50 | match key.as_str() { 51 | "title" => tag.set_title(value), 52 | "artist" => tag.set_artist(value), 53 | "album" => { 54 | // Override with playlist title if available 55 | if let Some(pl) = playlist { 56 | tag.set_album(&pl.title); 57 | } else { 58 | tag.set_album(value); 59 | } 60 | } 61 | "album_artist" => tag.set_album_artist(value), 62 | "genre" => tag.set_genre(value), 63 | "year" => { 64 | if let Ok(year) = value.parse::() { 65 | tag.set_year(year) 66 | } 67 | } 68 | _ => { 69 | Self::log_metadata_debug(format!("Skipping ID3 metadata: {} = {}", key, value)); 70 | } 71 | } 72 | } 73 | 74 | // Add playlist metadata if available 75 | if let Some(pl) = playlist { 76 | // Set track number (1-based index) 77 | tag.set_track(pl.index as u32); 78 | if let Some(total) = pl.total { 79 | tag.set_total_tracks(total as u32); 80 | } 81 | 82 | // Add playlist ID as custom frame 83 | let frame = ID3Frame::text("TXXX", format!("Playlist ID: {}", pl.id)); 84 | tag.add_frame(frame); 85 | } 86 | 87 | // Add technical metadata if available (as custom frames) 88 | if let Some(format) = audio_format { 89 | if let Some(audio_rate) = format.rates_info.audio_rate { 90 | let frame = ID3Frame::text("TXXX", format!("Audio Bitrate: {}", audio_rate)); 91 | tag.add_frame(frame); 92 | } 93 | 94 | if let Some(audio_codec) = &format.codec_info.audio_codec { 95 | let frame = ID3Frame::text("TXXX", format!("Audio Codec: {}", audio_codec)); 96 | tag.add_frame(frame); 97 | } 98 | } 99 | 100 | // Save changes 101 | tag.write_to_path(file_path.as_ref(), ID3Version::Id3v24) 102 | .map_err(|e| Error::Unknown(format!("Failed to write ID3 tags: {}", e)))?; 103 | 104 | Ok(()) 105 | } 106 | 107 | /// Add thumbnail to an MP3 file using ID3 picture frame. 108 | /// 109 | /// # Arguments 110 | /// 111 | /// * `file_path` - Path to the MP3 file 112 | /// * `thumbnail_path` - Path to the thumbnail image 113 | /// 114 | /// # Errors 115 | /// 116 | /// Returns an error if the thumbnail cannot be read or the ID3 tags cannot be written 117 | pub(super) fn add_thumbnail_to_mp3 + Debug + Copy>( 118 | file_path: P, 119 | thumbnail_path: &Path, 120 | ) -> Result<()> { 121 | #[cfg(feature = "tracing")] 122 | tracing::trace!("Adding thumbnail to MP3 file: {:?}", file_path); 123 | 124 | // Load existing tag or create a new one 125 | let mut tag = match ID3Tag::read_from_path(file_path.as_ref()) { 126 | Ok(tag) => tag, 127 | Err(_) => ID3Tag::new(), 128 | }; 129 | 130 | // Read thumbnail content 131 | let image_data = std::fs::read(thumbnail_path) 132 | .map_err(|e| Error::io_with_path("read thumbnail", thumbnail_path, e))?; 133 | 134 | // Determine MIME type based on file extension 135 | let mime_type = match thumbnail_path.extension().and_then(|ext| ext.to_str()) { 136 | Some("jpg") | Some("jpeg") => "image/jpeg", 137 | Some("png") => "image/png", 138 | _ => "image/jpeg", 139 | }; 140 | 141 | // Create picture frame 142 | let picture = ID3Frame::with_content( 143 | "APIC", 144 | id3::frame::Content::Picture(id3::frame::Picture { 145 | mime_type: mime_type.to_string(), 146 | picture_type: id3::frame::PictureType::CoverFront, 147 | description: String::new(), 148 | data: image_data, 149 | }), 150 | ); 151 | 152 | tag.add_frame(picture); 153 | 154 | // Save the tag 155 | tag.write_to_path(file_path.as_ref(), ID3Version::Id3v24) 156 | .map_err(|e| Error::Unknown(format!("Failed to write ID3 tags: {}", e)))?; 157 | 158 | #[cfg(feature = "tracing")] 159 | tracing::debug!("Added thumbnail to MP3 file: {:?}", file_path); 160 | 161 | Ok(()) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/utils/validation.rs: -------------------------------------------------------------------------------- 1 | //! URL and path validation utilities for security. 2 | //! 3 | //! This module provides functions to validate YouTube URLs and sanitize file paths 4 | //! to prevent security vulnerabilities like path traversal attacks. 5 | 6 | use crate::error::{Error, Result}; 7 | use std::path::{Path, PathBuf}; 8 | 9 | /// Validates a YouTube URL. 10 | /// 11 | /// # Arguments 12 | /// 13 | /// * `url` - The URL to validate 14 | /// 15 | /// # Returns 16 | /// 17 | /// Returns `Ok(())` if the URL is a valid YouTube URL, otherwise an error. 18 | /// 19 | /// # Errors 20 | /// 21 | /// This function will return an error if: 22 | /// - The URL cannot be parsed 23 | /// - The URL is not from YouTube (youtube.com, youtu.be, or youtube-nocookie.com) 24 | /// - The URL uses an unsafe scheme (only HTTP and HTTPS are allowed) 25 | /// 26 | /// # Examples 27 | /// 28 | /// ```rust 29 | /// # use yt_dlp::utils::validation::validate_youtube_url; 30 | /// // Valid URLs 31 | /// assert!(validate_youtube_url("https://www.youtube.com/watch?v=dQw4w9WgXcQ").is_ok()); 32 | /// assert!(validate_youtube_url("https://youtu.be/dQw4w9WgXcQ").is_ok()); 33 | /// 34 | /// // Invalid URLs 35 | /// assert!(validate_youtube_url("https://evil.com/watch?v=dQw4w9WgXcQ").is_err()); 36 | /// assert!(validate_youtube_url("file:///etc/passwd").is_err()); 37 | /// ``` 38 | pub fn validate_youtube_url(url: &str) -> Result<()> { 39 | // Try to parse the URL 40 | let parsed = url::Url::parse(url) 41 | .map_err(|e| Error::url_validation(url, format!("Invalid URL format: {}", e)))?; 42 | 43 | // Check the scheme (only HTTP and HTTPS are allowed) 44 | let scheme = parsed.scheme(); 45 | if scheme != "http" && scheme != "https" { 46 | return Err(Error::url_validation( 47 | url, 48 | format!( 49 | "Unsafe URL scheme '{}'. Only HTTP and HTTPS are allowed", 50 | scheme 51 | ), 52 | )); 53 | } 54 | 55 | // Check the host 56 | let host = parsed 57 | .host_str() 58 | .ok_or_else(|| Error::url_validation(url, "URL must have a host"))?; 59 | 60 | // Allow YouTube domains 61 | let is_youtube = host == "youtube.com" 62 | || host.ends_with(".youtube.com") 63 | || host == "youtu.be" 64 | || host.ends_with(".youtu.be") 65 | || host == "youtube-nocookie.com" 66 | || host.ends_with(".youtube-nocookie.com"); 67 | 68 | if !is_youtube { 69 | return Err(Error::url_validation( 70 | url, 71 | format!("URL must be from YouTube (got: {})", host), 72 | )); 73 | } 74 | 75 | Ok(()) 76 | } 77 | 78 | /// Sanitizes a file path to prevent path traversal attacks. 79 | /// 80 | /// # Arguments 81 | /// 82 | /// * `path` - The path to sanitize 83 | /// 84 | /// # Returns 85 | /// 86 | /// Returns a sanitized version of the path with dangerous components removed. 87 | /// 88 | /// # Errors 89 | /// 90 | /// This function will return an error if the path contains path traversal attempts. 91 | /// 92 | /// # Examples 93 | /// 94 | /// ```rust 95 | /// # use std::path::PathBuf; 96 | /// # use yt_dlp::utils::validation::sanitize_path; 97 | /// // Safe paths 98 | /// assert!(sanitize_path("video.mp4").is_ok()); 99 | /// assert!(sanitize_path("downloads/video.mp4").is_ok()); 100 | /// 101 | /// // Dangerous paths (will be rejected or sanitized) 102 | /// assert!(sanitize_path("../../../etc/passwd").is_err()); 103 | /// assert!(sanitize_path("/etc/passwd").is_err()); 104 | /// ``` 105 | pub fn sanitize_path(path: impl AsRef) -> Result { 106 | let path = path.as_ref(); 107 | 108 | // Check for absolute paths (not allowed for user-provided paths) 109 | if path.is_absolute() { 110 | return Err(Error::path_validation( 111 | path, 112 | "Absolute paths are not allowed", 113 | )); 114 | } 115 | 116 | // Build a sanitized path by filtering out dangerous components 117 | let mut sanitized = PathBuf::new(); 118 | let mut has_parent_ref = false; 119 | 120 | for component in path.components() { 121 | match component { 122 | std::path::Component::Normal(part) => { 123 | // Check for hidden directory traversal in filenames 124 | let part_str = part.to_string_lossy(); 125 | if part_str.contains("..") { 126 | return Err(Error::path_validation( 127 | path, 128 | format!("Path contains suspicious component: {}", part_str), 129 | )); 130 | } 131 | sanitized.push(part); 132 | } 133 | std::path::Component::ParentDir => { 134 | has_parent_ref = true; 135 | } 136 | std::path::Component::CurDir => { 137 | // Skip current directory references (harmless but unnecessary) 138 | } 139 | std::path::Component::RootDir => { 140 | return Err(Error::path_validation( 141 | path, 142 | "Root directory reference in path", 143 | )); 144 | } 145 | std::path::Component::Prefix(_) => { 146 | return Err(Error::path_validation( 147 | path, 148 | "Windows path prefix not allowed", 149 | )); 150 | } 151 | } 152 | } 153 | 154 | // Reject paths with parent directory references 155 | if has_parent_ref { 156 | return Err(Error::path_validation( 157 | path, 158 | format!("Path traversal detected (..): {}", path.display()), 159 | )); 160 | } 161 | 162 | // Ensure the sanitized path is not empty 163 | if sanitized.as_os_str().is_empty() { 164 | return Err(Error::path_validation( 165 | path, 166 | "Empty path after sanitization", 167 | )); 168 | } 169 | 170 | Ok(sanitized) 171 | } 172 | 173 | /// Sanitizes a filename by removing or replacing unsafe characters. 174 | /// 175 | /// # Arguments 176 | /// 177 | /// * `filename` - The filename to sanitize 178 | /// 179 | /// # Returns 180 | /// 181 | /// Returns a sanitized filename with unsafe characters removed or replaced. 182 | /// 183 | /// # Examples 184 | /// 185 | /// ```rust 186 | /// # use yt_dlp::utils::validation::sanitize_filename; 187 | /// assert_eq!(sanitize_filename("video.mp4"), "video.mp4"); 188 | /// assert_eq!(sanitize_filename("my/video\\file.mp4"), "myvideofile.mp4"); 189 | /// assert_eq!(sanitize_filename("file:name.mp4"), "filename.mp4"); 190 | /// ``` 191 | pub fn sanitize_filename(filename: &str) -> String { 192 | filename 193 | .replace(['/', '\\', ':', '*', '?', '"', '<', '>', '|'], "") 194 | .replace("..", "") 195 | .chars() 196 | .filter(|c| !c.is_control()) 197 | .collect::() 198 | .trim() 199 | .to_string() 200 | } 201 | -------------------------------------------------------------------------------- /src/client/deps/youtube.rs: -------------------------------------------------------------------------------- 1 | //! Fetch the latest release of 'yt-dlp' from a GitHub repository. 2 | 3 | use crate::client::deps::{Asset, Release, WantedRelease}; 4 | use crate::download::Fetcher; 5 | use crate::error::{Error, Result}; 6 | use crate::utils::platform::Architecture; 7 | use crate::utils::platform::Platform; 8 | use std::fmt; 9 | 10 | const BASE_ASSET_NAME: &str = "yt-dlp"; 11 | 12 | /// The GitHub fetcher is responsible for fetching the latest release of 'yt-dlp' from a GitHub repository. 13 | /// It can also select the correct asset for the current platform and architecture. 14 | /// 15 | /// # Example 16 | /// 17 | /// ```rust, no_run 18 | /// # use std::path::PathBuf; 19 | /// # use yt_dlp::fetcher::deps::youtube::GitHubFetcher; 20 | /// # #[tokio::main] 21 | /// # async fn main() -> Result<(), Box> { 22 | /// let fetcher = GitHubFetcher::new("yt-dlp", "yt-dlp"); 23 | /// let release = fetcher.fetch_release(None).await?; 24 | /// 25 | /// let destination = PathBuf::from("yt-dlp"); 26 | /// release.download(destination).await?; 27 | /// # Ok(()) 28 | /// # } 29 | #[derive(Debug)] 30 | pub struct GitHubFetcher { 31 | /// The owner or organization of the GitHub repository. 32 | owner: String, 33 | /// The name of the GitHub repository. 34 | repo: String, 35 | } 36 | 37 | impl fmt::Display for GitHubFetcher { 38 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 39 | write!(f, "GitHubFetcher(owner={}, repo={})", self.owner, self.repo) 40 | } 41 | } 42 | 43 | impl GitHubFetcher { 44 | /// Create a new fetcher for the given GitHub repository. 45 | /// 46 | /// # Arguments 47 | /// 48 | /// * `owner` - The owner of the GitHub repository. 49 | /// * `repo` - The name of the GitHub repository. 50 | pub fn new(owner: impl AsRef, repo: impl AsRef) -> Self { 51 | Self { 52 | owner: owner.as_ref().to_string(), 53 | repo: repo.as_ref().to_string(), 54 | } 55 | } 56 | 57 | /// Fetch the latest release for the current platform. 58 | /// 59 | /// # Arguments 60 | /// 61 | /// * `auth_token` - An optional GitHub personal access token to authenticate the request. 62 | /// 63 | /// # Errors 64 | /// 65 | /// This function will return an error if the release could not be fetched or if no asset was found for the current platform. 66 | pub async fn fetch_release(&self, auth_token: Option) -> Result { 67 | #[cfg(feature = "tracing")] 68 | tracing::debug!("Fetching latest release from {}/{}", self.owner, self.repo); 69 | 70 | let platform = Platform::detect(); 71 | let architecture = Architecture::detect(); 72 | 73 | self.fetch_release_for_platform(platform, architecture, auth_token) 74 | .await 75 | } 76 | 77 | /// Fetch the latest release for the given platform. 78 | /// 79 | /// # Arguments 80 | /// 81 | /// * `platform` - The platform to fetch the release for. 82 | /// * `architecture` - The architecture to fetch the release for. 83 | /// * `auth_token` - An optional GitHub personal access token to authenticate the request. 84 | /// 85 | /// # Errors 86 | /// 87 | /// This function will return an error if the release could not be fetched or if no asset was found for the given platform. 88 | pub async fn fetch_release_for_platform( 89 | &self, 90 | platform: Platform, 91 | architecture: Architecture, 92 | auth_token: Option, 93 | ) -> Result { 94 | #[cfg(feature = "tracing")] 95 | tracing::debug!( 96 | "Fetching latest release for {}/{} for platform: {:?}, architecture: {:?}", 97 | self.owner, 98 | self.repo, 99 | platform, 100 | architecture 101 | ); 102 | 103 | let release = self.fetch_latest_release(auth_token).await?; 104 | let asset = Self::select_asset(&platform, &architecture, &release).ok_or( 105 | Error::NoBinaryRelease { 106 | binary: "yt-dlp".to_string(), 107 | platform, 108 | architecture, 109 | }, 110 | )?; 111 | 112 | Ok(WantedRelease { 113 | name: asset.name.clone(), 114 | url: asset.download_url.clone(), 115 | }) 116 | } 117 | 118 | /// Fetch the latest release of the GitHub repository. 119 | /// 120 | /// # Arguments 121 | /// 122 | /// * `auth_token` - An optional GitHub personal access token to authenticate the request. 123 | pub async fn fetch_latest_release(&self, auth_token: Option) -> Result { 124 | #[cfg(feature = "tracing")] 125 | tracing::debug!("Fetching latest release for {}/{}", self.owner, self.repo); 126 | 127 | let url = format!( 128 | "https://api.github.com/repos/{}/{}/releases/latest", 129 | self.owner, self.repo 130 | ); 131 | 132 | let fetcher = Fetcher::new(&url, None); 133 | let response = fetcher.fetch_json(auth_token).await?; 134 | 135 | let release: Release = serde_json::from_value(response)?; 136 | Ok(release) 137 | } 138 | 139 | /// Select the correct asset from the release for the given platform and architecture. 140 | /// 141 | /// # Arguments 142 | /// 143 | /// * `platform` - The platform to select the asset for. 144 | /// * `architecture` - The architecture to select the asset for. 145 | /// * `release` - The release to select the asset from. 146 | pub fn select_asset<'a>( 147 | platform: &Platform, 148 | architecture: &Architecture, 149 | release: &'a Release, 150 | ) -> Option<&'a Asset> { 151 | #[cfg(feature = "tracing")] 152 | tracing::debug!( 153 | "Selecting asset for platform: {:?}, architecture: {:?}", 154 | platform, 155 | architecture 156 | ); 157 | 158 | let assets = &release.assets; 159 | assets.iter().find(|asset| { 160 | let name = &asset.name; 161 | 162 | match (platform, architecture) { 163 | (Platform::Windows, Architecture::X64) => { 164 | name.contains(&format!("{}.exe", BASE_ASSET_NAME)) 165 | } 166 | (Platform::Windows, Architecture::X86) => { 167 | name.contains(&format!("{}_x86.exe", BASE_ASSET_NAME)) 168 | } 169 | 170 | (Platform::Linux, Architecture::X64) => { 171 | name.contains(&format!("{}_linux", BASE_ASSET_NAME)) 172 | } 173 | (Platform::Linux, Architecture::Armv7l) => { 174 | name.contains(&format!("{}_linux_armv7l", BASE_ASSET_NAME)) 175 | } 176 | (Platform::Linux, Architecture::Aarch64) => { 177 | name.contains(&format!("{}_linux_aarch64", BASE_ASSET_NAME)) 178 | } 179 | 180 | (Platform::Mac, _) => name.contains(&format!("{}_macos", BASE_ASSET_NAME)), 181 | 182 | _ => false, 183 | } 184 | }) 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/download/partial.rs: -------------------------------------------------------------------------------- 1 | //! Partial download support for downloading specific portions of videos. 2 | //! 3 | //! This module provides functionality to download only specific parts of a video, 4 | //! either by time range or by chapter index. 5 | 6 | use std::fmt; 7 | 8 | /// Represents a range specification for partial downloads. 9 | /// 10 | /// # Examples 11 | /// 12 | /// ```rust,no_run 13 | /// use yt_dlp::download::partial::PartialRange; 14 | /// 15 | /// // Download from 1:30 to 5:00 16 | /// let time_range = PartialRange::TimeRange { 17 | /// start: 90.0, 18 | /// end: 300.0, 19 | /// }; 20 | /// 21 | /// // Download chapters 2 through 5 22 | /// let chapter_range = PartialRange::ChapterRange { 23 | /// start: 2, 24 | /// end: 5, 25 | /// }; 26 | /// 27 | /// // Download only chapter 3 28 | /// let single_chapter = PartialRange::SingleChapter { index: 3 }; 29 | /// ``` 30 | #[derive(Clone, Debug, PartialEq)] 31 | pub enum PartialRange { 32 | /// Download a specific time range (in seconds) 33 | TimeRange { 34 | /// Start time in seconds 35 | start: f64, 36 | /// End time in seconds 37 | end: f64, 38 | }, 39 | /// Download a range of chapters 40 | ChapterRange { 41 | /// First chapter index (0-based) 42 | start: usize, 43 | /// Last chapter index (0-based, inclusive) 44 | end: usize, 45 | }, 46 | /// Download a single chapter 47 | SingleChapter { 48 | /// Chapter index (0-based) 49 | index: usize, 50 | }, 51 | } 52 | 53 | impl PartialRange { 54 | /// Creates a time range for partial download. 55 | /// 56 | /// # Arguments 57 | /// 58 | /// * `start` - Start time in seconds 59 | /// * `end` - End time in seconds 60 | /// 61 | /// # Returns 62 | /// 63 | /// A PartialRange instance representing the time range 64 | pub fn time_range(start: f64, end: f64) -> Self { 65 | Self::TimeRange { start, end } 66 | } 67 | 68 | /// Creates a chapter range for partial download. 69 | /// 70 | /// # Arguments 71 | /// 72 | /// * `start` - First chapter index (0-based) 73 | /// * `end` - Last chapter index (0-based, inclusive) 74 | /// 75 | /// # Returns 76 | /// 77 | /// A PartialRange instance representing the chapter range 78 | pub fn chapter_range(start: usize, end: usize) -> Self { 79 | Self::ChapterRange { start, end } 80 | } 81 | 82 | /// Creates a single chapter for partial download. 83 | /// 84 | /// # Arguments 85 | /// 86 | /// * `index` - Chapter index (0-based) 87 | /// 88 | /// # Returns 89 | /// 90 | /// A PartialRange instance representing a single chapter 91 | pub fn single_chapter(index: usize) -> Self { 92 | Self::SingleChapter { index } 93 | } 94 | 95 | /// Converts this range to yt-dlp's --download-sections format. 96 | /// 97 | /// # Returns 98 | /// 99 | /// A string in yt-dlp format (e.g., "*00:01:30-00:05:00") 100 | pub fn to_ytdlp_arg(&self) -> String { 101 | match self { 102 | Self::TimeRange { start, end } => { 103 | format!("*{}-{}", format_time(*start), format_time(*end)) 104 | } 105 | Self::ChapterRange { start, end } => { 106 | // For chapter ranges, we'll need to convert to time ranges 107 | // This will be done at runtime with actual chapter data 108 | format!("chapters:{}-{}", start, end) 109 | } 110 | Self::SingleChapter { index } => { 111 | format!("chapters:{}-{}", index, index) 112 | } 113 | } 114 | } 115 | 116 | /// Checks if this range needs chapter metadata to be resolved. 117 | /// 118 | /// # Returns 119 | /// 120 | /// true if chapter metadata is needed, false otherwise 121 | pub fn needs_chapter_metadata(&self) -> bool { 122 | matches!(self, Self::ChapterRange { .. } | Self::SingleChapter { .. }) 123 | } 124 | 125 | /// Converts a chapter range to a time range using chapter metadata. 126 | /// 127 | /// # Arguments 128 | /// 129 | /// * `chapters` - List of chapters with start_time and end_time 130 | /// 131 | /// # Returns 132 | /// 133 | /// A TimeRange variant if conversion is successful, or None if indices are out of bounds 134 | /// 135 | /// # Errors 136 | /// 137 | /// Returns None if chapter indices are out of bounds 138 | pub fn to_time_range(&self, chapters: &[crate::model::chapter::Chapter]) -> Option { 139 | match self { 140 | Self::TimeRange { .. } => Some(self.clone()), 141 | Self::ChapterRange { start, end } => { 142 | if *end >= chapters.len() { 143 | return None; 144 | } 145 | let start_time = chapters[*start].start_time; 146 | let end_time = chapters[*end].end_time; 147 | Some(Self::TimeRange { 148 | start: start_time, 149 | end: end_time, 150 | }) 151 | } 152 | Self::SingleChapter { index } => { 153 | if *index >= chapters.len() { 154 | return None; 155 | } 156 | let start_time = chapters[*index].start_time; 157 | let end_time = chapters[*index].end_time; 158 | Some(Self::TimeRange { 159 | start: start_time, 160 | end: end_time, 161 | }) 162 | } 163 | } 164 | } 165 | 166 | /// Gets the start and end times for this range. 167 | /// 168 | /// # Returns 169 | /// 170 | /// A tuple (start, end) in seconds, or None if chapter conversion is needed 171 | pub fn get_times(&self) -> Option<(f64, f64)> { 172 | match self { 173 | Self::TimeRange { start, end } => Some((*start, *end)), 174 | _ => None, 175 | } 176 | } 177 | } 178 | 179 | impl fmt::Display for PartialRange { 180 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 181 | match self { 182 | Self::TimeRange { start, end } => { 183 | write!( 184 | f, 185 | "TimeRange({} - {})", 186 | format_time(*start), 187 | format_time(*end) 188 | ) 189 | } 190 | Self::ChapterRange { start, end } => { 191 | write!(f, "ChapterRange({} - {})", start, end) 192 | } 193 | Self::SingleChapter { index } => { 194 | write!(f, "SingleChapter({})", index) 195 | } 196 | } 197 | } 198 | } 199 | 200 | /// Formats a time in seconds to HH:MM:SS.mmm format. 201 | /// 202 | /// # Arguments 203 | /// 204 | /// * `seconds` - Time in seconds 205 | /// 206 | /// # Returns 207 | /// 208 | /// A formatted string in HH:MM:SS.mmm format 209 | fn format_time(seconds: f64) -> String { 210 | let hours = (seconds / 3600.0) as u64; 211 | let minutes = ((seconds % 3600.0) / 60.0) as u64; 212 | let secs = seconds % 60.0; 213 | format!("{:02}:{:02}:{:06.3}", hours, minutes, secs) 214 | } 215 | -------------------------------------------------------------------------------- /src/cache/backend/memory.rs: -------------------------------------------------------------------------------- 1 | //! In-memory backend implementation for testing. 2 | //! 3 | //! This module provides simple in-memory cache implementations for testing purposes. 4 | //! Data is stored in HashMap and is not persisted. 5 | 6 | use super::{FileBackend, VideoBackend}; 7 | use crate::cache::video::{CachedFile, CachedVideo}; 8 | use crate::error::Result; 9 | use crate::model::Video; 10 | use std::collections::HashMap; 11 | use std::path::PathBuf; 12 | use std::sync::Arc; 13 | use std::time::{SystemTime, UNIX_EPOCH}; 14 | use tokio::sync::RwLock; 15 | 16 | #[cfg(feature = "cache")] 17 | use crate::model::selector::{ 18 | AudioCodecPreference, AudioQuality, VideoCodecPreference, VideoQuality, 19 | }; 20 | 21 | /// Type alias for file cache storage. 22 | type FileStorage = Arc)>>>; 23 | 24 | /// In-memory video cache implementation for testing. 25 | #[derive(Debug, Clone)] 26 | pub struct MemoryVideoCache { 27 | data: Arc>>, 28 | ttl: i64, 29 | } 30 | 31 | #[async_trait::async_trait] 32 | impl VideoBackend for MemoryVideoCache { 33 | async fn new(_cache_dir: PathBuf, ttl: Option) -> Result { 34 | Ok(Self { 35 | data: Arc::new(RwLock::new(HashMap::new())), 36 | ttl: ttl.unwrap_or(24 * 60 * 60) as i64, 37 | }) 38 | } 39 | 40 | async fn get(&self, url: &str) -> Result> { 41 | let data = self.data.read().await; 42 | 43 | if let Some(cached) = data.get(url) { 44 | let now = SystemTime::now() 45 | .duration_since(UNIX_EPOCH) 46 | .unwrap_or_default() 47 | .as_secs() as i64; 48 | 49 | if cached.cached_at + self.ttl > now { 50 | return Ok(Some(cached.video()?)); 51 | } 52 | } 53 | 54 | Ok(None) 55 | } 56 | 57 | async fn put(&self, url: String, video: Video) -> Result<()> { 58 | let mut data = self.data.write().await; 59 | let cached = CachedVideo::from((url.clone(), video)); 60 | data.insert(url, cached); 61 | Ok(()) 62 | } 63 | 64 | async fn remove(&self, url: &str) -> Result<()> { 65 | let mut data = self.data.write().await; 66 | data.remove(url); 67 | Ok(()) 68 | } 69 | 70 | async fn clean(&self) -> Result<()> { 71 | let mut data = self.data.write().await; 72 | let now = SystemTime::now() 73 | .duration_since(UNIX_EPOCH) 74 | .unwrap_or_default() 75 | .as_secs() as i64; 76 | 77 | data.retain(|_, cached| cached.cached_at + self.ttl > now); 78 | Ok(()) 79 | } 80 | 81 | async fn get_by_id(&self, id: &str) -> Result { 82 | let data = self.data.read().await; 83 | let now = SystemTime::now() 84 | .duration_since(UNIX_EPOCH) 85 | .unwrap_or_default() 86 | .as_secs() as i64; 87 | 88 | for cached in data.values() { 89 | if cached.id == id && cached.cached_at + self.ttl > now { 90 | return Ok(cached.clone()); 91 | } 92 | } 93 | 94 | Err(crate::error::Error::Unknown(format!( 95 | "Video with ID {} not found or expired in cache", 96 | id 97 | ))) 98 | } 99 | } 100 | 101 | /// In-memory file cache implementation for testing. 102 | #[derive(Debug, Clone)] 103 | pub struct MemoryFileCache { 104 | files: FileStorage, 105 | ttl: i64, 106 | } 107 | 108 | #[async_trait::async_trait] 109 | impl FileBackend for MemoryFileCache { 110 | async fn new(_cache_dir: PathBuf, ttl: Option) -> Result { 111 | Ok(Self { 112 | files: Arc::new(RwLock::new(HashMap::new())), 113 | ttl: ttl.unwrap_or(7 * 24 * 60 * 60) as i64, 114 | }) 115 | } 116 | 117 | async fn get_by_hash(&self, hash: &str) -> Option<(CachedFile, PathBuf)> { 118 | let files = self.files.read().await; 119 | let now = SystemTime::now() 120 | .duration_since(UNIX_EPOCH) 121 | .unwrap_or_default() 122 | .as_secs() as i64; 123 | 124 | files.get(hash).and_then(|(cached, _)| { 125 | if cached.cached_at + self.ttl > now { 126 | Some((cached.clone(), PathBuf::from(&cached.relative_path))) 127 | } else { 128 | None 129 | } 130 | }) 131 | } 132 | 133 | async fn get_by_video_and_format( 134 | &self, 135 | video_id: &str, 136 | format_id: &str, 137 | ) -> Option<(CachedFile, PathBuf)> { 138 | let files = self.files.read().await; 139 | let now = SystemTime::now() 140 | .duration_since(UNIX_EPOCH) 141 | .unwrap_or_default() 142 | .as_secs() as i64; 143 | 144 | for (cached, _) in files.values() { 145 | if cached.video_id.as_deref() == Some(video_id) 146 | && cached.format_id.as_deref() == Some(format_id) 147 | && cached.cached_at + self.ttl > now 148 | { 149 | return Some((cached.clone(), PathBuf::from(&cached.relative_path))); 150 | } 151 | } 152 | 153 | None 154 | } 155 | 156 | #[cfg(feature = "cache")] 157 | async fn get_by_video_and_preferences( 158 | &self, 159 | video_id: &str, 160 | video_quality: Option, 161 | audio_quality: Option, 162 | video_codec: Option, 163 | audio_codec: Option, 164 | ) -> Option<(CachedFile, PathBuf)> { 165 | let files = self.files.read().await; 166 | let now = SystemTime::now() 167 | .duration_since(UNIX_EPOCH) 168 | .unwrap_or_default() 169 | .as_secs() as i64; 170 | 171 | let vq = video_quality.and_then(|q| serde_json::to_string(&q).ok()); 172 | let aq = audio_quality.and_then(|q| serde_json::to_string(&q).ok()); 173 | let vc = video_codec.and_then(|c| serde_json::to_string(&c).ok()); 174 | let ac = audio_codec.and_then(|c| serde_json::to_string(&c).ok()); 175 | 176 | for (cached, _) in files.values() { 177 | if cached.video_id.as_deref() == Some(video_id) 178 | && (vq.is_none() || cached.video_quality == vq) 179 | && (aq.is_none() || cached.audio_quality == aq) 180 | && (vc.is_none() || cached.video_codec == vc) 181 | && (ac.is_none() || cached.audio_codec == ac) 182 | && cached.cached_at + self.ttl > now 183 | { 184 | return Some((cached.clone(), PathBuf::from(&cached.relative_path))); 185 | } 186 | } 187 | 188 | None 189 | } 190 | 191 | async fn put(&self, file: CachedFile, content: &[u8]) -> Result { 192 | let mut files = self.files.write().await; 193 | let path = PathBuf::from(&file.relative_path); 194 | files.insert(file.id.clone(), (file, content.to_vec())); 195 | Ok(path) 196 | } 197 | 198 | async fn remove(&self, id: &str) -> Result<()> { 199 | let mut files = self.files.write().await; 200 | files.remove(id); 201 | Ok(()) 202 | } 203 | 204 | async fn clean(&self) -> Result<()> { 205 | let mut files = self.files.write().await; 206 | let now = SystemTime::now() 207 | .duration_since(UNIX_EPOCH) 208 | .unwrap_or_default() 209 | .as_secs() as i64; 210 | 211 | files.retain(|_, (cached, _)| cached.cached_at + self.ttl > now); 212 | Ok(()) 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /src/cache/video.rs: -------------------------------------------------------------------------------- 1 | //! Video cache wrapper using backend implementations. 2 | //! 3 | //! This module provides a high-level API for caching video metadata, 4 | //! using pluggable backend implementations (SQLite by default). 5 | 6 | use crate::error::Result; 7 | use crate::model::Video; 8 | use serde::{Deserialize, Serialize}; 9 | use std::path::Path; 10 | use std::time::{SystemTime, UNIX_EPOCH}; 11 | 12 | #[cfg(feature = "cache")] 13 | use crate::cache::backend::{VideoBackend, sqlite::SqliteVideoCache}; 14 | 15 | /// Structure for storing video metadata in cache. 16 | #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] 17 | pub struct CachedVideo { 18 | /// The ID of the video. 19 | pub id: String, 20 | /// The title of the video. 21 | pub title: String, 22 | /// The URL of the video. 23 | pub url: String, 24 | /// The complete video metadata as JSON. 25 | pub video_json: String, 26 | /// The cache timestamp (Unix timestamp). 27 | pub cached_at: i64, 28 | } 29 | 30 | impl CachedVideo { 31 | pub fn video(&self) -> Result