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