modde_sources/mediafire/
mod.rs1use std::path::Path;
5
6use anyhow::{Context, Result};
7use reqwest::Client;
8use tracing::{debug, info};
9
10use modde_core::manifest::wabbajack::DownloadDirective;
11
12use crate::direct::DirectSource;
13use crate::error::{SourceError, SourceResult, status_error};
14use crate::traits::{DownloadHandle, DownloadSource, ProgressCallback, VerifiedFile};
15
16pub struct MediaFireSource {
19 client: Client,
20 direct: DirectSource,
21}
22
23impl MediaFireSource {
24 #[must_use]
26 pub fn new(client: Client) -> Self {
27 Self {
28 direct: DirectSource::new(client.clone()),
29 client,
30 }
31 }
32}
33
34impl DownloadSource for MediaFireSource {
35 fn can_handle(&self, directive: &DownloadDirective) -> bool {
36 matches!(directive, DownloadDirective::MediaFire { .. })
37 }
38
39 async fn resolve(&self, directive: &DownloadDirective) -> SourceResult<DownloadHandle> {
40 let DownloadDirective::MediaFire { url, hash } = directive else {
41 return Err(SourceError::other(anyhow::anyhow!(
42 "not a MediaFire directive"
43 )));
44 };
45
46 let direct_url = scrape_mediafire_direct(&self.client, url)
47 .await
48 .map_err(SourceError::other)?;
49 info!(page = %url, direct = %direct_url, "resolved MediaFire direct URL");
50
51 Ok(DownloadHandle {
52 url: direct_url,
53 candidate_urls: Vec::new(),
54 headers: Default::default(),
55 expected_hash: *hash,
56 size_hint: None,
57 })
58 }
59
60 async fn download_with_progress(
61 &self,
62 handle: DownloadHandle,
63 dest: &Path,
64 progress: ProgressCallback,
65 ) -> SourceResult<VerifiedFile> {
66 self.direct
67 .download_with_progress(handle, dest, progress)
68 .await
69 }
70}
71
72async fn scrape_mediafire_direct(client: &Client, page_url: &str) -> SourceResult<String> {
73 let html = status_error(
74 client
75 .get(page_url)
76 .header(
77 "User-Agent",
78 "Mozilla/5.0 (X11; Linux x86_64) modde/wabbajack",
79 )
80 .send()
81 .await?,
82 )?
83 .text()
84 .await?;
85
86 extract_mediafire_direct(&html)
87 .with_context(|| {
88 format!("could not find MediaFire direct download link on page {page_url}")
89 })
90 .map_err(SourceError::other)
91}
92
93fn extract_mediafire_direct(html: &str) -> Result<String> {
98 let needle = "aria-label=\"Download file\"";
99 let pos = html
100 .find(needle)
101 .ok_or_else(|| anyhow::anyhow!("MediaFire page is missing the 'Download file' anchor"))?;
102 debug!("found mediafire download anchor at byte {pos}");
103
104 let region_start = html[..pos].rfind("<a").unwrap_or(0);
105 let region_end = pos
106 + html[pos..]
107 .find('>')
108 .ok_or_else(|| anyhow::anyhow!("malformed anchor on MediaFire page"))?;
109 let anchor = &html[region_start..=region_end];
110
111 let href_marker = "href=\"";
112 let href_pos = anchor
113 .find(href_marker)
114 .ok_or_else(|| anyhow::anyhow!("MediaFire anchor missing href"))?;
115 let href_start = href_pos + href_marker.len();
116 let href_end_rel = anchor[href_start..]
117 .find('"')
118 .ok_or_else(|| anyhow::anyhow!("MediaFire anchor href is unterminated"))?;
119 Ok(anchor[href_start..href_start + href_end_rel].to_string())
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn extracts_direct_link_from_popsok_button() {
128 let html = r#"<html><body>
129 <a aria-label="Download file" class="input popsok btn-prompt" href="https://download123.mediafire.com/abc/file.7z" id="downloadButton">
130 <span class="dl-btn-label">Download (123MB)</span>
131 </a>
132 </body></html>"#;
133 let url = extract_mediafire_direct(html).expect("should parse");
134 assert_eq!(url, "https://download123.mediafire.com/abc/file.7z");
135 }
136
137 #[test]
138 fn errors_when_no_download_button() {
139 let html = "<html><body>nope</body></html>";
140 assert!(extract_mediafire_direct(html).is_err());
141 }
142}