1use serde_json::Value;
3use std::fs::{self, OpenOptions};
4use std::io::Write;
5use std::path::{Path, PathBuf};
6pub fn publish(folder: &Path, id: &str, response: &Value) -> Result<PathBuf, String> {
8 if !id.strip_prefix("playlist-").is_some_and(|value| {
9 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
10 }) || response["id"].as_str() != Some(id)
11 {
12 return Err("Identité de M3U invalide.".into());
13 }
14 let content = response["content"]
15 .as_str()
16 .filter(|value| value.starts_with("#EXTM3U") && value.len() <= 8 * 1024 * 1024)
17 .ok_or("Contenu M3U invalide.")?;
18 publish_named(folder, id, "m3u", content.as_bytes())
19}
20pub fn publish_library_m3u(folder: &Path, id: &str, response: &Value) -> Result<PathBuf, String> {
23 if !id.strip_prefix("m3u-file-").is_some_and(|value| {
24 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
25 }) || response["id"].as_str() != Some(id)
26 {
27 return Err("Identité de M3U invalide.".into());
28 }
29 let filename = response["filename"]
30 .as_str()
31 .ok_or("Contenu M3U invalide.")?
32 .to_ascii_lowercase();
33 let extension = if filename.ends_with(".m3u8") {
34 "m3u8"
35 } else if filename.ends_with(".m3u") {
36 "m3u"
37 } else {
38 return Err("Contenu M3U invalide.".into());
39 };
40 let content = response["content"]
41 .as_str()
42 .filter(|value| value.len() <= 8 * 1024 * 1024 && !value.contains('\0'))
43 .ok_or("Contenu M3U invalide.")?;
44 let entries = content
45 .trim_start_matches('\u{feff}')
46 .lines()
47 .map(str::trim)
48 .filter(|line| !line.is_empty() && !line.starts_with('#'))
49 .count();
50 if entries > 4096 || response["track_count"].as_u64() != Some(entries as u64) {
51 return Err("Contenu M3U invalide.".into());
52 }
53 publish_named(folder, id, extension, content.as_bytes())
54}
55
56fn publish_named(
57 folder: &Path,
58 stem: &str,
59 extension: &str,
60 content: &[u8],
61) -> Result<PathBuf, String> {
62 fs::create_dir_all(folder)
63 .map_err(|_| "Dossier de téléchargement inaccessible.".to_string())?;
64 for attempt in 0..1000 {
65 let filename = if attempt == 0 {
66 format!("{stem}.{extension}")
67 } else {
68 format!("{stem}-{attempt}.{extension}")
69 };
70 let path = folder.join(filename);
71 let mut options = OpenOptions::new();
72 options.write(true).create_new(true);
73 #[cfg(unix)]
74 {
75 use std::os::unix::fs::OpenOptionsExt;
76 options.mode(0o600);
77 }
78 let mut file = match options.open(&path) {
79 Ok(file) => file,
80 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
81 Err(_) => return Err("Téléchargement impossible.".into()),
82 };
83 if file
84 .write_all(content)
85 .and_then(|()| file.sync_all())
86 .is_err()
87 {
88 drop(file);
89 let _ = fs::remove_file(&path);
90 return Err("Téléchargement interrompu.".into());
91 }
92 return Ok(path);
93 }
94 Err("Trop de copies de ce fichier existent dans les téléchargements.".into())
95}
96pub fn publish_analysis(
98 folder: &Path,
99 reference: &str,
100 response: &Value,
101) -> Result<PathBuf, String> {
102 use std::hash::{Hash, Hasher};
103 if reference.len() > 4096
104 || !["track:", "release:"].iter().any(|kind| {
105 reference
106 .strip_prefix(kind)
107 .is_some_and(|value| !value.is_empty())
108 })
109 {
110 return Err("Référence d’analyse invalide.".into());
111 }
112 let files = response["report"]["files"]
113 .as_array()
114 .filter(|files| !files.is_empty() && files.len() <= 2048)
115 .ok_or("Rapport d’analyse incomplet.")?;
116 if response["format"] != "flaccompagnon-report"
117 || !response["version"]
118 .as_u64()
119 .is_some_and(|version| version <= 1)
120 || !response["report"]["root"].is_string()
121 || !response["report"]["has_flac"].is_boolean()
122 || files.iter().any(|file| !file["path"].is_string())
123 {
124 return Err("Rapport FlacCompagnon invalide.".into());
125 }
126 let content = serde_json::to_vec_pretty(response).map_err(|_| "Rapport d’analyse invalide.")?;
127 if content.len() > 16 * 1024 * 1024 {
128 return Err("Rapport d’analyse trop volumineux.".into());
129 }
130 let mut hash = std::collections::hash_map::DefaultHasher::new();
131 reference.hash(&mut hash);
132 let kind = if reference.starts_with("track:") {
133 "titre"
134 } else {
135 "album"
136 };
137 publish_named(
138 folder,
139 &format!("flaccompagnon-{kind}-{:016x}", hash.finish()),
140 "json",
141 &content,
142 )
143}
144pub fn publish_spectrum(folder: &Path, reference: &str, bytes: &[u8]) -> Result<PathBuf, String> {
146 use std::hash::{Hash, Hasher};
147 if reference.len() > 4096
148 || !reference
149 .strip_prefix("track:")
150 .is_some_and(|s| !s.is_empty())
151 {
152 return Err("Référence de spectre invalide.".into());
153 }
154 if bytes.len() > 8 * 1024 * 1024 {
155 return Err("Spectre trop volumineux.".into());
156 }
157 let extension = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
158 "png"
159 } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
160 "jpg"
161 } else {
162 return Err("Format de spectre invalide.".into());
163 };
164 let mut hash = std::collections::hash_map::DefaultHasher::new();
165 reference.hash(&mut hash);
166 publish_named(
167 folder,
168 &format!("spectre-{:016x}", hash.finish()),
169 extension,
170 bytes,
171 )
172}
173#[cfg(test)]
174#[path = "download_tests.rs"]
175mod tests;
176
177pub fn publish_wikipedia(
180 folder: &Path,
181 language: &str,
182 page_id: u64,
183 document: &str,
184) -> Result<PathBuf, String> {
185 if !matches!(language, "fr" | "en")
186 || page_id == 0
187 || page_id > 9_007_199_254_740_991
188 || document.len() > 1024 * 1024
189 || !document.starts_with(&format!(
190 "\u{feff}Wikipédia · {} · {page_id}\n",
191 language.to_uppercase()
192 ))
193 || document
194 .chars()
195 .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
196 {
197 return Err("Document Wikipédia invalide ou trop volumineux.".into());
198 }
199 publish_named(
200 folder,
201 &format!("wikipedia-{language}-{page_id}"),
202 "txt",
203 document.as_bytes(),
204 )
205}
206
207pub fn publish_note(
210 folder: &Path,
211 reference: &str,
212 response: &Value,
213 detail: &Value,
214) -> Result<PathBuf, String> {
215 if reference.len() > 4096
216 || !["artist:", "release:", "track:"].iter().any(|prefix| {
217 reference
218 .strip_prefix(prefix)
219 .is_some_and(|value| !value.is_empty())
220 })
221 || response["reference"].as_str() != Some(reference)
222 || response["annotation"]["reference"].as_str() != Some(reference)
223 || detail["reference"].as_str() != Some(reference)
224 {
225 return Err("Identité de note personnelle invalide.".into());
226 }
227 let note = response["annotation"]["note"]
228 .as_str()
229 .filter(|text| !text.trim().is_empty() && text.len() <= 1024 * 1024 && !text.contains('\0'))
230 .ok_or("Aucune note personnelle disponible à télécharger.")?;
231 let field = if reference.starts_with("artist:") {
232 "name"
233 } else {
234 "title"
235 };
236 let title = detail[field]
237 .as_str()
238 .filter(|title| !title.trim().is_empty() && title.len() <= 4096)
239 .ok_or("Nom de note personnelle invalide.")?;
240 let mut safe_title = String::new();
242 for character in title.chars() {
243 let character = if character.is_alphanumeric()
244 || matches!(character, ' ' | '-' | '_' | '(' | ')' | '\'')
245 {
246 character
247 } else {
248 '-'
249 };
250 if safe_title.len() + character.len_utf8() > 180 {
251 break;
252 }
253 safe_title.push(character);
254 }
255 let title = safe_title.trim_matches([' ', '-']);
256 if title.is_empty() {
257 return Err("Nom de note personnelle invalide.".into());
258 }
259 publish_named(folder, &format!("Note-{title}"), "md", note.as_bytes())
260}