Skip to main content

stremio_addon_core/
router.rs

1use crate::auth::{AuthConfig, AuthError, AuthQuery};
2use crate::config::{decode_config_segment, strip_json_suffix, ConfigError, UserConfig};
3use crate::models::{
4    CatalogExtraArgs, CatalogResponse, Manifest, MetaResponse, StreamRequest, StreamResponse,
5};
6use crate::signing::{SignedPlayback, SigningError};
7use async_trait::async_trait;
8use axum::extract::{Path, Query, State};
9use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
10use axum::response::{IntoResponse, Redirect, Response};
11use axum::routing::{get, post};
12use axum::{Json, Router};
13use percent_encoding::percent_decode_str;
14use serde::Serialize;
15use std::collections::BTreeMap;
16use std::sync::Arc;
17use tower_http::cors::CorsLayer;
18
19#[derive(Clone, Debug, Default)]
20pub struct AddonContext {
21    pub user_config: UserConfig,
22}
23
24#[async_trait]
25pub trait AddonAdapter: Send + Sync + 'static {
26    async fn manifest(&self, ctx: AddonContext) -> Result<Manifest, AddonError>;
27
28    async fn stream(
29        &self,
30        ctx: AddonContext,
31        content_type: String,
32        id: String,
33    ) -> Result<StreamResponse, AddonError>;
34
35    async fn stream_request(
36        &self,
37        ctx: AddonContext,
38        request: StreamRequest,
39    ) -> Result<StreamResponse, AddonError> {
40        let Some(content_type) = request.content_type else {
41            return Ok(StreamResponse::default());
42        };
43        let Some(id) = request.id else {
44            return Ok(StreamResponse::default());
45        };
46        self.stream(ctx, content_type, id).await
47    }
48
49    async fn catalog(
50        &self,
51        ctx: AddonContext,
52        content_type: String,
53        id: String,
54        extra: CatalogExtraArgs,
55    ) -> Result<CatalogResponse, AddonError>;
56
57    async fn meta(
58        &self,
59        ctx: AddonContext,
60        content_type: String,
61        id: String,
62    ) -> Result<MetaResponse, AddonError>;
63
64    async fn playback(
65        &self,
66        ctx: AddonContext,
67        ident: String,
68    ) -> Result<PlaybackResponse, AddonError>;
69}
70
71#[derive(Debug, thiserror::Error)]
72pub enum AddonError {
73    #[error("addon auth failed: {0}")]
74    Auth(#[from] AuthError),
75    #[error("invalid config: {0}")]
76    Config(#[from] ConfigError),
77    #[error("not found")]
78    NotFound,
79    #[error("bad request: {0}")]
80    BadRequest(String),
81    #[error("provider error: {0}")]
82    Provider(String),
83    #[error("playback error: {0}")]
84    Playback(String),
85}
86
87impl IntoResponse for AddonError {
88    fn into_response(self) -> Response {
89        let status = match self {
90            AddonError::Auth(_) => StatusCode::UNAUTHORIZED,
91            AddonError::NotFound => StatusCode::NOT_FOUND,
92            AddonError::Config(_) | AddonError::BadRequest(_) => StatusCode::BAD_REQUEST,
93            AddonError::Provider(_) | AddonError::Playback(_) => StatusCode::INTERNAL_SERVER_ERROR,
94        };
95
96        let body = ErrorBody {
97            error: self.to_string(),
98        };
99
100        (status, Json(body)).into_response()
101    }
102}
103
104#[derive(Clone, Debug)]
105pub struct PlaybackResponse {
106    pub location: String,
107    pub cache_max_age_seconds: u64,
108}
109
110#[derive(Serialize)]
111struct ErrorBody {
112    error: String,
113}
114
115#[derive(Clone)]
116struct AppState {
117    adapter: Arc<dyn AddonAdapter>,
118    auth: AuthConfig,
119    options: RouterOptions,
120}
121
122pub fn build_router(adapter: Arc<dyn AddonAdapter>, auth: AuthConfig) -> Router {
123    build_router_with_options(adapter, auth, RouterOptions::default())
124}
125
126#[derive(Clone, Debug)]
127pub struct RouterOptions {
128    pub catalog_routes: bool,
129    pub meta_routes: bool,
130    pub playback_routes: bool,
131    pub alias_routes: bool,
132    pub path_key_routes: bool,
133    pub health_routes: bool,
134    pub playback_signing_key: Option<String>,
135    pub require_stream_json_suffix: bool,
136}
137
138impl Default for RouterOptions {
139    fn default() -> Self {
140        Self {
141            catalog_routes: true,
142            meta_routes: true,
143            playback_routes: true,
144            alias_routes: true,
145            path_key_routes: true,
146            health_routes: true,
147            playback_signing_key: None,
148            require_stream_json_suffix: false,
149        }
150    }
151}
152
153pub fn build_router_with_options(
154    adapter: Arc<dyn AddonAdapter>,
155    auth: AuthConfig,
156    options: RouterOptions,
157) -> Router {
158    let mut router = Router::new()
159        .route("/manifest.json", get(manifest))
160        .route("/:config/manifest.json", get(manifest_with_config))
161        .route("/stream", post(stream_post))
162        .route("/stream/:type/:id", get(stream_without_config))
163        .route("/api/streams", post(stream_post))
164        .route("/api/streams/:type/:id", get(stream_api_without_config))
165        .route("/:config/stream/:type/:id", get(stream_with_config));
166
167    if options.alias_routes {
168        router = router
169            .route("/", get(manifest))
170            .route("/stremio/manifest.json", get(manifest))
171            .route("/api/manifest", get(manifest));
172    }
173
174    if options.path_key_routes {
175        router = router
176            .route("/u/:path_key/manifest.json", get(manifest_with_path_key))
177            .route("/u/:path_key/stream/:type/:id", get(stream_with_path_key));
178    }
179
180    if options.catalog_routes {
181        router = router
182            .route("/catalog/:type/:id/*extra", get(catalog_without_config))
183            .route(
184                "/:config/catalog/:type/:id/*extra",
185                get(catalog_with_config),
186            );
187    }
188
189    if options.meta_routes {
190        router = router
191            .route("/meta/:type/:id", get(meta_without_config))
192            .route("/:config/meta/:type/:id", get(meta_with_config));
193    }
194
195    if options.playback_routes {
196        router = router
197            .route("/play/:ident", get(play_without_config))
198            .route("/:config/play/:ident", get(play_with_config));
199    }
200
201    if options.health_routes {
202        router = router
203            .route("/health", get(health))
204            .route("/healthz", get(health));
205    }
206
207    let state = AppState {
208        adapter,
209        auth,
210        options,
211    };
212
213    router.layer(CorsLayer::permissive()).with_state(state)
214}
215
216async fn manifest(
217    State(state): State<AppState>,
218    Query(query): Query<AuthQuery>,
219    headers: HeaderMap,
220) -> Result<Json<Manifest>, AddonError> {
221    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
222    state.adapter.manifest(ctx).await.map(Json)
223}
224
225async fn stream_post(
226    State(state): State<AppState>,
227    Query(query): Query<AuthQuery>,
228    headers: HeaderMap,
229    Json(body): Json<StreamRequest>,
230) -> Result<Json<StreamResponse>, AddonError> {
231    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
232    state.adapter.stream_request(ctx, body).await.map(Json)
233}
234
235async fn manifest_with_config(
236    State(state): State<AppState>,
237    Path(config): Path<String>,
238    Query(query): Query<AuthQuery>,
239    headers: HeaderMap,
240) -> Result<Json<Manifest>, AddonError> {
241    let cfg = decode_config_segment(&config)?;
242    let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
243    state.adapter.manifest(ctx).await.map(Json)
244}
245
246async fn manifest_with_path_key(
247    State(state): State<AppState>,
248    Path(path_key): Path<String>,
249    Query(query): Query<AuthQuery>,
250    headers: HeaderMap,
251) -> Result<Json<Manifest>, AddonError> {
252    let ctx = context_from_parts(
253        &state.auth,
254        None,
255        Some(&query),
256        &headers,
257        Some(path_key.as_str()),
258    )?;
259    state.adapter.manifest(ctx).await.map(Json)
260}
261
262async fn stream_without_config(
263    State(state): State<AppState>,
264    Path((content_type, id)): Path<(String, String)>,
265    Query(query): Query<AuthQuery>,
266    headers: HeaderMap,
267) -> Result<Json<StreamResponse>, AddonError> {
268    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
269    let id = stream_path_id(&state.options, &id)?;
270    state.adapter.stream(ctx, content_type, id).await.map(Json)
271}
272
273async fn stream_api_without_config(
274    State(state): State<AppState>,
275    Path((content_type, id)): Path<(String, String)>,
276    Query(query): Query<AuthQuery>,
277    headers: HeaderMap,
278) -> Result<Json<StreamResponse>, AddonError> {
279    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
280    state
281        .adapter
282        .stream(ctx, content_type, strip_json_suffix(&id).to_string())
283        .await
284        .map(Json)
285}
286
287async fn stream_with_path_key(
288    State(state): State<AppState>,
289    Path((path_key, content_type, id)): Path<(String, String, String)>,
290    Query(query): Query<AuthQuery>,
291    headers: HeaderMap,
292) -> Result<Json<StreamResponse>, AddonError> {
293    let ctx = context_from_parts(
294        &state.auth,
295        None,
296        Some(&query),
297        &headers,
298        Some(path_key.as_str()),
299    )?;
300    let id = stream_path_id(&state.options, &id)?;
301    state.adapter.stream(ctx, content_type, id).await.map(Json)
302}
303
304async fn stream_with_config(
305    State(state): State<AppState>,
306    Path((config, content_type, id)): Path<(String, String, String)>,
307    Query(query): Query<AuthQuery>,
308    headers: HeaderMap,
309) -> Result<Json<StreamResponse>, AddonError> {
310    let cfg = decode_config_segment(&config)?;
311    let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
312    let id = stream_path_id(&state.options, &id)?;
313    state.adapter.stream(ctx, content_type, id).await.map(Json)
314}
315
316async fn catalog_without_config(
317    State(state): State<AppState>,
318    Path((content_type, id, extra)): Path<(String, String, String)>,
319    Query(query): Query<AuthQuery>,
320    headers: HeaderMap,
321) -> Result<Json<CatalogResponse>, AddonError> {
322    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
323    state
324        .adapter
325        .catalog(ctx, content_type, id, parse_extra_args(&extra)?)
326        .await
327        .map(Json)
328}
329
330async fn catalog_with_config(
331    State(state): State<AppState>,
332    Path((config, content_type, id, extra)): Path<(String, String, String, String)>,
333    Query(query): Query<AuthQuery>,
334    headers: HeaderMap,
335) -> Result<Json<CatalogResponse>, AddonError> {
336    let cfg = decode_config_segment(&config)?;
337    let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
338    state
339        .adapter
340        .catalog(ctx, content_type, id, parse_extra_args(&extra)?)
341        .await
342        .map(Json)
343}
344
345async fn meta_without_config(
346    State(state): State<AppState>,
347    Path((content_type, id)): Path<(String, String)>,
348    Query(query): Query<AuthQuery>,
349    headers: HeaderMap,
350) -> Result<Json<MetaResponse>, AddonError> {
351    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
352    state
353        .adapter
354        .meta(ctx, content_type, strip_json_suffix(&id).to_string())
355        .await
356        .map(Json)
357}
358
359async fn meta_with_config(
360    State(state): State<AppState>,
361    Path((config, content_type, id)): Path<(String, String, String)>,
362    Query(query): Query<AuthQuery>,
363    headers: HeaderMap,
364) -> Result<Json<MetaResponse>, AddonError> {
365    let cfg = decode_config_segment(&config)?;
366    let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
367    state
368        .adapter
369        .meta(ctx, content_type, strip_json_suffix(&id).to_string())
370        .await
371        .map(Json)
372}
373
374async fn play_without_config(
375    State(state): State<AppState>,
376    Path(ident): Path<String>,
377    Query(query): Query<AuthQuery>,
378    headers: HeaderMap,
379) -> Result<Response, AddonError> {
380    let ctx = context_from_parts(&state.auth, None, Some(&query), &headers, None)?;
381    let ident = verified_playback_ident(&state.options, &ident, &query)?;
382    playback_response(state.adapter.playback(ctx, ident).await?)
383}
384
385async fn play_with_config(
386    State(state): State<AppState>,
387    Path((config, ident)): Path<(String, String)>,
388    Query(query): Query<AuthQuery>,
389    headers: HeaderMap,
390) -> Result<Response, AddonError> {
391    let cfg = decode_config_segment(&config)?;
392    let ctx = context_from_parts(&state.auth, Some(cfg), Some(&query), &headers, None)?;
393    let ident = verified_playback_ident(&state.options, &ident, &query)?;
394    playback_response(state.adapter.playback(ctx, ident).await?)
395}
396
397fn context_from_parts(
398    auth: &AuthConfig,
399    user_config: Option<UserConfig>,
400    query: Option<&AuthQuery>,
401    headers: &HeaderMap,
402    path_key: Option<&str>,
403) -> Result<AddonContext, AddonError> {
404    let user_config = user_config.unwrap_or_default();
405    auth.validate(Some(&user_config), query, headers, path_key)?;
406    Ok(AddonContext { user_config })
407}
408
409async fn health() -> Json<serde_json::Value> {
410    Json(serde_json::json!({ "status": "ok" }))
411}
412
413fn verified_playback_ident(
414    options: &RouterOptions,
415    path_ident: &str,
416    query: &AuthQuery,
417) -> Result<String, AddonError> {
418    let Some(signing_key) = options.playback_signing_key.as_deref() else {
419        return Ok(path_ident.to_string());
420    };
421
422    let sig = query
423        .sig
424        .as_deref()
425        .ok_or_else(|| AddonError::Playback("missing signed playback token".to_string()))?;
426    let payload = SignedPlayback::verify(sig, signing_key.as_bytes()).map_err(signing_error)?;
427
428    if payload.ident != path_ident {
429        return Err(AddonError::Playback(
430            "signed playback token does not match path ident".to_string(),
431        ));
432    }
433
434    Ok(payload.ident)
435}
436
437fn signing_error(error: SigningError) -> AddonError {
438    AddonError::Playback(error.to_string())
439}
440
441fn parse_extra_args(extra: &str) -> Result<CatalogExtraArgs, AddonError> {
442    let mut values = BTreeMap::new();
443    let extra = strip_json_suffix(extra);
444
445    for segment in extra.split('/') {
446        let Some((key, value)) = segment.split_once('=') else {
447            return Err(AddonError::BadRequest(format!(
448                "invalid catalog extra segment: {segment}"
449            )));
450        };
451
452        values.insert(percent_decode(key)?, percent_decode(value)?);
453    }
454
455    Ok(CatalogExtraArgs { values })
456}
457
458fn percent_decode(value: &str) -> Result<String, AddonError> {
459    percent_decode_str(value)
460        .decode_utf8()
461        .map(|value| value.into_owned())
462        .map_err(|_| AddonError::BadRequest("invalid percent encoding".to_string()))
463}
464
465fn stream_path_id(options: &RouterOptions, id: &str) -> Result<String, AddonError> {
466    if options.require_stream_json_suffix && !id.ends_with(".json") {
467        return Err(AddonError::NotFound);
468    }
469
470    Ok(strip_json_suffix(id).to_string())
471}
472
473fn playback_response(playback: PlaybackResponse) -> Result<Response, AddonError> {
474    let mut response = Redirect::temporary(&playback.location).into_response();
475    let headers = response.headers_mut();
476    let cache_control = format!(
477        "max-age={}, must-revalidate, proxy-revalidate",
478        playback.cache_max_age_seconds
479    );
480    headers.insert(
481        header::CACHE_CONTROL,
482        HeaderValue::from_str(&cache_control)
483            .map_err(|_| AddonError::Playback("invalid cache control header".to_string()))?,
484    );
485
486    Ok(response)
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::models::{
493        BehaviorHints, CatalogDef, Meta, MetaPreview, Resource, ResourceSpec, Stream,
494    };
495    use axum::body::Body;
496    use axum::http::{Request, StatusCode};
497    use tower::ServiceExt;
498
499    struct DummyAdapter;
500
501    #[async_trait]
502    impl AddonAdapter for DummyAdapter {
503        async fn manifest(&self, _ctx: AddonContext) -> Result<Manifest, AddonError> {
504            Ok(Manifest {
505                id: "test.addon".to_string(),
506                version: "0.1.0".to_string(),
507                name: "Test Addon".to_string(),
508                description: None,
509                resources: vec![ResourceSpec::Object(Resource {
510                    name: "stream".to_string(),
511                    types: vec!["movie".to_string()],
512                    id_prefixes: vec!["tt".to_string()],
513                })],
514                types: vec!["movie".to_string()],
515                catalogs: vec![CatalogDef {
516                    id: "direct".to_string(),
517                    r#type: "movie".to_string(),
518                    name: "Direct".to_string(),
519                    extra: vec![],
520                }],
521                id_prefixes: vec!["tt".to_string()],
522                behavior_hints: Some(BehaviorHints {
523                    configurable: Some(false),
524                    configuration_required: Some(false),
525                    p2p: Some(false),
526                    adult: Some(false),
527                }),
528                config: vec![],
529                logo: None,
530                background: None,
531                contact_email: None,
532                extra: Default::default(),
533            })
534        }
535
536        async fn stream(
537            &self,
538            _ctx: AddonContext,
539            _content_type: String,
540            id: String,
541        ) -> Result<StreamResponse, AddonError> {
542            Ok(StreamResponse {
543                streams: vec![Stream {
544                    ident: Some(id),
545                    url: Some("https://example.com/play".to_string()),
546                    ..Stream::default()
547                }],
548            })
549        }
550
551        async fn catalog(
552            &self,
553            _ctx: AddonContext,
554            content_type: String,
555            _id: String,
556            extra: CatalogExtraArgs,
557        ) -> Result<CatalogResponse, AddonError> {
558            Ok(CatalogResponse {
559                metas: vec![MetaPreview {
560                    id: extra.get("search").unwrap_or_default().to_string(),
561                    r#type: content_type,
562                    name: "Result".to_string(),
563                    poster: None,
564                }],
565                cache_max_age: Some(3600),
566            })
567        }
568
569        async fn meta(
570            &self,
571            _ctx: AddonContext,
572            content_type: String,
573            id: String,
574        ) -> Result<MetaResponse, AddonError> {
575            Ok(MetaResponse {
576                meta: Meta {
577                    id,
578                    r#type: content_type,
579                    name: "Meta".to_string(),
580                    ..Meta::default()
581                },
582            })
583        }
584
585        async fn playback(
586            &self,
587            _ctx: AddonContext,
588            ident: String,
589        ) -> Result<PlaybackResponse, AddonError> {
590            Ok(PlaybackResponse {
591                location: format!("https://example.com/{ident}"),
592                cache_max_age_seconds: 18_000,
593            })
594        }
595    }
596
597    #[tokio::test]
598    async fn manifest_requires_auth() {
599        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
600        let response = app
601            .oneshot(
602                Request::builder()
603                    .uri("/manifest.json")
604                    .body(Body::empty())
605                    .unwrap(),
606            )
607            .await
608            .unwrap();
609
610        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
611    }
612
613    #[tokio::test]
614    async fn manifest_accepts_query_auth() {
615        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
616        let response = app
617            .oneshot(
618                Request::builder()
619                    .uri("/manifest.json?authKey=secret")
620                    .body(Body::empty())
621                    .unwrap(),
622            )
623            .await
624            .unwrap();
625
626        assert_eq!(response.status(), StatusCode::OK);
627    }
628
629    #[tokio::test]
630    async fn manifest_accepts_path_key() {
631        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
632        let response = app
633            .oneshot(
634                Request::builder()
635                    .uri("/u/secret/manifest.json")
636                    .body(Body::empty())
637                    .unwrap(),
638            )
639            .await
640            .unwrap();
641
642        assert_eq!(response.status(), StatusCode::OK);
643    }
644
645    #[tokio::test]
646    async fn manifest_does_not_mount_php_alias() {
647        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
648        let response = app
649            .oneshot(
650                Request::builder()
651                    .uri("/index.php?authKey=secret")
652                    .body(Body::empty())
653                    .unwrap(),
654            )
655            .await
656            .unwrap();
657
658        assert_eq!(response.status(), StatusCode::NOT_FOUND);
659    }
660
661    #[tokio::test]
662    async fn stream_accepts_config_auth_and_strips_json_suffix() {
663        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
664        let response = app
665            .oneshot(
666                Request::builder()
667                    .uri("/%7B%22authKey%22%3A%22secret%22%7D/stream/movie/tt123.json")
668                    .body(Body::empty())
669                    .unwrap(),
670            )
671            .await
672            .unwrap();
673
674        assert_eq!(response.status(), StatusCode::OK);
675    }
676
677    #[tokio::test]
678    async fn stream_can_require_json_suffix_without_requiring_it_for_api_alias() {
679        let app = build_router_with_options(
680            Arc::new(DummyAdapter),
681            AuthConfig::disabled(),
682            RouterOptions {
683                require_stream_json_suffix: true,
684                ..RouterOptions::default()
685            },
686        );
687
688        let response = app
689            .clone()
690            .oneshot(
691                Request::builder()
692                    .uri("/stream/movie/tt123")
693                    .body(Body::empty())
694                    .unwrap(),
695            )
696            .await
697            .unwrap();
698        assert_eq!(response.status(), StatusCode::NOT_FOUND);
699
700        let response = app
701            .oneshot(
702                Request::builder()
703                    .uri("/api/streams/movie/tt123")
704                    .body(Body::empty())
705                    .unwrap(),
706            )
707            .await
708            .unwrap();
709        assert_eq!(response.status(), StatusCode::OK);
710    }
711
712    #[tokio::test]
713    async fn stream_post_accepts_body_and_key_alias() {
714        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
715        let response = app
716            .oneshot(
717                Request::builder()
718                    .method("POST")
719                    .uri("/stream?key=secret")
720                    .header(header::CONTENT_TYPE, "application/json")
721                    .body(Body::from(
722                        r#"{"type":"movie","id":"tt123","name":"Movie","year":"2024"}"#,
723                    ))
724                    .unwrap(),
725            )
726            .await
727            .unwrap();
728
729        assert_eq!(response.status(), StatusCode::OK);
730    }
731
732    #[tokio::test]
733    async fn parses_catalog_extra_args() {
734        let extra = parse_extra_args("search=hello%20world.json").unwrap();
735        assert_eq!(extra.get("search"), Some("hello world"));
736    }
737
738    #[tokio::test]
739    async fn play_requires_auth() {
740        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
741        let response = app
742            .oneshot(
743                Request::builder()
744                    .uri("/play/abc123")
745                    .body(Body::empty())
746                    .unwrap(),
747            )
748            .await
749            .unwrap();
750
751        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
752    }
753
754    #[tokio::test]
755    async fn play_redirects_with_cache_header() {
756        let app = build_router(Arc::new(DummyAdapter), AuthConfig::required("secret"));
757        let response = app
758            .oneshot(
759                Request::builder()
760                    .uri("/play/abc123?authKey=secret")
761                    .body(Body::empty())
762                    .unwrap(),
763            )
764            .await
765            .unwrap();
766
767        assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
768        assert_eq!(
769            response.headers().get(header::LOCATION).unwrap(),
770            "https://example.com/abc123"
771        );
772        assert_eq!(
773            response.headers().get(header::CACHE_CONTROL).unwrap(),
774            "max-age=18000, must-revalidate, proxy-revalidate"
775        );
776    }
777
778    #[tokio::test]
779    async fn signed_playback_rejects_missing_sig() {
780        let app = build_router_with_options(
781            Arc::new(DummyAdapter),
782            AuthConfig::required("secret"),
783            RouterOptions {
784                playback_signing_key: Some("signing-key".to_string()),
785                ..RouterOptions::default()
786            },
787        );
788        let response = app
789            .oneshot(
790                Request::builder()
791                    .uri("/play/abc123?authKey=secret")
792                    .body(Body::empty())
793                    .unwrap(),
794            )
795            .await
796            .unwrap();
797
798        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
799    }
800
801    #[tokio::test]
802    async fn signed_playback_accepts_matching_sig() {
803        let token = crate::signing::SignedPlayback::new("abc123", 60)
804            .sign(b"signing-key")
805            .unwrap();
806        let app = build_router_with_options(
807            Arc::new(DummyAdapter),
808            AuthConfig::required("secret"),
809            RouterOptions {
810                playback_signing_key: Some("signing-key".to_string()),
811                ..RouterOptions::default()
812            },
813        );
814        let response = app
815            .oneshot(
816                Request::builder()
817                    .uri(format!("/play/abc123?authKey=secret&sig={token}"))
818                    .body(Body::empty())
819                    .unwrap(),
820            )
821            .await
822            .unwrap();
823
824        assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT);
825    }
826}