1use std::{
2 collections::HashMap,
3 convert::Infallible,
4 net::SocketAddr,
5 sync::{
6 Arc, Mutex,
7 atomic::{AtomicUsize, Ordering},
8 },
9};
10
11use bytes::Bytes;
12use http_body_util::Full;
13use hyper::{
14 Method, Request, Response, StatusCode, body::Incoming, header, server::conn::http1,
15 service::service_fn,
16};
17use hyper_util::rt::TokioIo;
18use reqwest::Client;
19use sha2::{Digest, Sha256};
20use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle};
21use tracing::{debug, info};
22use url::Url;
23
24use crate::{AniError, RequestHeaders, Result, StreamLink, SubtitleTrack};
25
26const MAX_PLAYLIST_BYTES: usize = 2 * 1024 * 1024;
27const MAX_SUBTITLE_BYTES: usize = 2 * 1024 * 1024;
28const MAX_RESOURCES: usize = 16_384;
29const SCAN_LIMIT: usize = 64 * 1024;
30const FALLBACK_SUBTITLE_DURATION_SECONDS: f64 = 30.0 * 60.0;
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33enum ResourceKind {
34 EntryPlaylist,
35 Playlist,
36 Segment,
37 Subtitle,
38 SubtitlePlaylist,
39 Resource,
40}
41
42#[derive(Clone)]
43struct Registered {
44 url: Url,
45 headers: RequestHeaders,
46 kind: ResourceKind,
47 subtitles: Vec<SubtitleTrack>,
48 duration_seconds: Option<f64>,
49}
50
51struct State {
52 client: Client,
53 base: SocketAddr,
54 resources: Mutex<HashMap<String, Registered>>,
55 tokens_by_url: Mutex<HashMap<String, String>>,
56 counter: AtomicUsize,
57 secret: String,
58}
59
60pub struct HlsRelay {
62 shutdown: Option<oneshot::Sender<()>>,
63 task: JoinHandle<()>,
64}
65
66impl Drop for HlsRelay {
67 fn drop(&mut self) {
68 if let Some(shutdown) = self.shutdown.take() {
69 let _ = shutdown.send(());
70 }
71 let _ = &self.task;
74 }
75}
76
77pub async fn relay_stream(stream: &StreamLink) -> Result<(HlsRelay, StreamLink)> {
85 relay_stream_inner(stream, true).await
86}
87
88pub async fn relay_stream_without_hls_subtitles(
99 stream: &StreamLink,
100) -> Result<(HlsRelay, StreamLink)> {
101 relay_stream_inner(stream, false).await
102}
103
104async fn relay_stream_inner(
105 stream: &StreamLink,
106 expose_subtitles_in_hls: bool,
107) -> Result<(HlsRelay, StreamLink)> {
108 if !stream.hls {
109 return Err(AniError::InvalidInput {
110 input: stream.url.clone(),
111 reason: "only HLS streams can be relayed".to_string(),
112 });
113 }
114 let upstream = validate_upstream(&stream.url)?;
115 let listener = TcpListener::bind("127.0.0.1:0").await?;
116 let address = listener.local_addr()?;
117 info!(
118 bind_address = %address,
119 upstream_host = %upstream.host_str().unwrap_or("?"),
120 expose_subtitles_in_hls,
121 subtitle_tracks = stream.subtitles.len(),
122 "starting loopback HLS relay",
123 );
124 let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
125 let state = Arc::new(State {
126 client: Client::builder()
127 .redirect(reqwest::redirect::Policy::limited(8))
128 .build()?,
129 base: address,
130 resources: Mutex::new(HashMap::new()),
131 tokens_by_url: Mutex::new(HashMap::new()),
132 counter: AtomicUsize::new(0),
133 secret: format!("{}-{}", std::process::id(), unix_nanos()),
134 });
135 let token = register(
136 &state,
137 upstream,
138 stream.headers.clone(),
139 ResourceKind::EntryPlaylist,
140 )?;
141 let server_state = Arc::clone(&state);
142 let task = tokio::spawn(async move {
143 loop {
144 tokio::select! {
145 _ = &mut shutdown_rx => break,
146 accepted = listener.accept() => {
147 let Ok((socket, _)) = accepted else { break };
148 let state = Arc::clone(&server_state);
149 tokio::spawn(async move {
150 let service = service_fn(move |request| handle(Arc::clone(&state), request));
151 let _ = http1::Builder::new().serve_connection(TokioIo::new(socket), service).await;
152 });
153 }
154 }
155 }
156 });
157 let mut local = stream.clone();
158 local.url = local_url(address, &token);
159 local.headers = RequestHeaders::default();
160 let mut playlist_subtitles = Vec::with_capacity(local.subtitles.len());
161 for track in &mut local.subtitles {
162 let url = validate_upstream(&track.url)?;
163 let token = register(
164 &state,
165 url.clone(),
166 stream.headers.clone(),
167 ResourceKind::Subtitle,
168 )?;
169 track.url = local_url(address, &token);
170
171 if !expose_subtitles_in_hls {
172 continue;
173 }
174 let duration_seconds = probe_subtitle_duration(&state.client, &url, &stream.headers).await;
175 let playlist_token = register(
176 &state,
177 url,
178 stream.headers.clone(),
179 ResourceKind::SubtitlePlaylist,
180 )?;
181 let mut playlist_track = track.clone();
182 playlist_track.url = local_url(address, &playlist_token);
183 playlist_subtitles.push(playlist_track);
184 if let Some(entry) = state
185 .resources
186 .lock()
187 .expect("relay registry poisoned")
188 .get_mut(&playlist_token)
189 {
190 entry.duration_seconds = duration_seconds;
191 }
192 }
193 if !playlist_subtitles.is_empty()
194 && let Some(entry) = state
195 .resources
196 .lock()
197 .expect("relay registry poisoned")
198 .get_mut(&token)
199 {
200 entry.subtitles = playlist_subtitles;
201 }
202 debug!(
203 local_url = %local.url,
204 bind_address = %address,
205 "HLS relay is ready to serve the rewritten stream URL",
206 );
207 Ok((
208 HlsRelay {
209 shutdown: Some(shutdown_tx),
210 task,
211 },
212 local,
213 ))
214}
215
216async fn handle(
217 state: Arc<State>,
218 request: Request<Incoming>,
219) -> std::result::Result<Response<Full<Bytes>>, Infallible> {
220 let response = match handle_inner(&state, request).await {
221 Ok(response) => response,
222 Err(error) => response(
223 StatusCode::BAD_GATEWAY,
224 "text/plain",
225 error.to_string().into_bytes(),
226 ),
227 };
228 Ok(response)
229}
230
231async fn handle_inner(
232 state: &Arc<State>,
233 request: Request<Incoming>,
234) -> Result<Response<Full<Bytes>>> {
235 if request.method() == Method::OPTIONS {
236 return Ok(Response::builder()
237 .status(StatusCode::NO_CONTENT)
238 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
239 .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
240 .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
241 .header(
242 header::ACCESS_CONTROL_EXPOSE_HEADERS,
243 "Content-Length, Content-Range, Accept-Ranges",
244 )
245 .body(Full::new(Bytes::new()))
246 .expect("valid CORS response"));
247 }
248
249 if request.method() != Method::GET && request.method() != Method::HEAD {
250 return Ok(response(
251 StatusCode::METHOD_NOT_ALLOWED,
252 "text/plain",
253 b"method not allowed".to_vec(),
254 ));
255 }
256 let token = request
257 .uri()
258 .path()
259 .strip_prefix("/r/")
260 .filter(|v| !v.is_empty());
261 let Some(token) = token else {
262 return Ok(response(
263 StatusCode::NOT_FOUND,
264 "text/plain",
265 b"not found".to_vec(),
266 ));
267 };
268 let registered = state
269 .resources
270 .lock()
271 .expect("relay registry poisoned")
272 .get(token)
273 .cloned();
274 let Some(registered) = registered else {
275 return Ok(response(
276 StatusCode::FORBIDDEN,
277 "text/plain",
278 b"invalid relay token".to_vec(),
279 ));
280 };
281 if registered.kind == ResourceKind::SubtitlePlaylist {
282 let segment_token = register(
283 state,
284 registered.url.clone(),
285 registered.headers.clone(),
286 ResourceKind::Subtitle,
287 )?;
288 let body = subtitle_playlist(
289 &local_url(state.base, &segment_token),
290 registered
291 .duration_seconds
292 .unwrap_or(FALLBACK_SUBTITLE_DURATION_SECONDS),
293 );
294 return Ok(if request.method() == Method::HEAD {
295 response(StatusCode::OK, "application/vnd.apple.mpegurl", Vec::new())
296 } else {
297 response(
298 StatusCode::OK,
299 "application/vnd.apple.mpegurl",
300 body.into_bytes(),
301 )
302 });
303 }
304 let mut upstream = state
305 .client
306 .request(request.method().clone(), registered.url.clone());
307 if let Some(value) = ®istered.headers.referer {
308 upstream = upstream.header(header::REFERER, value);
309 }
310 if let Some(value) = ®istered.headers.origin {
311 upstream = upstream.header(header::ORIGIN, value);
312 }
313 for (name, value) in ®istered.headers.extra {
314 upstream = upstream.header(name, value);
315 }
316 let conditional_headers = [header::IF_NONE_MATCH, header::IF_MODIFIED_SINCE];
317 if registered.kind != ResourceKind::Segment
318 && let Some(value) = request.headers().get(header::RANGE)
319 {
320 upstream = upstream.header(header::RANGE, value);
321 }
322 for name in conditional_headers {
323 if let Some(value) = request.headers().get(&name) {
324 upstream = upstream.header(name, value);
325 }
326 }
327 let upstream = upstream.send().await?;
328 let status = upstream.status();
329 let upstream_headers = upstream.headers().clone();
330 let content_type = upstream
331 .headers()
332 .get(header::CONTENT_TYPE)
333 .and_then(|v| v.to_str().ok())
334 .unwrap_or("")
335 .to_owned();
336 if request.method() == Method::HEAD {
337 let corrected_type = corrected_content_type(®istered, &content_type, false);
338 let mut result = response(status, &corrected_type, Vec::new());
339 copy_upstream_headers(
340 &upstream_headers,
341 result.headers_mut(),
342 registered.kind != ResourceKind::Segment,
343 );
344 return Ok(result);
345 }
346 let bytes = upstream.bytes().await?.to_vec();
347 let playlist = matches!(
348 registered.kind,
349 ResourceKind::EntryPlaylist | ResourceKind::Playlist
350 ) || content_type.contains("mpegurl")
351 || registered
352 .url
353 .path()
354 .to_ascii_lowercase()
355 .ends_with(".m3u8")
356 || bytes.starts_with(b"#EXTM3U");
357 if playlist {
358 if bytes.len() > MAX_PLAYLIST_BYTES {
359 return Err(AniError::ProviderResponseSizeExceeded {
360 provider: "HLS relay upstream".to_string(),
361 size: bytes.len(),
362 limit: MAX_PLAYLIST_BYTES,
363 });
364 }
365 let text = String::from_utf8(bytes).map_err(|error| AniError::ProviderInvalidResponse {
366 provider: "HLS relay upstream".to_string(),
367 message: "provider playlist is not UTF-8".to_string(),
368 source: Some(Box::new(error)),
369 })?;
370 let rewritten = rewrite_playlist(state, ®istered, &text)?;
371 let rewritten = if registered.kind == ResourceKind::EntryPlaylist {
372 expose_subtitles(state, ®istered, &text, rewritten)?
373 } else {
374 rewritten
375 };
376 return Ok(response(
377 status,
378 "application/vnd.apple.mpegurl",
379 rewritten.into_bytes(),
380 ));
381 }
382 let (bytes, stripped) = if registered.kind == ResourceKind::Segment {
383 strip_png_wrapper(bytes)
384 } else {
385 (bytes, false)
386 };
387 let corrected_type = corrected_content_type(®istered, &content_type, stripped);
388 let mut result = response(status, &corrected_type, bytes);
389 copy_upstream_headers(&upstream_headers, result.headers_mut(), false);
390 Ok(result)
391}
392
393fn rewrite_playlist(state: &Arc<State>, parent: &Registered, body: &str) -> Result<String> {
394 let mut output = String::with_capacity(body.len() + 256);
395 let mut next_uri_is_playlist = false;
396 for line in body.lines() {
397 let trimmed = line.trim();
398 let rewritten = if trimmed.is_empty() {
399 line.to_owned()
400 } else if trimmed.starts_with('#') {
401 if trimmed
402 .to_ascii_uppercase()
403 .starts_with("#EXT-X-STREAM-INF:")
404 {
405 next_uri_is_playlist = true;
406 }
407 rewrite_uri_attributes(state, parent, line)?
408 } else {
409 let kind = if next_uri_is_playlist {
410 ResourceKind::Playlist
411 } else {
412 ResourceKind::Segment
413 };
414 next_uri_is_playlist = false;
415 relay_reference(state, parent, trimmed, kind)?
416 };
417 output.push_str(&rewritten);
418 output.push('\n');
419 }
420 Ok(output)
421}
422
423fn expose_subtitles(
424 state: &Arc<State>,
425 parent: &Registered,
426 original: &str,
427 rewritten: String,
428) -> Result<String> {
429 if parent.subtitles.is_empty() {
430 return Ok(rewritten);
431 }
432 if original.lines().any(|line| {
433 line.trim_start()
434 .to_ascii_uppercase()
435 .starts_with("#EXT-X-STREAM-INF:")
436 }) {
437 return Ok(add_subtitles_to_master(&rewritten, &parent.subtitles));
438 }
439
440 let media_token = register(
441 state,
442 parent.url.clone(),
443 parent.headers.clone(),
444 ResourceKind::Playlist,
445 )?;
446 let mut master = String::from("#EXTM3U\n#EXT-X-VERSION:3\n");
447 append_subtitle_renditions(&mut master, &parent.subtitles);
448 master.push_str("#EXT-X-STREAM-INF:BANDWIDTH=1,SUBTITLES=\"aniplay-subs\"\n");
449 master.push_str(&local_url(state.base, &media_token));
450 master.push('\n');
451 Ok(master)
452}
453
454fn add_subtitles_to_master(body: &str, subtitles: &[SubtitleTrack]) -> String {
455 let mut output = String::with_capacity(body.len() + subtitles.len() * 160);
456 let mut inserted = false;
457 for line in body.lines() {
458 output.push_str(line);
459 output.push('\n');
460 if !inserted && line.trim().eq_ignore_ascii_case("#EXTM3U") {
461 append_subtitle_renditions(&mut output, subtitles);
462 inserted = true;
463 }
464 }
465 if !inserted {
466 append_subtitle_renditions(&mut output, subtitles);
467 }
468 output
469 .lines()
470 .map(|line| {
471 if line
472 .trim_start()
473 .to_ascii_uppercase()
474 .starts_with("#EXT-X-STREAM-INF:")
475 && !line.to_ascii_uppercase().contains("SUBTITLES=")
476 {
477 format!("{line},SUBTITLES=\"aniplay-subs\"")
478 } else {
479 line.to_owned()
480 }
481 })
482 .collect::<Vec<_>>()
483 .join("\n")
484 + "\n"
485}
486
487fn append_subtitle_renditions(output: &mut String, subtitles: &[SubtitleTrack]) {
488 let default_index = subtitles
489 .iter()
490 .position(|subtitle| subtitle.default)
491 .unwrap_or(0);
492 for (index, subtitle) in subtitles.iter().enumerate() {
493 let name = hls_attribute(&subtitle.label);
494 let default = if index == default_index { "YES" } else { "NO" };
495 output.push_str(&format!(
496 "#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID=\"aniplay-subs\",NAME=\"{name}\",DEFAULT={default},AUTOSELECT=YES,FORCED=NO,URI=\"{}\"\n",
497 subtitle.url
498 ));
499 }
500}
501
502fn hls_attribute(value: &str) -> String {
503 value
504 .replace(['\r', '\n'], " ")
505 .replace('"', "'")
506 .trim()
507 .to_owned()
508}
509
510fn subtitle_playlist(segment_url: &str, duration_seconds: f64) -> String {
511 let duration_seconds = duration_seconds.max(1.0);
512 let target_duration = duration_seconds.ceil() as u64;
513 format!(
514 "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-PLAYLIST-TYPE:VOD\n#EXT-X-TARGETDURATION:{target_duration}\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:{duration_seconds:.3},\n{segment_url}\n#EXT-X-ENDLIST\n"
515 )
516}
517
518async fn probe_subtitle_duration(
519 client: &Client,
520 url: &Url,
521 headers: &RequestHeaders,
522) -> Option<f64> {
523 let mut request = client.get(url.clone());
524 if let Some(value) = &headers.referer {
525 request = request.header(header::REFERER, value);
526 }
527 if let Some(value) = &headers.origin {
528 request = request.header(header::ORIGIN, value);
529 }
530 for (name, value) in &headers.extra {
531 request = request.header(name, value);
532 }
533 let response = request.send().await.ok()?.error_for_status().ok()?;
534 let bytes = response.bytes().await.ok()?;
535 if bytes.len() > MAX_SUBTITLE_BYTES {
536 return None;
537 }
538 subtitle_duration(std::str::from_utf8(&bytes).ok()?)
539}
540
541fn subtitle_duration(body: &str) -> Option<f64> {
542 body.lines()
543 .filter_map(|line| line.split_once("-->").map(|(_, end)| end))
544 .filter_map(parse_subtitle_timestamp)
545 .reduce(f64::max)
546 .map(|seconds| seconds + 1.0)
547}
548
549fn parse_subtitle_timestamp(value: &str) -> Option<f64> {
550 let timestamp = value
551 .trim()
552 .split_ascii_whitespace()
553 .next()?
554 .replace(',', ".");
555 let parts = timestamp
556 .split(':')
557 .map(str::parse::<f64>)
558 .collect::<std::result::Result<Vec<_>, _>>()
559 .ok()?;
560 match parts.as_slice() {
561 [minutes, seconds] => Some(minutes * 60.0 + seconds),
562 [hours, minutes, seconds] => Some(hours * 3600.0 + minutes * 60.0 + seconds),
563 _ => None,
564 }
565}
566
567fn rewrite_uri_attributes(state: &Arc<State>, parent: &Registered, line: &str) -> Result<String> {
568 let mut output = line.to_owned();
569 let mut offset = 0;
570 let upper = line.trim().to_ascii_uppercase();
571 let kind =
572 if upper.starts_with("#EXT-X-MEDIA:") || upper.starts_with("#EXT-X-I-FRAME-STREAM-INF:") {
573 ResourceKind::Playlist
574 } else {
575 ResourceKind::Resource
576 };
577 while let Some(found) = output[offset..].to_ascii_uppercase().find("URI=") {
578 let quote_at = offset + found + 4;
579 let Some(quote) = output.as_bytes().get(quote_at).copied() else {
580 break;
581 };
582 if quote != b'\'' && quote != b'"' {
583 offset = quote_at + 1;
584 continue;
585 }
586 let start = quote_at + 1;
587 let Some(end_rel) = output.as_bytes()[start..]
588 .iter()
589 .position(|value| *value == quote)
590 else {
591 break;
592 };
593 let end = start + end_rel;
594 let rewritten = relay_reference(state, parent, &output[start..end], kind)?;
595 output.replace_range(start..end, &rewritten);
596 offset = start + rewritten.len() + 1;
597 }
598 Ok(output)
599}
600
601fn relay_reference(
602 state: &Arc<State>,
603 parent: &Registered,
604 reference: &str,
605 kind: ResourceKind,
606) -> Result<String> {
607 let resolved =
608 parent
609 .url
610 .join(reference)
611 .map_err(|error| AniError::ProviderInvalidResponse {
612 provider: "HLS relay upstream".to_string(),
613 message: format!("invalid playlist URL: {error}"),
614 source: Some(Box::new(error)),
615 })?;
616 validate_url(&resolved)?;
617 let token = register(state, resolved, parent.headers.clone(), kind)?;
618 Ok(local_url(state.base, &token))
619}
620
621fn register(
622 state: &Arc<State>,
623 url: Url,
624 headers: RequestHeaders,
625 kind: ResourceKind,
626) -> Result<String> {
627 validate_url(&url)?;
628 let url_key = format!("{kind:?}:{url}");
629 if let Some(token) = state
630 .tokens_by_url
631 .lock()
632 .expect("relay URL registry poisoned")
633 .get(&url_key)
634 .cloned()
635 {
636 return Ok(token);
637 }
638 let mut resources = state.resources.lock().expect("relay registry poisoned");
639 if resources.len() >= MAX_RESOURCES {
640 return Err(AniError::ProviderInvalidResponse {
641 provider: "HLS relay upstream".to_string(),
642 message: "provider playlist contains too many resources".to_string(),
643 source: None,
644 });
645 }
646 let count = state.counter.fetch_add(1, Ordering::Relaxed);
647 let token =
648 hex::encode(Sha256::digest(format!("{}:{count}:{url}", state.secret)))[..32].to_owned();
649 resources.insert(
650 token.clone(),
651 Registered {
652 url,
653 headers,
654 kind,
655 subtitles: Vec::new(),
656 duration_seconds: None,
657 },
658 );
659 state
660 .tokens_by_url
661 .lock()
662 .expect("relay URL registry poisoned")
663 .insert(url_key, token.clone());
664 Ok(token)
665}
666
667fn corrected_content_type(registered: &Registered, upstream: &str, stripped: bool) -> String {
668 if matches!(
669 registered.kind,
670 ResourceKind::EntryPlaylist | ResourceKind::Playlist
671 ) {
672 return "application/vnd.apple.mpegurl".into();
673 }
674 if registered.kind == ResourceKind::Subtitle {
675 let path = registered.url.path().to_ascii_lowercase();
676 return if path.ends_with(".vtt") {
677 "text/vtt".into()
678 } else if path.ends_with(".srt") {
679 "application/x-subrip".into()
680 } else if path.ends_with(".ass") || path.ends_with(".ssa") {
681 "text/x-ssa".into()
682 } else if upstream.is_empty() {
683 "text/plain; charset=utf-8".into()
684 } else {
685 upstream.into()
686 };
687 }
688 if registered.kind != ResourceKind::Segment {
689 return if upstream.is_empty() {
690 "application/octet-stream".into()
691 } else {
692 upstream.into()
693 };
694 }
695 let path = registered.url.path().to_ascii_lowercase();
696 if path.ends_with(".m4s") || path.ends_with(".mp4") || path.ends_with(".m4v") {
697 "video/mp4".into()
698 } else if path.ends_with(".aac") {
699 "audio/aac".into()
700 } else if stripped || upstream.is_empty() || upstream.to_ascii_lowercase().starts_with("image/")
701 {
702 "video/mp2t".into()
703 } else {
704 upstream.into()
705 }
706}
707
708fn validate_upstream(value: &str) -> Result<Url> {
709 let url = Url::parse(value).map_err(|error| AniError::InvalidInput {
710 input: value.to_string(),
711 reason: format!("invalid HLS URL: {error}"),
712 })?;
713 validate_url(&url)?;
714 Ok(url)
715}
716
717fn validate_url(url: &Url) -> Result<()> {
718 if !url.username().is_empty() || url.password().is_some() {
719 return Err(AniError::ProviderUrlValidationFailed {
720 reason: "credential-bearing playlist URLs are not allowed".to_string(),
721 });
722 }
723 let loopback = url
724 .host_str()
725 .is_some_and(|host| host == "127.0.0.1" || host == "localhost" || host == "::1");
726 if url.scheme() != "https" && !(cfg!(test) && loopback && url.scheme() == "http") {
727 return Err(AniError::ProviderUrlValidationFailed {
728 reason: "relay resources must use HTTPS".to_string(),
729 });
730 }
731 Ok(())
732}
733
734fn strip_png_wrapper(bytes: Vec<u8>) -> (Vec<u8>, bool) {
735 if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
736 return (bytes, false);
737 }
738 let limit = bytes.len().min(SCAN_LIMIT);
739 for offset in 8..limit.saturating_sub(376) {
740 if bytes[offset] == 0x47 && bytes[offset + 188] == 0x47 && bytes[offset + 376] == 0x47 {
741 return (bytes[offset..].to_vec(), true);
742 }
743 }
744 (bytes, false)
745}
746
747fn response(status: StatusCode, content_type: &str, bytes: Vec<u8>) -> Response<Full<Bytes>> {
748 Response::builder()
749 .status(status)
750 .header(header::CONTENT_TYPE, content_type)
751 .header(header::CONTENT_LENGTH, bytes.len())
752 .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
753 .header(header::ACCESS_CONTROL_ALLOW_METHODS, "GET, HEAD, OPTIONS")
754 .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Range, Content-Type")
755 .header(
756 header::ACCESS_CONTROL_EXPOSE_HEADERS,
757 "Content-Length, Content-Range, Accept-Ranges",
758 )
759 .body(Full::new(Bytes::from(bytes)))
760 .expect("valid relay response")
761}
762
763fn copy_upstream_headers(
764 source: &hyper::HeaderMap,
765 destination: &mut hyper::HeaderMap,
766 include_length: bool,
767) {
768 for name in [
769 header::CONTENT_RANGE,
770 header::ACCEPT_RANGES,
771 header::ETAG,
772 header::LAST_MODIFIED,
773 header::CACHE_CONTROL,
774 ] {
775 if let Some(value) = source.get(&name) {
776 destination.insert(name, value.clone());
777 }
778 }
779 if include_length && let Some(value) = source.get(header::CONTENT_LENGTH) {
780 destination.insert(header::CONTENT_LENGTH, value.clone());
781 }
782}
783
784fn local_url(address: SocketAddr, token: &str) -> String {
785 format!("http://{address}/r/{token}")
786}
787fn unix_nanos() -> u128 {
788 std::time::SystemTime::now()
789 .duration_since(std::time::UNIX_EPOCH)
790 .unwrap_or_default()
791 .as_nanos()
792}
793
794#[cfg(test)]
795mod tests {
796 use super::*;
797 use wiremock::{
798 Mock, MockServer, ResponseTemplate,
799 matchers::{method, path},
800 };
801
802 #[test]
803 fn strips_only_confirmed_png_wrapped_transport_streams() {
804 let mut wrapped = b"\x89PNG\r\n\x1a\nnot-really-png".to_vec();
805 let offset = wrapped.len();
806 wrapped.resize(offset + 377, 0);
807 wrapped[offset] = 0x47;
808 wrapped[offset + 188] = 0x47;
809 wrapped[offset + 376] = 0x47;
810 let (result, stripped) = strip_png_wrapper(wrapped);
811 assert!(stripped);
812 assert_eq!(result[0], 0x47);
813 }
814
815 #[test]
816 fn leaves_real_png_data_untouched() {
817 let value = b"\x89PNG\r\n\x1a\nordinary image".to_vec();
818 assert_eq!(strip_png_wrapper(value.clone()), (value, false));
819 }
820
821 #[test]
822 fn adds_external_subtitles_to_master_variants() {
823 let subtitles = vec![
824 SubtitleTrack {
825 label: "English".into(),
826 url: "http://127.0.0.1:1234/r/subtitle-one".into(),
827 default: true,
828 },
829 SubtitleTrack {
830 label: "Signs \"and\" Songs".into(),
831 url: "http://127.0.0.1:1234/r/subtitle-two".into(),
832 default: false,
833 },
834 ];
835 let master = add_subtitles_to_master(
836 "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000\nvideo.m3u8\n",
837 &subtitles,
838 );
839
840 assert!(master.contains("TYPE=SUBTITLES,GROUP-ID=\"aniplay-subs\""));
841 assert!(master.contains("NAME=\"English\",DEFAULT=YES"));
842 assert!(master.contains("NAME=\"Signs 'and' Songs\",DEFAULT=NO"));
843 assert!(master.contains("#EXT-X-STREAM-INF:BANDWIDTH=1000,SUBTITLES=\"aniplay-subs\""));
844 }
845
846 #[test]
847 fn subtitle_media_playlist_wraps_the_actual_track() {
848 let playlist = subtitle_playlist("http://127.0.0.1:1234/r/subtitle", 1_421.25);
849 assert!(playlist.contains("#EXT-X-PLAYLIST-TYPE:VOD"));
850 assert!(playlist.contains("#EXT-X-TARGETDURATION:1422"));
851 assert!(playlist.contains("#EXTINF:1421.250,"));
852 assert!(playlist.contains("http://127.0.0.1:1234/r/subtitle"));
853 assert!(playlist.ends_with("#EXT-X-ENDLIST\n"));
854 }
855
856 #[test]
857 fn derives_subtitle_duration_from_webvtt_and_srt_cues() {
858 let webvtt = "WEBVTT\n\n00:00:01.000 --> 00:00:04.500\nHello\n\n00:23:40.250 --> 00:23:43.750\nEnd\n";
859 let srt =
860 "1\n00:00:01,000 --> 00:00:03,000\nHello\n\n2\n01:02:03,250 --> 01:02:05,500\nEnd\n";
861
862 assert_eq!(subtitle_duration(webvtt), Some(1_424.75));
863 assert_eq!(subtitle_duration(srt), Some(3_726.5));
864 assert_eq!(subtitle_duration("WEBVTT\n\nNo cues"), None);
865 }
866
867 #[tokio::test]
868 async fn rewrites_nested_resources_and_unwraps_segments() {
869 let server = MockServer::start().await;
870 Mock::given(method("GET"))
871 .and(path("/master.m3u8"))
872 .respond_with(
873 ResponseTemplate::new(200)
874 .insert_header("content-type", "application/vnd.apple.mpegurl")
875 .set_body_string("#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nmedia.m3u8\n"),
876 )
877 .mount(&server)
878 .await;
879 Mock::given(method("GET"))
880 .and(path("/media.m3u8"))
881 .respond_with(ResponseTemplate::new(200).set_body_string(
882 "#EXTM3U\n#EXT-X-KEY:METHOD=AES-128,URI=\"key.bin\"\nsegment.png\n",
883 ))
884 .mount(&server)
885 .await;
886 Mock::given(method("GET"))
887 .and(path("/key.bin"))
888 .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![1, 2, 3]))
889 .mount(&server)
890 .await;
891 let mut wrapped = b"\x89PNG\r\n\x1a\nwrapper".to_vec();
892 let offset = wrapped.len();
893 wrapped.resize(offset + 377, 0);
894 wrapped[offset] = 0x47;
895 wrapped[offset + 188] = 0x47;
896 wrapped[offset + 376] = 0x47;
897 Mock::given(method("GET"))
898 .and(path("/segment.png"))
899 .respond_with(ResponseTemplate::new(200).set_body_bytes(wrapped))
900 .mount(&server)
901 .await;
902 Mock::given(method("HEAD"))
903 .and(path("/segment.png"))
904 .respond_with(
905 ResponseTemplate::new(200)
906 .insert_header("content-type", "image/png")
907 .insert_header("content-length", "999"),
908 )
909 .mount(&server)
910 .await;
911
912 let stream = StreamLink {
913 url: format!("{}/master.m3u8", server.uri()),
914 resolution: "Auto".into(),
915 hls: true,
916 provider: "MegaPlay".into(),
917 downloadable: true,
918 headers: RequestHeaders::default(),
919 subtitles: vec![],
920 };
921 let (_relay, local) = relay_stream(&stream).await.unwrap();
922 let client = reqwest::Client::new();
923 let master = client
924 .get(&local.url)
925 .send()
926 .await
927 .unwrap()
928 .text()
929 .await
930 .unwrap();
931 let media_url = master
932 .lines()
933 .find(|line| line.starts_with("http://"))
934 .unwrap();
935 let media = client
936 .get(media_url)
937 .send()
938 .await
939 .unwrap()
940 .text()
941 .await
942 .unwrap();
943 let segment_url = media
944 .lines()
945 .find(|line| !line.is_empty() && !line.starts_with('#'))
946 .unwrap();
947 let segment_head = client.head(segment_url).send().await.unwrap();
948 assert_eq!(
949 segment_head.headers().get("content-type").unwrap(),
950 "video/mp2t"
951 );
952 let segment = client.get(segment_url).send().await.unwrap();
953 assert_eq!(segment.headers().get("content-type").unwrap(), "video/mp2t");
954 assert_eq!(segment.bytes().await.unwrap()[0], 0x47);
955 assert!(media.contains("URI=\"http://127.0.0.1:"));
956 }
957
958 #[tokio::test]
959 async fn wraps_media_playlists_with_external_subtitle_renditions() {
960 let server = MockServer::start().await;
961 Mock::given(method("GET"))
962 .and(path("/media.m3u8"))
963 .respond_with(
964 ResponseTemplate::new(200)
965 .insert_header("content-type", "application/vnd.apple.mpegurl")
966 .set_body_string("#EXTM3U\n#EXTINF:10,\nsegment.ts\n"),
967 )
968 .mount(&server)
969 .await;
970 Mock::given(method("GET"))
971 .and(path("/subtitles.vtt"))
972 .respond_with(
973 ResponseTemplate::new(200)
974 .insert_header("content-type", "text/vtt")
975 .set_body_string("WEBVTT\n"),
976 )
977 .mount(&server)
978 .await;
979
980 let stream = StreamLink {
981 url: format!("{}/media.m3u8", server.uri()),
982 resolution: "Auto".into(),
983 hls: true,
984 provider: "MegaPlay".into(),
985 downloadable: true,
986 headers: RequestHeaders::default(),
987 subtitles: vec![SubtitleTrack {
988 label: "English".into(),
989 url: format!("{}/subtitles.vtt", server.uri()),
990 default: true,
991 }],
992 };
993 let (_relay, local) = relay_stream(&stream).await.unwrap();
994 let client = reqwest::Client::new();
995 let master = client
996 .get(&local.url)
997 .send()
998 .await
999 .unwrap()
1000 .text()
1001 .await
1002 .unwrap();
1003
1004 assert!(master.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"));
1005 let subtitle_playlist_url = master
1006 .lines()
1007 .find(|line| line.starts_with("#EXT-X-MEDIA:TYPE=SUBTITLES"))
1008 .and_then(|line| line.split("URI=\"").nth(1))
1009 .and_then(|value| value.split('"').next())
1010 .unwrap();
1011 let subtitle_playlist = client
1012 .get(subtitle_playlist_url)
1013 .send()
1014 .await
1015 .unwrap()
1016 .text()
1017 .await
1018 .unwrap();
1019 assert!(subtitle_playlist.contains(&local.subtitles[0].url));
1020 let media_url = master
1021 .lines()
1022 .find(|line| line.starts_with("http://"))
1023 .unwrap();
1024 let media = client
1025 .get(media_url)
1026 .send()
1027 .await
1028 .unwrap()
1029 .text()
1030 .await
1031 .unwrap();
1032 assert!(media.contains("#EXTINF:10,"));
1033 let subtitle = client.get(&local.subtitles[0].url).send().await.unwrap();
1034 assert_eq!(
1035 subtitle
1036 .headers()
1037 .get(header::CONTENT_TYPE)
1038 .unwrap()
1039 .to_str()
1040 .unwrap(),
1041 "text/vtt"
1042 );
1043 }
1044
1045 #[tokio::test]
1046 async fn desktop_relay_omits_hls_subtitle_renditions_but_keeps_the_sidecar() {
1047 let server = MockServer::start().await;
1048 Mock::given(method("GET"))
1049 .and(path("/media.m3u8"))
1050 .respond_with(
1051 ResponseTemplate::new(200)
1052 .insert_header("content-type", "application/vnd.apple.mpegurl")
1053 .set_body_string("#EXTM3U\n#EXTINF:10,\nsegment.ts\n"),
1054 )
1055 .mount(&server)
1056 .await;
1057 Mock::given(method("GET"))
1058 .and(path("/subtitles.vtt"))
1059 .respond_with(
1060 ResponseTemplate::new(200)
1061 .insert_header("content-type", "text/vtt")
1062 .set_body_string("WEBVTT\n\n00:00:01.000 --> 00:00:04.500\nHello\n"),
1063 )
1064 .mount(&server)
1065 .await;
1066
1067 let stream = StreamLink {
1068 url: format!("{}/media.m3u8", server.uri()),
1069 resolution: "Auto".into(),
1070 hls: true,
1071 provider: "MegaPlay".into(),
1072 downloadable: true,
1073 headers: RequestHeaders::default(),
1074 subtitles: vec![SubtitleTrack {
1075 label: "English".into(),
1076 url: format!("{}/subtitles.vtt", server.uri()),
1077 default: true,
1078 }],
1079 };
1080 let (_relay, local) = relay_stream_without_hls_subtitles(&stream).await.unwrap();
1081 let client = reqwest::Client::new();
1082 let master = client
1083 .get(&local.url)
1084 .send()
1085 .await
1086 .unwrap()
1087 .text()
1088 .await
1089 .unwrap();
1090
1091 assert!(!master.contains("#EXT-X-MEDIA:TYPE=SUBTITLES"));
1092 assert!(!master.contains("SUBTITLES=\"aniplay-subs\""));
1093
1094 assert_eq!(local.subtitles.len(), 1);
1097 let subtitle = client.get(&local.subtitles[0].url).send().await.unwrap();
1098 assert_eq!(subtitle.status(), reqwest::StatusCode::OK);
1099 assert_eq!(
1100 subtitle
1101 .headers()
1102 .get(header::CONTENT_TYPE)
1103 .unwrap()
1104 .to_str()
1105 .unwrap(),
1106 "text/vtt"
1107 );
1108 let body = subtitle.text().await.unwrap();
1109 assert!(body.contains("Hello"));
1110 }
1111
1112 #[tokio::test]
1113 async fn suppresses_segment_ranges_and_rejects_unknown_tokens() {
1114 let server = MockServer::start().await;
1115 Mock::given(method("GET"))
1116 .and(path("/master.m3u8"))
1117 .respond_with(ResponseTemplate::new(200).set_body_string("#EXTM3U\nsegment.ts\n"))
1118 .mount(&server)
1119 .await;
1120 Mock::given(method("GET"))
1121 .and(path("/segment.ts"))
1122 .respond_with(ResponseTemplate::new(200).set_body_bytes(vec![0x47, 1, 2]))
1123 .expect(1)
1124 .mount(&server)
1125 .await;
1126 let stream = StreamLink {
1127 url: format!("{}/master.m3u8", server.uri()),
1128 resolution: "Auto".into(),
1129 hls: true,
1130 provider: "MegaPlay".into(),
1131 downloadable: true,
1132 headers: RequestHeaders::default(),
1133 subtitles: vec![],
1134 };
1135 let (_relay, local) = relay_stream(&stream).await.unwrap();
1136 let client = reqwest::Client::new();
1137 let playlist = client
1138 .get(&local.url)
1139 .send()
1140 .await
1141 .unwrap()
1142 .text()
1143 .await
1144 .unwrap();
1145 let segment_url = playlist
1146 .lines()
1147 .find(|line| line.starts_with("http://"))
1148 .unwrap();
1149 let ranged = client
1150 .get(segment_url)
1151 .header("range", "bytes=0-2")
1152 .send()
1153 .await
1154 .unwrap();
1155 assert_eq!(ranged.status(), reqwest::StatusCode::OK);
1156 let requests = server.received_requests().await.unwrap();
1157 let segment_request = requests
1158 .iter()
1159 .find(|request| request.url.path() == "/segment.ts")
1160 .unwrap();
1161 assert!(!segment_request.headers.contains_key("range"));
1162 let base = Url::parse(&local.url).unwrap();
1163 let rejected = client
1164 .get(base.join("/r/unknown").unwrap())
1165 .send()
1166 .await
1167 .unwrap();
1168 assert_eq!(rejected.status(), reqwest::StatusCode::FORBIDDEN);
1169 }
1170
1171 #[test]
1172 fn rejects_non_https_remote_resources() {
1173 let error = validate_upstream("http://example.com/master.m3u8").unwrap_err();
1174 assert!(matches!(
1175 error,
1176 AniError::ProviderUrlValidationFailed { .. }
1177 ));
1178 }
1179
1180 #[test]
1181 fn caps_registered_playlist_resources() {
1182 let state = Arc::new(State {
1183 client: Client::new(),
1184 base: "127.0.0.1:1".parse().unwrap(),
1185 resources: Mutex::new(HashMap::new()),
1186 tokens_by_url: Mutex::new(HashMap::new()),
1187 counter: AtomicUsize::new(0),
1188 secret: "test".into(),
1189 });
1190 for index in 0..MAX_RESOURCES {
1191 register(
1192 &state,
1193 Url::parse(&format!("https://kotocdn.site/{index}")).unwrap(),
1194 RequestHeaders::default(),
1195 ResourceKind::Segment,
1196 )
1197 .unwrap();
1198 }
1199 let error = register(
1200 &state,
1201 Url::parse("https://kotocdn.site/overflow").unwrap(),
1202 RequestHeaders::default(),
1203 ResourceKind::Segment,
1204 )
1205 .unwrap_err();
1206 assert!(matches!(error, AniError::ProviderInvalidResponse { .. }));
1207 }
1208}