Skip to main content

modde_sources/mediafire/
mod.rs

1//! `MediaFire` download source: resolves a file page to its direct URL, then
2//! delegates the transfer to the direct-HTTP source.
3
4use 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
16/// `MediaFire` download source. Resolves the file page to its underlying direct URL,
17/// then delegates the actual transfer to [`DirectSource`].
18pub struct MediaFireSource {
19    client: Client,
20    direct: DirectSource,
21}
22
23impl MediaFireSource {
24    /// Create a source that resolves and downloads over the given HTTP `client`.
25    #[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
93/// Extracts the actual download URL from a `MediaFire` file page.
94///
95/// The page contains an anchor of the form
96/// `<a aria-label="Download file" class="input popsok …" href="https://download…mediafire.com/…">`.
97fn 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}