1use std::{
2 collections::BTreeSet,
3 error::Error,
4 path::{Path, PathBuf},
5 str::FromStr,
6};
7
8use mime::Mime;
9use reqwest::header::CONTENT_TYPE;
10use serde::{de::DeserializeOwned, Serialize};
11
12use crate::{
13 api::{BoardPosts, GlowficError, PostInBoard, Replies},
14 types::{Continuity, Icon, Thread},
15 utils::{
16 extension_to_image_mime, guess_image_mime, http_client, mime_to_image_extension, url_hash,
17 },
18 Board, Post, Reply,
19};
20
21const CACHE_ROOT: &str = "./cache";
22
23impl Board {
24 fn cache_key(id: u64) -> PathBuf {
25 format!("{CACHE_ROOT}/boards/{id}.json").into()
26 }
27
28 pub async fn get_cached(
29 id: u64,
30 invalidate_cache: bool,
31 ) -> Result<Result<Self, Vec<GlowficError>>, Box<dyn Error>> {
32 get_cached_glowfic(&Self::url(id), &Self::cache_key(id), invalidate_cache).await
33 }
34}
35
36impl Post {
37 fn cache_key(id: u64) -> PathBuf {
38 format!("{CACHE_ROOT}/posts/{id}/post.json").into()
39 }
40
41 pub async fn get_cached(
42 id: u64,
43 invalidate_cache: bool,
44 ) -> Result<Result<Self, Vec<GlowficError>>, Box<dyn Error>> {
45 get_cached_glowfic(&Self::url(id), &Self::cache_key(id), invalidate_cache).await
46 }
47}
48
49impl Replies {
50 fn cache_key(id: u64) -> PathBuf {
51 format!("{CACHE_ROOT}/posts/{id}/replies.json").into()
52 }
53
54 pub async fn get_all_cached(
55 id: u64,
56 invalidate_cache: bool,
57 ) -> Result<Result<Vec<Reply>, Vec<GlowficError>>, Box<dyn Error>> {
58 let cache_path = Self::cache_key(id);
59
60 if !invalidate_cache {
61 if let Ok(data) = std::fs::read(&cache_path) {
62 let parsed: Result<Self, Vec<GlowficError>> =
63 serde_json::from_slice(&data).unwrap();
64
65 if let Ok(replies) = parsed {
66 return Ok(Ok(replies.0));
67 }
68 }
69 }
70
71 let response = Self::get_all(id).await?;
72
73 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
74 write_if_changed(&cache_path, serde_json::to_vec_pretty(&response).unwrap()).unwrap();
75
76 Ok(response)
77 }
78}
79
80impl BoardPosts {
81 fn cache_key(id: u64) -> PathBuf {
82 format!("{CACHE_ROOT}/boards/{id}/posts.json").into()
83 }
84
85 pub async fn get_all_cached(
86 id: u64,
87 invalidate_cache: bool,
88 ) -> Result<Result<Vec<PostInBoard>, Vec<GlowficError>>, Box<dyn Error>> {
89 let cache_path = Self::cache_key(id);
90
91 if !invalidate_cache {
92 if let Ok(data) = std::fs::read(&cache_path) {
93 let parsed: Result<Vec<PostInBoard>, Vec<GlowficError>> =
94 serde_json::from_slice(&data).unwrap();
95
96 if let Ok(posts) = parsed {
97 return Ok(Ok(posts));
98 }
99 }
100 }
101
102 let response = Self::get_all(id).await?;
103
104 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
105 write_if_changed(&cache_path, serde_json::to_vec_pretty(&response).unwrap()).unwrap();
106
107 Ok(response)
108 }
109}
110
111impl Icon {
112 fn cache_key(id: u64, extension: &str) -> PathBuf {
113 format!("{CACHE_ROOT}/images/glowfic_{id}.{extension}").into()
115 }
116
117 pub async fn download_cached(
118 &self,
119 invalidate_cache: bool,
120 ) -> Result<(Mime, Vec<u8>), Box<dyn Error>> {
121 let Self { id, url, .. } = self;
122
123 let Some(url) = url else {
124 return Err("No url provided for this icon".into());
125 };
126
127 if !invalidate_cache {
128 if let Ok((mime, data)) = read_image_file(Self::cache_key(*id, "*")) {
129 return Ok((mime, data));
130 }
131 }
132
133 log::info!("Downloading icon {id} from {url}");
134
135 let (mime, data) = download_image(url).await?;
136
137 let mime = guess_image_mime(&data).unwrap_or(mime);
138
139 let extension = mime_to_image_extension(&mime).ok_or(format!("Invalid mime: {mime}"))?;
140
141 let cache_path = Self::cache_key(*id, &extension);
142 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
143 write_if_changed(cache_path, &data).unwrap();
144
145 Ok((mime, data))
146 }
147}
148
149impl Thread {
150 pub async fn get_cached(
151 id: u64,
152 invalidate_cache: bool,
153 ) -> Result<Result<Self, Vec<GlowficError>>, Box<dyn Error>> {
154 let post = match Post::get_cached(id, invalidate_cache).await? {
155 Ok(post) => post,
156 Err(errors) => return Ok(Err(errors)),
157 };
158 let replies = match Replies::get_all_cached(id, invalidate_cache).await? {
159 Ok(replies) => replies,
160 Err(errors) => return Ok(Err(errors)),
161 };
162
163 Ok(Ok(Self { post, replies }))
164 }
165 pub async fn cache_all_icons(&self, invalidate_cache: bool) {
166 let icons: BTreeSet<_> = self.icons().collect();
167
168 for icon in icons {
169 if let Err(e) = icon.download_cached(invalidate_cache).await {
170 log::info!("{e:?}");
171 }
172 }
173 for url in self.image_urls() {
174 if let Err(e) = download_cached_image(&url, invalidate_cache).await {
175 log::info!("{e:?}");
176 }
177 }
178 }
179}
180
181impl Continuity {
182 pub async fn get_cached(
183 id: u64,
184 invalidate_cache: bool,
185 ) -> Result<Result<Self, Vec<GlowficError>>, Box<dyn Error>> {
186 let board = match Board::get_cached(id, invalidate_cache).await? {
187 Ok(board) => board,
188 Err(errors) => return Ok(Err(errors)),
189 };
190 let threads = match BoardPosts::get_all_cached(id, invalidate_cache).await? {
191 Ok(board_posts) => {
192 let mut threads = vec![];
193 for p in board_posts {
194 log::info!("Downloading post {} - {}", p.id, &p.subject);
195 let thread = match Thread::get_cached(p.id, invalidate_cache).await? {
196 Ok(thread) => thread,
197 Err(e) => return Ok(Err(e)),
198 };
199 threads.push(thread);
200 }
201 threads
202 }
203 Err(errors) => return Ok(Err(errors)),
204 };
205
206 Ok(Ok(Self { board, threads }))
207 }
208 pub async fn cache_all_icons(&self, invalidate_cache: bool) {
209 let icons: BTreeSet<_> = self.threads.iter().flat_map(|t| t.icons()).collect();
210 for icon in icons {
211 if let Err(e) = icon.download_cached(invalidate_cache).await {
212 log::info!("{e:?}");
213 }
214 }
215
216 let urls: BTreeSet<_> = self.threads.iter().flat_map(|t| t.image_urls()).collect();
217 for url in urls {
218 if let Err(e) = download_cached_image(&url, invalidate_cache).await {
219 log::info!("{e:?}");
220 }
221 }
222 }
223}
224
225pub async fn download_cached_image(
226 url: &str,
227 invalidate_cache: bool,
228) -> Result<(Mime, Vec<u8>), Box<dyn Error>> {
229 fn image_cache_key(hash: &str, extension: &str) -> PathBuf {
230 format!("{CACHE_ROOT}/images/hash_{hash}.{extension}").into()
231 }
232
233 let hash = url_hash(url);
234
235 if !invalidate_cache {
236 if let Ok((mime, data)) = read_image_file(image_cache_key(&hash, "*")) {
237 return Ok((mime, data));
238 }
239 }
240
241 log::info!("Downloading image {hash} from {url}");
242
243 let (mime, data) = download_image(url).await?;
244
245 let mime = guess_image_mime(&data).unwrap_or(mime);
246
247 let extension = mime_to_image_extension(&mime).ok_or(format!("Invalid mime: {mime}"))?;
248
249 let cache_path = image_cache_key(&hash, &extension);
250 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
251 write_if_changed(cache_path, &data).unwrap();
252
253 Ok((mime, data))
254}
255
256async fn get_cached_glowfic<T>(
257 url: &str,
258 cache_path: &Path,
259 invalidate_cache: bool,
260) -> Result<Result<T, Vec<GlowficError>>, Box<dyn Error>>
261where
262 T: DeserializeOwned + Serialize,
263{
264 if !invalidate_cache {
265 if let Ok(data) = std::fs::read(cache_path) {
266 let parsed: Result<T, Vec<GlowficError>> = serde_json::from_slice(&data).unwrap();
267 if parsed.is_ok() {
268 return Ok(parsed);
269 }
270 }
271 }
272 let response = crate::api::get_glowfic(url).await?;
273
274 std::fs::create_dir_all(cache_path.parent().unwrap()).unwrap();
275 write_if_changed(cache_path, serde_json::to_vec_pretty(&response).unwrap()).unwrap();
276
277 Ok(response)
278}
279
280pub async fn download_image(url: &str) -> Result<(Mime, Vec<u8>), reqwest::Error> {
281 let response = http_client().get(url).send().await?;
282
283 let content_type = response.headers().get(CONTENT_TYPE).unwrap();
284 let mime = Mime::from_str(content_type.to_str().unwrap()).unwrap();
285
286 let data = response.bytes().await?;
287
288 Ok((mime, data.to_vec()))
289}
290fn read_image_file(path: PathBuf) -> Result<(Mime, Vec<u8>), Box<dyn Error>> {
291 let files: Vec<_> = glob::glob(path.to_str().unwrap()).unwrap().collect();
292
293 match &*files {
294 [Ok(path)] => {
296 let data = std::fs::read(path).unwrap();
297
298 let extension = path.extension().unwrap().to_str().unwrap();
299 if let Some(mime) = extension_to_image_mime(extension) {
300 Ok((mime, data))
301 } else {
302 Err("Unsupprted extension in cached image.")?
303 }
304 }
305
306 [_one, _two, _rest @ ..] => {
310 #[allow(clippy::manual_flatten)] for file in files {
312 if let Ok(file) = file {
313 std::fs::remove_file(file).unwrap();
314 }
315 }
316
317 Err(format!("Found multiple files for image ({path:?}). Cleaning them up. No further action needed."))?
318 }
319
320 _ => Err("Did not find a match for image in the cache.")?,
321 }
322}
323
324pub fn write_if_changed(path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> std::io::Result<()> {
326 match std::fs::read(path.as_ref()) {
327 Ok(data) if data == contents.as_ref() => Ok(()),
328 Ok(_) | Err(_) => std::fs::write(path, contents),
329 }
330}