1use 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;
12pub fn download_episode(folder: &Path, url: &str, title: &str) -> Result<PathBuf, String> {
14 download_episode_authorized(folder, url, title, || Ok(()))
15}
16pub 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;