1use std::collections::HashMap;
10use std::convert::Infallible;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, Instant};
14
15use axum::body::Body;
16use axum::extract::{Path, Query, State};
17use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
18use axum::middleware::Next;
19use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
20use axum::response::{IntoResponse, Response};
21use axum::routing::{delete, get, patch, post};
22use axum::{Json, Router};
23use futures::stream::{Stream, StreamExt};
24use librqbit::api::TorrentIdOrHash;
25use serde::{Deserialize, Serialize};
26use tokio::io::AsyncReadExt;
27use tokio_stream::wrappers::BroadcastStream;
28use tokio_util::io::ReaderStream;
29
30use crate::VERSION;
31use crate::daemon::{Daemon, Event, TorrentView};
32
33#[derive(Clone)]
34pub struct AppState {
35 pub daemon: Arc<Daemon>,
36 pub sources: Arc<torq_sources::Registry>,
37 pub client: reqwest::Client,
38 api_port: u16,
39 auth_token: String,
40 stream_tokens: Arc<Mutex<HashMap<String, Instant>>>,
44}
45
46const STREAM_TOKEN_TTL: Duration = Duration::from_secs(60 * 60);
50
51pub fn router(
52 daemon: Arc<Daemon>,
53 auth_token: String,
54 sources: Arc<torq_sources::Registry>,
55 client: reqwest::Client,
56 api_port: u16,
57) -> Router {
58 let state = Arc::new(AppState {
59 daemon,
60 sources,
61 client,
62 api_port,
63 auth_token,
64 stream_tokens: Arc::new(Mutex::new(HashMap::new())),
65 });
66 Router::new()
67 .route("/health", get(health))
68 .route("/torrents", get(list_torrents).post(add_torrent))
69 .route("/torrents/{id}", delete(remove_torrent))
70 .route("/torrents/{id}/pause", post(pause_torrent))
71 .route("/torrents/{id}/resume", post(resume_torrent))
72 .route("/torrents/{id}/files", get(torrent_files))
73 .route("/torrents/{id}/play", get(play_file))
74 .route("/torrents/{id}/stream/{file_id}", get(stream_file))
75 .route("/search", get(search))
76 .route("/rss", get(list_rss).post(add_rss))
77 .route("/rss/{id}", delete(remove_rss))
78 .route("/library", get(library_status).post(library_scan))
79 .route("/config", get(get_config))
80 .route("/config/limits", patch(set_limits))
81 .route("/events", get(events))
82 .route_layer(axum::middleware::from_fn_with_state(
83 state.clone(),
84 require_auth,
85 ))
86 .with_state(state)
87}
88
89async fn require_auth(
90 State(state): State<Arc<AppState>>,
91 req: Request<Body>,
92 next: Next,
93) -> Response {
94 let header_ok = req
95 .headers()
96 .get(header::AUTHORIZATION)
97 .and_then(|v| v.to_str().ok())
98 .and_then(|v| v.strip_prefix("Bearer "))
99 .map(|token| constant_time_eq(token.as_bytes(), state.auth_token.as_bytes()))
100 .unwrap_or(false);
101 let token_ok = req
104 .uri()
105 .query()
106 .and_then(|q| {
107 q.split('&')
108 .find_map(|kv| kv.strip_prefix("token="))
109 .map(|t| stream_token_valid(&state.stream_tokens, t))
110 })
111 .unwrap_or(false);
112 if header_ok || token_ok {
113 next.run(req).await
114 } else {
115 (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
116 }
117}
118
119fn stream_token_valid(map: &Mutex<HashMap<String, Instant>>, token: &str) -> bool {
120 map.lock()
121 .ok()
122 .and_then(|m| m.get(token).copied())
123 .is_some_and(|issued| issued.elapsed() < STREAM_TOKEN_TTL)
124}
125
126fn new_stream_token() -> String {
128 let mut b = [0u8; 16];
129 getrandom::getrandom(&mut b).expect("os rng");
130 b.iter().map(|x| format!("{x:02x}")).collect()
131}
132
133fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
134 if a.len() != b.len() {
135 return false;
136 }
137 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
138}
139
140#[derive(Serialize)]
143struct Health {
144 version: &'static str,
145 torrents: usize,
146}
147
148async fn health(State(state): State<Arc<AppState>>) -> Json<Health> {
149 Json(Health {
150 version: VERSION,
151 torrents: state.daemon.views().len(),
152 })
153}
154
155async fn list_torrents(State(state): State<Arc<AppState>>) -> Json<Vec<TorrentView>> {
156 Json(state.daemon.views())
157}
158
159#[derive(Serialize)]
160struct ConfigInfo {
161 max_active: usize,
163 upload_bps: Option<u32>,
166 download_bps: Option<u32>,
167}
168
169async fn get_config(State(state): State<Arc<AppState>>) -> Json<ConfigInfo> {
170 let cfg = crate::config::Config::load().unwrap_or_default();
171 Json(ConfigInfo {
172 max_active: state.daemon.max_active(),
173 upload_bps: cfg.upload_bps,
174 download_bps: cfg.download_bps,
175 })
176}
177
178#[derive(Serialize, Debug, PartialEq)]
179struct FileInfo {
180 id: usize,
181 name: String,
182 length: u64,
183 included: bool,
184}
185
186fn file_list(details: &librqbit::api::TorrentDetailsResponse) -> Vec<FileInfo> {
187 details
188 .files
189 .as_deref()
190 .unwrap_or_default()
191 .iter()
192 .enumerate()
193 .map(|(i, f)| FileInfo {
194 id: i,
195 name: f.components.join("/"),
196 length: f.length,
197 included: f.included,
198 })
199 .collect()
200}
201
202async fn torrent_files(
203 State(state): State<Arc<AppState>>,
204 Path(id): Path<String>,
205) -> Result<Json<Vec<FileInfo>>, ApiError> {
206 let details = state
207 .daemon
208 .engine()
209 .api()
210 .api_torrent_details(TorrentIdOrHash::parse(&id)?)?;
211 Ok(Json(file_list(&details)))
212}
213
214const VIDEO_EXTS: &[&str] = &[
215 "mp4", "mkv", "webm", "avi", "mov", "m4v", "ts", "wmv", "flv",
216];
217
218fn pick_play_file(files: &[FileInfo]) -> Option<&FileInfo> {
220 let is_video = |f: &FileInfo| {
221 f.name
222 .rsplit('.')
223 .next()
224 .is_some_and(|e| VIDEO_EXTS.contains(&e.to_ascii_lowercase().as_str()))
225 };
226 files
227 .iter()
228 .filter(|f| is_video(f))
229 .max_by_key(|f| f.length)
230 .or_else(|| files.iter().max_by_key(|f| f.length))
231}
232
233#[derive(Serialize)]
234struct PlayResponse {
235 url: String,
236 name: String,
237 file_id: usize,
238 length: u64,
239}
240
241async fn play_file(
245 State(state): State<Arc<AppState>>,
246 Path(id): Path<String>,
247) -> Result<Json<PlayResponse>, ApiError> {
248 let details = state
249 .daemon
250 .engine()
251 .api()
252 .api_torrent_details(TorrentIdOrHash::parse(&id)?)?;
253 let files = file_list(&details);
254 let file =
255 pick_play_file(&files).ok_or_else(|| ApiError::NotFound("torrent has no files".into()))?;
256 let mut tokens = state.stream_tokens.lock().expect("stream tokens");
257 let now = Instant::now();
258 tokens.retain(|_, issued| now.duration_since(*issued) < STREAM_TOKEN_TTL);
259 let token = new_stream_token();
260 tokens.insert(token.clone(), now);
261 drop(tokens);
262 let url = format!(
263 "http://127.0.0.1:{}/torrents/{id}/stream/{}?token={token}",
264 state.api_port, file.id
265 );
266 Ok(Json(PlayResponse {
267 url,
268 name: file.name.clone(),
269 file_id: file.id,
270 length: file.length,
271 }))
272}
273
274const STREAM_PROBE_TIMEOUT: Duration = Duration::from_secs(3);
283
284async fn stream_file(
285 State(state): State<Arc<AppState>>,
286 Path((id, file_id)): Path<(String, usize)>,
287 headers: HeaderMap,
288) -> Result<Response, ApiError> {
289 let parsed = TorrentIdOrHash::parse(&id)?;
290 let api = state.daemon.engine().api();
291 let details = api.api_torrent_details(parsed)?;
292 let files = details.files.as_deref().unwrap_or_default();
293 let file = files
294 .get(file_id)
295 .ok_or_else(|| ApiError::NotFound(format!("file {file_id} not found")))?;
296 let total = file.length;
297
298 let stats = api.api_stats_v1(parsed)?;
304 let file_complete =
305 stats.file_progress.get(file_id).copied().unwrap_or(0) >= total;
306
307 let mut stream = api.api_stream(parsed, file_id)?;
308 let range = headers
309 .get(header::RANGE)
310 .and_then(|v| v.to_str().ok())
311 .and_then(|r| parse_range(r, total));
312
313 let mut headers = HeaderMap::new();
314 if file_complete {
315 headers.insert("Accept-Ranges", HeaderValue::from_static("bytes"));
316 }
317 headers.insert(
318 header::CONTENT_TYPE,
319 HeaderValue::from_static(mime_for(&file.name)),
320 );
321
322 let Some((start, end)) = range else {
323 let len = total;
324 headers.insert(
325 header::CONTENT_LENGTH,
326 HeaderValue::from_str(&len.to_string()).expect("valid header"),
327 );
328 let body = Body::from_stream(ReaderStream::with_capacity(
329 stream.take(len),
330 64 * 1024,
331 ));
332 return Ok((StatusCode::OK, headers, body).into_response());
333 };
334
335 use tokio::io::AsyncSeekExt;
336 stream.seek(std::io::SeekFrom::Start(start)).await?;
337 let mut probe = [0u8; 4096];
340 let n = match tokio::time::timeout(STREAM_PROBE_TIMEOUT, stream.read(&mut probe)).await {
341 Ok(Ok(n)) if n > 0 => n,
342 _ => {
343 let mut resp = Response::builder()
344 .status(StatusCode::RANGE_NOT_SATISFIABLE)
345 .header(
346 header::CONTENT_RANGE,
347 format!("bytes */{total}"),
348 )
349 .body(Body::empty())
350 .expect("valid response");
351 resp.headers_mut().insert(
352 "Accept-Ranges",
353 HeaderValue::from_static("bytes"),
354 );
355 return Ok(resp);
356 }
357 };
358
359 let len = end - start + 1;
360 headers.insert(
361 header::CONTENT_RANGE,
362 HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).expect("valid header"),
363 );
364 headers.insert(
365 header::CONTENT_LENGTH,
366 HeaderValue::from_str(&len.to_string()).expect("valid header"),
367 );
368
369 let head = futures::stream::once(async move {
371 Ok::<_, std::io::Error>(bytes::Bytes::copy_from_slice(&probe[..n]))
372 });
373 let rest = ReaderStream::with_capacity(
374 stream.take(len.saturating_sub(n as u64)),
375 64 * 1024,
376 );
377 let body = Body::from_stream(head.chain(rest));
378 Ok((StatusCode::PARTIAL_CONTENT, headers, body).into_response())
379}
380
381fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> {
383 if total == 0 {
384 return None;
385 }
386 let spec = header.strip_prefix("bytes=")?;
387 let (start_str, end_str) = spec.split_once('-')?;
388 if start_str.is_empty() {
389 let n = end_str.parse::<u64>().ok()?;
391 if n == 0 {
392 return None;
393 }
394 let start = total.saturating_sub(n);
395 return Some((start, total - 1));
396 }
397 let start = start_str.parse::<u64>().ok()?;
398 if start >= total {
399 return None;
400 }
401 let end = if end_str.is_empty() {
402 total - 1
403 } else {
404 end_str.parse::<u64>().ok()?.min(total - 1)
405 };
406 (end >= start).then_some((start, end))
407}
408
409fn mime_for(name: &str) -> &'static str {
410 match name
411 .rsplit('.')
412 .next()
413 .unwrap_or("")
414 .to_ascii_lowercase()
415 .as_str()
416 {
417 "mp4" | "m4v" => "video/mp4",
418 "mkv" => "video/x-matroska",
419 "webm" => "video/webm",
420 "avi" => "video/x-msvideo",
421 "mov" => "video/quicktime",
422 "ts" => "video/mp2t",
423 "wmv" => "video/x-ms-wmv",
424 "flv" => "video/x-flv",
425 "mp3" => "audio/mpeg",
426 "m4a" | "aac" => "audio/mp4",
427 "flac" => "audio/flac",
428 "ogg" | "opus" => "audio/ogg",
429 _ => "application/octet-stream",
430 }
431}
432
433#[derive(Deserialize)]
434struct SearchReq {
435 q: String,
436 #[serde(default)]
438 sources: Option<String>,
439}
440
441async fn search(
444 State(state): State<Arc<AppState>>,
445 Query(req): Query<SearchReq>,
446) -> Json<torq_sources::SearchReport> {
447 let only = req
448 .sources
449 .as_deref()
450 .map(|s| s.split(',').map(str::to_string).collect::<Vec<_>>());
451 let report = torq_sources::aggregate::search_all(
452 &state.sources.sources,
453 &state.client,
454 &req.q,
455 only.as_deref(),
456 )
457 .await;
458 Json(report)
459}
460
461#[derive(Deserialize)]
462struct AddReq {
463 #[serde(default)]
464 magnet: String,
465 #[serde(default)]
466 paused: bool,
467 #[serde(default)]
469 torrent_b64: Option<String>,
470}
471
472async fn add_torrent(
473 State(state): State<Arc<AppState>>,
474 Json(req): Json<AddReq>,
475) -> Result<Json<TorrentView>, ApiError> {
476 let view = match req.torrent_b64 {
477 Some(b64) => {
478 use base64::Engine;
479 let bytes = base64::engine::general_purpose::STANDARD
480 .decode(b64.trim())
481 .map_err(|e| ApiError::BadRequest(format!("invalid torrent_b64: {e}")))?;
482 state.daemon.add_torrent_bytes(bytes, req.paused).await?
483 }
484 None if req.magnet.trim().is_empty() => {
485 return Err(ApiError::BadRequest(
486 "provide a magnet or torrent_b64".into(),
487 ));
488 }
489 None => state.daemon.add_magnet(&req.magnet, req.paused).await?,
490 };
491 Ok(Json(view))
492}
493
494#[derive(Deserialize, Default)]
495struct RemoveReq {
496 #[serde(default, deserialize_with = "deserialize_bool_flag")]
497 delete_files: bool,
498}
499
500fn deserialize_bool_flag<'de, D>(d: D) -> Result<bool, D::Error>
503where
504 D: serde::Deserializer<'de>,
505{
506 let s = String::deserialize(d)?;
507 match s.as_str() {
508 "true" | "1" => Ok(true),
509 "false" | "0" => Ok(false),
510 other => Err(serde::de::Error::custom(format!(
511 "expected true/false/1/0, got {other:?}"
512 ))),
513 }
514}
515
516async fn remove_torrent(
517 State(state): State<Arc<AppState>>,
518 Path(id): Path<String>,
519 Query(req): Query<RemoveReq>,
520) -> Result<StatusCode, ApiError> {
521 let parsed = TorrentIdOrHash::parse(&id)?;
522 state.daemon.remove(parsed, req.delete_files).await?;
523 Ok(StatusCode::NO_CONTENT)
524}
525
526async fn pause_torrent(
527 State(state): State<Arc<AppState>>,
528 Path(id): Path<String>,
529) -> Result<StatusCode, ApiError> {
530 state.daemon.pause(TorrentIdOrHash::parse(&id)?).await?;
531 Ok(StatusCode::NO_CONTENT)
532}
533
534async fn resume_torrent(
535 State(state): State<Arc<AppState>>,
536 Path(id): Path<String>,
537) -> Result<StatusCode, ApiError> {
538 state.daemon.resume(TorrentIdOrHash::parse(&id)?).await?;
539 Ok(StatusCode::NO_CONTENT)
540}
541
542async fn list_rss(State(state): State<Arc<AppState>>) -> Json<Vec<crate::rss::Subscription>> {
543 Json(state.daemon.rss.list())
544}
545
546#[derive(Deserialize)]
547struct AddRssReq {
548 url: String,
549 #[serde(default)]
550 title_re: Option<String>,
551 #[serde(default)]
552 min_size: Option<u64>,
553 #[serde(default)]
554 max_size: Option<u64>,
555 #[serde(default = "default_sub_interval")]
556 interval_secs: u64,
557}
558
559fn default_sub_interval() -> u64 {
560 300
561}
562
563async fn add_rss(
564 State(state): State<Arc<AppState>>,
565 Json(req): Json<AddRssReq>,
566) -> Result<Json<crate::rss::Subscription>, ApiError> {
567 let sub = state.daemon.rss.add(
568 &req.url,
569 req.title_re,
570 req.min_size,
571 req.max_size,
572 req.interval_secs,
573 )?;
574 Ok(Json(sub))
575}
576
577async fn remove_rss(
578 State(state): State<Arc<AppState>>,
579 Path(id): Path<u64>,
580) -> Result<StatusCode, ApiError> {
581 if state.daemon.rss.remove(id) {
582 Ok(StatusCode::NO_CONTENT)
583 } else {
584 Err(ApiError::NotFound(format!("subscription {id} not found")))
585 }
586}
587
588#[derive(Serialize)]
589struct LibraryStatus {
590 indexed: usize,
591 dirs: Vec<PathBuf>,
592}
593
594async fn library_status(State(state): State<Arc<AppState>>) -> Json<LibraryStatus> {
595 Json(LibraryStatus {
596 indexed: state.daemon.library.count(),
597 dirs: state.daemon.library.dirs(),
598 })
599}
600
601async fn library_scan(State(state): State<Arc<AppState>>) -> Result<Json<LibraryStatus>, ApiError> {
603 state.daemon.library.scan()?;
604 Ok(Json(LibraryStatus {
605 indexed: state.daemon.library.count(),
606 dirs: state.daemon.library.dirs(),
607 }))
608}
609
610#[derive(Deserialize)]
611struct LimitsReq {
612 upload_bps: Option<u32>,
613 download_bps: Option<u32>,
614}
615
616async fn set_limits(
619 State(state): State<Arc<AppState>>,
620 Json(req): Json<LimitsReq>,
621) -> Result<StatusCode, ApiError> {
622 state
623 .daemon
624 .engine()
625 .set_limits(req.upload_bps, req.download_bps);
626 let mut config = crate::config::Config::load()?;
627 config.upload_bps = req.upload_bps;
628 config.download_bps = req.download_bps;
629 config.save()?;
630 Ok(StatusCode::NO_CONTENT)
631}
632
633async fn events(
634 State(state): State<Arc<AppState>>,
635) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
636 let stream = BroadcastStream::new(state.daemon.subscribe()).map(|item| {
637 let data = match item {
638 Ok(Event::TorrentFailed { id, error }) => {
639 serde_json::json!({"type": "torrent_failed", "id": id, "error": error}).to_string()
640 }
641 Ok(ev) => serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into()),
642 Err(_) => "{}".into(), };
644 Ok(SseEvent::default().event("torq").data(data))
645 });
646 Sse::new(stream).keep_alive(KeepAlive::default())
647}
648
649pub enum ApiError {
652 NotFound(String),
653 BadRequest(String),
654 Internal(anyhow::Error),
655}
656
657impl IntoResponse for ApiError {
658 fn into_response(self) -> Response {
659 match self {
660 Self::NotFound(m) => (StatusCode::NOT_FOUND, m).into_response(),
661 Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m).into_response(),
662 Self::Internal(e) => {
663 tracing::error!("api error: {e:#}");
664 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
665 }
666 }
667 }
668}
669
670impl From<anyhow::Error> for ApiError {
671 fn from(e: anyhow::Error) -> Self {
672 let msg = e.to_string();
673 if msg.contains("not found") || msg.contains("not managed") {
674 Self::NotFound(msg)
675 } else if msg.contains("not a valid magnet") || msg.contains("failed to parse") {
676 Self::BadRequest(msg)
677 } else {
678 Self::Internal(e)
679 }
680 }
681}
682
683impl From<librqbit::ApiError> for ApiError {
684 fn from(e: librqbit::ApiError) -> Self {
685 let msg = e.to_string();
686 if msg.contains("not found") {
687 Self::NotFound(msg)
688 } else {
689 Self::Internal(anyhow::anyhow!(msg))
690 }
691 }
692}
693
694impl From<std::io::Error> for ApiError {
695 fn from(e: std::io::Error) -> Self {
696 Self::Internal(anyhow::anyhow!(e))
697 }
698}
699
700#[cfg(test)]
701mod tests {
702 use super::*;
703
704 fn file(id: usize, name: &str, length: u64) -> FileInfo {
705 FileInfo {
706 id,
707 name: name.into(),
708 length,
709 included: true,
710 }
711 }
712
713 #[test]
714 fn stream_tokens_expire_and_reject_unknown() {
715 let map = Mutex::new(HashMap::new());
716 assert!(!stream_token_valid(&map, "nope"));
717 map.lock().unwrap().insert("fresh".into(), Instant::now());
718 assert!(stream_token_valid(&map, "fresh"));
719 map.lock()
720 .unwrap()
721 .insert("stale".into(), Instant::now() - STREAM_TOKEN_TTL - Duration::from_secs(1));
722 assert!(!stream_token_valid(&map, "stale"));
723 assert!(stream_token_valid(&map, "fresh"));
725 }
726
727 #[test]
728 fn play_picks_largest_video_else_largest_file() {
729 let files = vec![
730 file(0, "cover.jpg", 500_000),
731 file(1, "movie.mkv", 5_000_000_000),
732 file(2, "sample.mp4", 200_000_000),
733 ];
734 assert_eq!(pick_play_file(&files).unwrap().id, 1);
735 let only_audio = vec![file(0, "song.mp3", 10_000_000), file(1, "notes.txt", 1)];
737 assert_eq!(pick_play_file(&only_audio).unwrap().id, 0);
738 assert_eq!(pick_play_file(&[]), None);
739 }
740}