Skip to main content

yt_dlp/utils/
fs.rs

1//! Tools for working with the file system.
2
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt;
5use std::path::{Path, PathBuf};
6
7use tar::Archive;
8use tokio::fs::{File, OpenOptions};
9use uuid::Uuid;
10use xz2::read::XzDecoder;
11use zip::ZipArchive;
12
13use crate::error::{Error, Result};
14
15/// Converts a path to a UTF-8 string reference.
16///
17/// # Arguments
18///
19/// * `path` - The path to convert
20///
21/// # Returns
22///
23/// The path as a UTF-8 string slice
24///
25/// # Errors
26///
27/// Returns `Error::PathValidation` if the path contains invalid UTF-8
28pub fn try_path_str(path: &Path) -> Result<&str> {
29    path.to_str()
30        .ok_or_else(|| Error::path_validation(path, "Path contains invalid UTF-8"))
31}
32
33/// Gets the file extension from a path, lowercased.
34///
35/// # Arguments
36///
37/// * `path` - The path to extract the extension from
38///
39/// # Returns
40///
41/// Lowercase file extension string
42///
43/// # Errors
44///
45/// Returns `Error::PathValidation` if the file has no extension or contains invalid characters
46pub fn try_extension(path: &Path) -> Result<String> {
47    let ext = path
48        .extension()
49        .ok_or_else(|| Error::path_validation(path, "File has no extension"))?
50        .to_str()
51        .ok_or_else(|| Error::path_validation(path, "Invalid characters in file extension"))?
52        .to_lowercase();
53
54    Ok(ext)
55}
56
57/// Creates a temporary output path for file processing.
58///
59/// # Arguments
60///
61/// * `file_path` - Original file path
62/// * `file_format` - File extension for the temporary file
63///
64/// # Returns
65///
66/// `PathBuf` to a unique temporary file in the same directory
67pub fn create_temp_path(file_path: &Path, file_format: &str) -> PathBuf {
68    let parent_dir = file_path.parent().unwrap_or_else(|| Path::new(""));
69    let uuid = Uuid::new_v4();
70
71    if let Some(file_stem) = file_path.file_stem().and_then(|s| s.to_str()) {
72        parent_dir.join(format!("{}_{}_temp.{}", file_stem, uuid, file_format))
73    } else {
74        parent_dir.join(format!("output_{}_temp.{}", uuid, file_format))
75    }
76}
77
78/// Determines the MIME type of a file based on its extension.
79///
80/// # Arguments
81///
82/// * `path` - Path to the file
83///
84/// # Returns
85///
86/// The MIME type as a string
87pub fn determine_mime_type(path: &Path) -> String {
88    let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
89
90    match extension.to_lowercase().as_str() {
91        "mp4" => "video/mp4",
92        "webm" => "video/webm",
93        "mp3" => "audio/mpeg",
94        "m4a" => "audio/mp4",
95        "jpg" | "jpeg" => "image/jpeg",
96        "png" => "image/png",
97        "vtt" => "text/vtt",
98        "srt" => "application/x-subrip",
99        "ass" | "ssa" => "text/x-ssa",
100        _ => "application/octet-stream",
101    }
102    .to_string()
103}
104
105/// Returns the name of the given path.
106///
107/// # Arguments
108///
109/// * `path` - The path to extract the name from
110///
111/// # Returns
112///
113/// The file name as a string
114///
115/// # Errors
116///
117/// Returns an error if the path has no file name or contains invalid UTF-8
118pub fn try_name(path: impl Into<PathBuf>) -> Result<String> {
119    let path: PathBuf = path.into();
120
121    let name = path
122        .file_name()
123        .ok_or_else(|| Error::path_validation(&path, "Path has no file name"))?;
124    let name = name
125        .to_str()
126        .ok_or_else(|| Error::path_validation(&path, "File name contains invalid UTF-8"))?;
127
128    Ok(name.to_string())
129}
130
131/// Returns the name of the given path without the extension.
132///
133/// # Arguments
134///
135/// * `path` - The path to extract the name from
136///
137/// # Returns
138///
139/// The file name without extension
140///
141/// # Errors
142///
143/// Returns an error if the path has no file stem or contains invalid UTF-8
144pub fn try_without_extension(path: impl Into<PathBuf>) -> Result<String> {
145    let path: PathBuf = path.into();
146
147    let name = path
148        .file_stem()
149        .ok_or_else(|| Error::path_validation(&path, "Path has no file stem"))?;
150    let name = name
151        .to_str()
152        .ok_or_else(|| Error::path_validation(&path, "File stem contains invalid UTF-8"))?;
153
154    Ok(name.to_string())
155}
156
157/// Returns the parent directory of the given path.
158///
159/// # Arguments
160///
161/// * `path` - The path to extract the parent from
162///
163/// # Returns
164///
165/// The parent directory path
166///
167/// # Errors
168///
169/// Returns an error if the path has no parent
170pub fn try_parent(path: impl Into<PathBuf>) -> Result<PathBuf> {
171    let path: PathBuf = path.into();
172
173    let parent = path
174        .parent()
175        .ok_or_else(|| Error::path_validation(&path, "Path has no parent directory"))?;
176
177    Ok(parent.to_path_buf())
178}
179
180/// Creates a new file at the given destination.
181///
182/// # Arguments
183///
184/// * `destination` - The path to create the file at
185///
186/// # Returns
187///
188/// An opened file handle
189///
190/// # Errors
191///
192/// Returns an error if the file cannot be created
193pub async fn create_file(destination: impl Into<PathBuf>) -> Result<File> {
194    let destination: PathBuf = destination.into();
195
196    tracing::debug!(
197        destination = ?destination,
198        "⚙️ Creating new file"
199    );
200
201    let mut open_options = OpenOptions::new();
202    open_options.read(true);
203    open_options.write(true);
204    open_options.create(true);
205    open_options.truncate(true);
206
207    #[cfg(unix)]
208    {
209        open_options.mode(0o644);
210    }
211
212    let file = open_options.open(&destination).await?;
213
214    tracing::debug!(
215        destination = ?destination,
216        "✅ File created successfully"
217    );
218
219    Ok(file)
220}
221
222/// Creates a new directory at the given destination.
223/// If the directory already exists, nothing is done.
224///
225/// # Arguments
226///
227/// * `destination` - The path to create the directory at
228///
229/// # Returns
230///
231/// Ok(()) if the directory was created or already exists
232///
233/// # Errors
234///
235/// Returns an error if the directory cannot be created
236pub async fn create_dir(destination: impl Into<PathBuf>) -> Result<()> {
237    let destination: PathBuf = destination.into();
238
239    tracing::debug!(
240        destination = ?destination,
241        "⚙️ Creating directory"
242    );
243
244    tokio::fs::create_dir_all(&destination).await?;
245
246    tracing::debug!(
247        destination = ?destination,
248        "✅ Directory created successfully"
249    );
250
251    Ok(())
252}
253
254/// Creates the parent directory of the given destination.
255/// If the parent directory already exists, nothing is done.
256///
257/// # Arguments
258///
259/// * `destination` - The path to create the parent directory for
260///
261/// # Returns
262///
263/// Ok(()) if the parent directory was created or already exists
264///
265/// # Errors
266///
267/// Returns an error if the parent directory cannot be created
268pub async fn create_parent_dir(destination: impl Into<PathBuf>) -> Result<()> {
269    let destination: PathBuf = destination.into();
270
271    tracing::debug!(
272        destination = ?destination,
273        "⚙️ Creating parent directory"
274    );
275
276    if let Some(parent) = destination.parent() {
277        tokio::fs::create_dir_all(parent).await?;
278    } else {
279        tokio::fs::create_dir_all(&destination).await?;
280    }
281
282    tracing::debug!(
283        destination = ?destination,
284        "✅ Parent directory created successfully"
285    );
286
287    Ok(())
288}
289
290/// Extracts a zip file to the given destination.
291///
292/// # Arguments
293///
294/// * `zip_path` - The path to the zip file.
295/// * `destination` - The path to extract the zip file to.
296pub async fn extract_zip(zip_path: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Result<()> {
297    let zip_path: PathBuf = zip_path.into();
298    let destination: PathBuf = destination.into();
299
300    tracing::debug!(
301        zip_path = ?zip_path,
302        destination = ?destination,
303        "⚙️ Extracting zip file"
304    );
305
306    let zip_path_for_tracing = zip_path.clone();
307    let destination_for_tracing = destination.clone();
308
309    tokio::task::spawn_blocking(move || {
310        let file = std::fs::File::open(&zip_path).map_err(|e| Error::io_with_path("open zip file", &zip_path, e))?;
311
312        let mut archive = ZipArchive::new(file)?;
313
314        for i in 0..archive.len() {
315            let mut file = archive.by_index(i)?;
316
317            let file_name = file
318                .enclosed_name()
319                .ok_or_else(|| {
320                    Error::path_validation(
321                        PathBuf::from(format!("zip entry {}", i)),
322                        "Zip entry has no valid file name",
323                    )
324                })?
325                .to_path_buf();
326
327            let dest_path = destination.join(file_name);
328
329            if file.is_dir() {
330                std::fs::create_dir_all(&dest_path)
331                    .map_err(|e| Error::io_with_path("create directory from zip", &dest_path, e))?;
332            } else {
333                if let Some(parent) = dest_path.parent() {
334                    std::fs::create_dir_all(parent)
335                        .map_err(|e| Error::io_with_path("create parent directory from zip", parent, e))?;
336                }
337
338                let mut outfile = std::fs::File::create(&dest_path)
339                    .map_err(|e| Error::io_with_path("create file from zip", &dest_path, e))?;
340
341                std::io::copy(&mut file, &mut outfile)
342                    .map_err(|e| Error::io_with_path("copy file content from zip", &dest_path, e))?;
343            }
344
345            // Get and set permissions on Unix
346            #[cfg(unix)]
347            {
348                use std::os::unix::fs::PermissionsExt;
349                if let Some(mode) = file.unix_mode() {
350                    std::fs::set_permissions(&dest_path, std::fs::Permissions::from_mode(mode))
351                        .map_err(|e| Error::io_with_path("set permissions from zip", &dest_path, e))?;
352                }
353            }
354        }
355
356        Ok::<_, Error>(())
357    })
358    .await
359    .map_err(|e| Error::runtime("extract zip archive", e))??;
360
361    tracing::debug!(
362        zip_path = ?zip_path_for_tracing,
363        destination = ?destination_for_tracing,
364        "✅ Zip file extracted successfully"
365    );
366
367    Ok(())
368}
369
370/// Extracts a tar.xz file to the given destination.
371///
372/// # Arguments
373///
374/// * `tar_path` - The path to the tar.xz file.
375/// * `destination` - The path to extract the tar.xz file to.
376pub async fn extract_tar_xz(tar_path: impl Into<PathBuf>, destination: impl Into<PathBuf>) -> Result<()> {
377    let tar_path: PathBuf = tar_path.into();
378    let destination: PathBuf = destination.into();
379
380    tracing::debug!(
381        tar_path = ?tar_path,
382        destination = ?destination,
383        "⚙️ Extracting tar.xz file"
384    );
385
386    let tar_path_for_tracing = tar_path.clone();
387    let destination_for_tracing = destination.clone();
388
389    tokio::task::spawn_blocking(move || {
390        let file = std::fs::File::open(&tar_path).map_err(|e| Error::io_with_path("open tar.xz file", &tar_path, e))?;
391
392        let decompressor = XzDecoder::new(file);
393        let mut archive = Archive::new(decompressor);
394
395        archive
396            .unpack(&destination)
397            .map_err(|e| Error::io_with_path("unpack tar.xz archive", &destination, e))?;
398
399        Ok::<_, Error>(())
400    })
401    .await
402    .map_err(|e| Error::runtime("extract tar.xz archive", e))??;
403
404    tracing::debug!(
405        tar_path = ?tar_path_for_tracing,
406        destination = ?destination_for_tracing,
407        "✅ Tar.xz file extracted successfully"
408    );
409
410    Ok(())
411}
412
413/// Sets the executable bit on the given file.
414///
415/// # Arguments
416///
417/// * `executable` - The path to the executable file.
418#[cfg(unix)]
419pub async fn set_executable(executable: impl Into<PathBuf>) -> Result<()> {
420    let executable: PathBuf = executable.into();
421
422    tracing::debug!(path = ?executable, "⚙️ Setting executable permissions");
423
424    let mut perms = tokio::fs::metadata(&executable).await?.permissions();
425
426    perms.set_mode(0o755);
427    tokio::fs::set_permissions(executable, perms).await?;
428
429    Ok(())
430}
431
432/// No-op implementation for Windows, as Windows doesn't use executable bits.
433///
434/// # Arguments
435///
436/// * `executable` - The path to the executable file.
437#[cfg(not(unix))]
438pub async fn set_executable(_executable: impl Into<PathBuf>) -> Result<()> {
439    // Windows doesn't use executable bits, so this is a no-op
440    Ok(())
441}
442
443/// Generates a random filename with the specified length.
444///
445/// # Arguments
446///
447/// * `length` - The length of the random string to generate.
448///
449/// # Returns
450///
451/// A random string of the specified length.
452pub fn random_filename(length: usize) -> String {
453    let uuid = Uuid::new_v4().to_string().replace('-', "");
454
455    uuid.chars().take(length).collect()
456}
457
458use std::sync::LazyLock;
459
460use regex::Regex;
461
462static VIDEO_ID_REGEX_1: LazyLock<Regex> =
463    LazyLock::new(|| Regex::new(r"(?:video|audio)-([a-zA-Z0-9_-]{11})").expect("Invalid regex"));
464static VIDEO_ID_REGEX_2: LazyLock<Regex> =
465    LazyLock::new(|| Regex::new(r"([a-zA-Z0-9_-]{11})\.[a-zA-Z0-9]+$").expect("Invalid regex"));
466static VIDEO_ID_REGEX_3: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[a-zA-Z0-9_-]{11}").expect("Invalid regex"));
467
468/// Extracts a potential video ID from a filename.
469pub fn extract_video_id(filename: &str) -> Option<String> {
470    // Pattern 1: filename contains "video-[ID]" or "audio-[ID]"
471    if let Some(captures) = VIDEO_ID_REGEX_1.captures(filename)
472        && let Some(id) = captures.get(1)
473    {
474        return Some(id.as_str().to_string());
475    }
476
477    // Pattern 2: filename contains "[ID].mp4" or "[ID].mp3", etc.
478    if let Some(captures) = VIDEO_ID_REGEX_2.captures(filename)
479        && let Some(id) = captures.get(1)
480    {
481        return Some(id.as_str().to_string());
482    }
483
484    // Pattern 3: if the name directly contains a YouTube ID (11 characters)
485    if let Some(captures) = VIDEO_ID_REGEX_3.captures(filename)
486        && let Some(id) = captures.get(0)
487    {
488        let id_str = id.as_str();
489        if id_str.len() == 11 {
490            return Some(id_str.to_string());
491        }
492    }
493
494    None
495}
496
497/// Removes a temporary file and logs any errors.
498/// Does not propagate errors to avoid interrupting the execution flow.
499///
500/// # Arguments
501///
502/// * `file_path` - The path of the file to delete
503///
504/// # Returns
505///
506/// `true` if the file was successfully deleted, `false` otherwise
507pub async fn remove_temp_file(file_path: impl Into<PathBuf>) -> bool {
508    let file_path: PathBuf = file_path.into();
509    let result = tokio::fs::remove_file(&file_path).await;
510
511    if let Err(ref e) = result {
512        tracing::warn!(path = ?file_path, error = %e, "Failed to remove temporary file");
513    }
514
515    result.is_ok()
516}