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("&", "&")
172 .replace("<", "<")
173 .replace(">", ">")
174 .replace(""", "\"")
175 .replace("'", "'")
176 .replace("'", "'");
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;