Skip to main content

phemios_core/
podcast_download.rs

1//! Streaming publication of explicitly requested public podcast episodes.
2use crate::podcast::{PublicResolver, feed_address};
3use std::{
4    fs,
5    io::{self, Read},
6    path::{Path, PathBuf},
7    sync::atomic::{AtomicU64, Ordering},
8    time::Duration,
9};
10use ureq::{http::Uri, unversioned::transport::DefaultConnector};
11const MAX_EPISODE: u64 = 1024 * 1024 * 1024;
12/// Download one public enclosure into the native-owned podcast directory, without session headers.
13pub fn download_episode(folder: &Path, url: &str, title: &str) -> Result<PathBuf, String> {
14    download_episode_authorized(folder, url, title, || Ok(()))
15}
16/// Revalidate the captured account immediately before publishing the completed temporary download.
17pub fn download_episode_authorized(
18    folder: &Path,
19    url: &str,
20    title: &str,
21    admit: impl FnOnce() -> Result<(), String>,
22) -> Result<PathBuf, String> {
23    let config = ureq::Agent::config_builder()
24        .timeout_global(Some(Duration::from_secs(1200)))
25        .timeout_connect(Some(Duration::from_secs(15)))
26        .max_redirects(5)
27        .proxy(None)
28        .build();
29    let agent = ureq::Agent::with_parts(config, DefaultConnector::default(), PublicResolver);
30    download_with_authorized(&agent, folder, url, title, admit)
31}
32#[cfg(test)]
33fn download_with(
34    agent: &ureq::Agent,
35    folder: &Path,
36    url: &str,
37    title: &str,
38) -> Result<PathBuf, String> {
39    download_with_authorized(agent, folder, url, title, || Ok(()))
40}
41fn download_with_authorized(
42    agent: &ureq::Agent,
43    folder: &Path,
44    url: &str,
45    title: &str,
46    admit: impl FnOnce() -> Result<(), String>,
47) -> Result<PathBuf, String> {
48    let uri = feed_address(url).map_err(|_| "Adresse podcast invalide.")?;
49    if title.len() > 2048 {
50        return Err("Titre de podcast invalide.".into());
51    }
52    let mut response = agent
53        .get(uri.to_string())
54        .header(
55            "User-Agent",
56            concat!("Phemios/", env!("CARGO_PKG_VERSION"), " (Podcast download)"),
57        )
58        .header("Accept-Encoding", "identity")
59        .call()
60        .map_err(|_| "Épisode indisponible ou adresse non publique.")?;
61    if response.status() != ureq::http::StatusCode::OK {
62        return Err("Épisode indisponible ou adresse non publique.".into());
63    }
64    let mime = response
65        .headers()
66        .get("content-type")
67        .and_then(|v| v.to_str().ok())
68        .unwrap_or("");
69    let extension = media_extension(mime, &uri)?;
70    if response
71        .headers()
72        .get("content-encoding")
73        .is_some_and(|value| value != "identity")
74    {
75        return Err("Format de téléchargement du podcast non pris en charge.".into());
76    }
77    let length = response
78        .headers()
79        .get("content-length")
80        .and_then(|v| v.to_str().ok())
81        .and_then(|v| v.parse::<u64>().ok());
82    if length.is_some_and(|value| value == 0 || value > MAX_EPISODE) {
83        return Err("Épisode vide ou trop volumineux (limite : 1 Go).".into());
84    }
85    publish_authorized(
86        folder,
87        title,
88        extension,
89        response.body_mut().as_reader(),
90        length,
91        MAX_EPISODE,
92        admit,
93    )
94}
95fn media_extension<'a>(mime: &str, uri: &'a Uri) -> Result<&'a str, String> {
96    match mime
97        .split(';')
98        .next()
99        .unwrap_or("")
100        .trim()
101        .to_ascii_lowercase()
102        .as_str()
103    {
104        "audio/mpeg" | "audio/mp3" => Ok("mp3"),
105        "audio/mp4" | "audio/x-m4a" => Ok("m4a"),
106        "video/mp4" => Ok("mp4"),
107        "audio/ogg" | "application/ogg" => Ok("ogg"),
108        "audio/opus" => Ok("opus"),
109        "audio/aac" | "audio/aacp" => Ok("aac"),
110        "audio/wav" | "audio/x-wav" => Ok("wav"),
111        "audio/flac" | "audio/x-flac" => Ok("flac"),
112        "" | "application/octet-stream" | "binary/octet-stream" => {
113            let extension = uri.path().rsplit('.').next().unwrap_or("");
114            if [
115                "mp3", "m4a", "mp4", "ogg", "oga", "opus", "aac", "wav", "flac",
116            ]
117            .contains(&extension)
118            {
119                Ok(extension)
120            } else {
121                Err("Format de téléchargement du podcast non pris en charge.".into())
122            }
123        }
124        _ => Err("Format de téléchargement du podcast non pris en charge.".into()),
125    }
126}
127#[cfg(test)]
128fn publish_episode(
129    folder: &Path,
130    title: &str,
131    extension: &str,
132    reader: impl Read,
133    expected: Option<u64>,
134    limit: u64,
135) -> Result<PathBuf, String> {
136    publish_authorized(folder, title, extension, reader, expected, limit, || Ok(()))
137}
138fn publish_authorized(
139    folder: &Path,
140    title: &str,
141    extension: &str,
142    reader: impl Read,
143    expected: Option<u64>,
144    limit: u64,
145    admit: impl FnOnce() -> Result<(), String>,
146) -> Result<PathBuf, String> {
147    static NEXT: AtomicU64 = AtomicU64::new(0);
148    fs::create_dir_all(folder).map_err(|_| "Dossier de téléchargement inaccessible.")?;
149    let stem: String = title
150        .chars()
151        .take(80)
152        .map(|ch| {
153            if ch.is_alphanumeric() || matches!(ch, ' ' | '-' | '_') {
154                ch
155            } else {
156                '-'
157            }
158        })
159        .collect();
160    let stem = stem.trim_matches([' ', '-', '_']);
161    let stem = if stem.is_empty() { "podcast" } else { stem };
162    let temporary = folder.join(format!(
163        ".phemios-podcast-{}-{}.part",
164        std::process::id(),
165        NEXT.fetch_add(1, Ordering::Relaxed)
166    ));
167    let mut options = fs::OpenOptions::new();
168    options.write(true).create_new(true);
169    #[cfg(unix)]
170    {
171        use std::os::unix::fs::OpenOptionsExt;
172        options.mode(0o600);
173    }
174    let mut file = options
175        .open(&temporary)
176        .map_err(|_| "Téléchargement impossible.")?;
177    let result = (|| {
178        let count = io::copy(&mut reader.take(limit.saturating_add(1)), &mut file)
179            .map_err(|_| "Téléchargement interrompu.")?;
180        if count == 0 || count > limit || expected.is_some_and(|length| length != count) {
181            return Err("Épisode incomplet ou trop volumineux (limite : 1 Go).".into());
182        }
183        file.sync_all().map_err(|_| "Téléchargement interrompu.")?;
184        drop(file);
185        admit()?;
186        for attempt in 0..1000 {
187            let suffix = if attempt == 0 {
188                String::new()
189            } else {
190                format!("-{attempt}")
191            };
192            let path = folder.join(format!("{stem}{suffix}.{extension}"));
193            match fs::hard_link(&temporary, &path) {
194                Ok(()) => return Ok(path),
195                Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
196                Err(_) => return Err("Téléchargement impossible.".into()),
197            }
198        }
199        Err("Trop de copies de cet épisode existent.".into())
200    })();
201    let _ = fs::remove_file(&temporary);
202    result
203}
204#[cfg(test)]
205#[path = "podcast_download_tests.rs"]
206mod tests;