Skip to main content

lavende_core/sources/
reddit.rs

1pub mod manager {
2    use super::track::RedditTrack;
3    use crate::{
4        protocol::tracks::{LoadResult, Track, TrackInfo},
5        sources::{playable_track::BoxedTrack, plugin::SourcePlugin},
6    };
7    use async_trait::async_trait;
8    use regex::Regex;
9    use serde_json::Value;
10    use std::sync::{Arc, OnceLock};
11    static PATH_EXTRACTOR: OnceLock<Regex> = OnceLock::new();
12    const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
13    pub struct RedditSource {
14        http: Arc<reqwest::Client>,
15    }
16    impl RedditSource {
17        pub fn new(
18            _config: Option<crate::config::sources::RedditConfig>,
19            http: Arc<reqwest::Client>,
20        ) -> Result<Self, String> {
21            Ok(Self { http })
22        }
23        async fn acquire_metadata_packet(
24            &self,
25            resource_url: &str,
26        ) -> Option<(TrackInfo, Option<String>)> {
27            let mut target_endpoint = resource_url.to_owned();
28            if resource_url.contains("/s/") || resource_url.contains("/video/") {
29                target_endpoint = self.resolve_canonical_link(resource_url).await?;
30            }
31            let metadata_url = if target_endpoint.ends_with('/') {
32                format!("{}.json", &target_endpoint[..target_endpoint.len() - 1])
33            } else {
34                format!("{}.json", target_endpoint)
35            };
36            let raw_json = self.fetch_payload(&metadata_url).await?;
37            let post_listing = match raw_json.as_array().and_then(|a| a.first()) {
38                Some(l) => l,
39                None => return None,
40            };
41            let post_data = match post_listing["data"]["children"]
42                .as_array()
43                .and_then(|a| a.first())
44                .and_then(|c| c["data"].as_object())
45            {
46                Some(d) => d,
47                None => return None,
48            };
49            let entry_name = match post_data.get("title").and_then(|v| v.as_str()) {
50                Some(t) => self.unescape_html(t),
51                None => return None,
52            };
53            let creator = format!(
54                "u/{}",
55                post_data
56                    .get("author")
57                    .and_then(|v| v.as_str())
58                    .unwrap_or("unknown")
59            );
60            let preview_img = post_data
61                .get("preview")
62                .and_then(|p| p.get("images"))
63                .and_then(|i| i.as_array())
64                .and_then(|i| i.first())
65                .and_then(|i| i.get("source"))
66                .and_then(|s| s.get("url"))
67                .and_then(|u| u.as_str())
68                .or_else(|| post_data.get("thumbnail").and_then(|v| v.as_str()))
69                .filter(|s| s.starts_with("http"))
70                .map(|s| self.unescape_html(s));
71            let media_spec = match post_data
72                .get("secure_media")
73                .and_then(|sm| sm.get("reddit_video"))
74                .or_else(|| post_data.get("media").and_then(|m| m.get("reddit_video")))
75            {
76                Some(ms) => ms,
77                None => return None,
78            };
79            let duration = match media_spec.get("duration").and_then(|v| v.as_f64()) {
80                Some(d) => (d * 1000.0) as u64,
81                None => 0,
82            };
83            let base_stream = match media_spec.get("fallback_url").and_then(|v| v.as_str()) {
84                Some(u) => u.split('?').next().unwrap_or(u),
85                None => return None,
86            };
87            let rid = self.identify_resource(resource_url);
88            let audio_stream = self.discover_audio_asset(base_stream).await;
89            Some((
90                TrackInfo {
91                    identifier: rid,
92                    is_seekable: true,
93                    author: creator,
94                    length: duration,
95                    is_stream: false,
96                    position: 0,
97                    title: entry_name,
98                    uri: Some(resource_url.to_owned()),
99                    artwork_url: preview_img,
100                    isrc: None,
101                    source_name: "reddit".to_owned(),
102                },
103                audio_stream,
104            ))
105        }
106        async fn resolve_canonical_link(&self, url: &str) -> Option<String> {
107            let outcome = self
108                .http
109                .head(url)
110                .header(reqwest::header::USER_AGENT, USER_AGENT)
111                .send()
112                .await
113                .ok()?;
114            if outcome.status().is_redirection() {
115                outcome
116                    .headers()
117                    .get(reqwest::header::LOCATION)?
118                    .to_str()
119                    .ok()
120                    .map(|l| l.split('?').next().unwrap_or(l).to_owned())
121            } else {
122                Some(url.to_owned())
123            }
124        }
125        async fn fetch_payload(&self, url: &str) -> Option<Value> {
126            self.http
127                .get(url)
128                .header(reqwest::header::USER_AGENT, USER_AGENT)
129                .header(reqwest::header::ACCEPT, "application/json")
130                .send()
131                .await
132                .ok()?
133                .json()
134                .await
135                .ok()
136        }
137        async fn discover_audio_asset(&self, primary_url: &str) -> Option<String> {
138            let anchor = primary_url.split('_').next()?;
139            let pool = vec![
140                format!("{}_audio.mp4", anchor),
141                format!("{}_AUDIO_128.mp4", anchor),
142                format!("{}_audio.mp3", anchor),
143                primary_url.replace("DASH_", "DASH_audio"),
144            ];
145            for probe_url in pool {
146                if self
147                    .http
148                    .head(&probe_url)
149                    .header(reqwest::header::USER_AGENT, USER_AGENT)
150                    .send()
151                    .await
152                    .is_ok_and(|res| res.status().is_success())
153                {
154                    return Some(probe_url);
155                }
156            }
157            None
158        }
159        fn identify_resource(&self, link: &str) -> String {
160            let pattern = PATH_EXTRACTOR
161                .get_or_init(|| Regex::new(r"/(?:comments|video|s)/([^/?#]+)").unwrap());
162            if let Some(hits) = pattern.captures(link) {
163                return hits[1].to_owned();
164            }
165            link.to_owned()
166        }
167        fn unescape_html(&self, input: &str) -> String {
168            let mut result = input.to_owned();
169            loop {
170                let next = result
171                    .replace("&amp;", "&")
172                    .replace("&lt;", "<")
173                    .replace("&gt;", ">")
174                    .replace("&quot;", "\"")
175                    .replace("&#39;", "'")
176                    .replace("&apos;", "'");
177                if next == result {
178                    break;
179                }
180                result = next;
181            }
182            result
183        }
184    }
185    #[async_trait]
186    impl SourcePlugin for RedditSource {
187        fn name(&self) -> &str {
188            "reddit"
189        }
190        fn can_handle(&self, identifier: &str) -> bool {
191            identifier.contains("reddit.com/") || identifier.contains("v.redd.it/")
192        }
193        async fn load(
194            &self,
195            identifier: &str,
196            _routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
197        ) -> LoadResult {
198            match self.acquire_metadata_packet(identifier).await {
199                Some((meta, _)) => LoadResult::Track(Track::new(meta)),
200                None => LoadResult::Empty {},
201            }
202        }
203        async fn get_track(
204            &self,
205            identifier: &str,
206            routeplanner: Option<Arc<dyn crate::routeplanner::RoutePlanner>>,
207        ) -> Option<BoxedTrack> {
208            let (meta, audio_stream) = self.acquire_metadata_packet(identifier).await?;
209            Some(Arc::new(RedditTrack {
210                client: self.http.clone(),
211                uri: meta.uri.unwrap_or_else(|| identifier.to_owned()),
212                audio_url: audio_stream,
213                local_addr: routeplanner.and_then(|rp| rp.get_address()),
214            }))
215        }
216    }
217}
218pub mod track {
219    use crate::sources::{
220        http::HttpTrack,
221        playable_track::{PlayableTrack, ResolvedTrack},
222    };
223    use async_trait::async_trait;
224    use std::{net::IpAddr, sync::Arc};
225    use tracing::debug;
226    pub struct RedditTrack {
227        pub client: Arc<reqwest::Client>,
228        pub uri: String,
229        pub audio_url: Option<String>,
230        pub local_addr: Option<IpAddr>,
231    }
232    #[async_trait]
233    impl PlayableTrack for RedditTrack {
234        async fn resolve(&self) -> Result<ResolvedTrack, String> {
235            let url = self
236                .audio_url
237                .clone()
238                .ok_or_else(|| "No audio stream available for Reddit track".to_string())?;
239            debug!("Reddit playback URL: {url}");
240            HttpTrack {
241                url,
242                local_addr: self.local_addr,
243                proxy: None,
244            }
245            .resolve()
246            .await
247        }
248    }
249}
250pub use manager::RedditSource;
251pub use track::RedditTrack;