1use std::convert::Infallible;
8use std::path::PathBuf;
9use std::sync::Arc;
10
11use axum::body::Body;
12use axum::extract::{Path, Query, State};
13use axum::http::{HeaderMap, HeaderValue, Request, StatusCode, header};
14use axum::middleware::Next;
15use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
16use axum::response::{IntoResponse, Response};
17use axum::routing::{delete, get, patch, post};
18use axum::{Json, Router};
19use futures::stream::{Stream, StreamExt};
20use librqbit::api::TorrentIdOrHash;
21use serde::{Deserialize, Serialize};
22use tokio::io::AsyncReadExt;
23use tokio_stream::wrappers::BroadcastStream;
24use tokio_util::io::ReaderStream;
25
26use crate::VERSION;
27use crate::daemon::{Daemon, Event, TorrentView};
28
29#[derive(Clone)]
30pub struct AppState {
31 pub daemon: Arc<Daemon>,
32 pub sources: Arc<torq_sources::Registry>,
33 pub client: reqwest::Client,
34 auth_token: String,
35}
36
37pub fn router(
38 daemon: Arc<Daemon>,
39 auth_token: String,
40 sources: Arc<torq_sources::Registry>,
41 client: reqwest::Client,
42) -> Router {
43 let state = Arc::new(AppState {
44 daemon,
45 sources,
46 client,
47 auth_token,
48 });
49 Router::new()
50 .route("/health", get(health))
51 .route("/torrents", get(list_torrents).post(add_torrent))
52 .route("/torrents/{id}", delete(remove_torrent))
53 .route("/torrents/{id}/pause", post(pause_torrent))
54 .route("/torrents/{id}/resume", post(resume_torrent))
55 .route("/torrents/{id}/files", get(torrent_files))
56 .route("/torrents/{id}/stream/{file_id}", get(stream_file))
57 .route("/search", get(search))
58 .route("/rss", get(list_rss).post(add_rss))
59 .route("/rss/{id}", delete(remove_rss))
60 .route("/library", get(library_status).post(library_scan))
61 .route("/config/limits", patch(set_limits))
62 .route("/events", get(events))
63 .route_layer(axum::middleware::from_fn_with_state(
64 state.clone(),
65 require_auth,
66 ))
67 .with_state(state)
68}
69
70async fn require_auth(
71 State(state): State<Arc<AppState>>,
72 req: Request<Body>,
73 next: Next,
74) -> Response {
75 let authed = req
76 .headers()
77 .get(header::AUTHORIZATION)
78 .and_then(|v| v.to_str().ok())
79 .and_then(|v| v.strip_prefix("Bearer "))
80 .map(|token| constant_time_eq(token.as_bytes(), state.auth_token.as_bytes()))
81 .unwrap_or(false);
82 if authed {
83 next.run(req).await
84 } else {
85 (StatusCode::UNAUTHORIZED, "missing or invalid bearer token").into_response()
86 }
87}
88
89fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
90 if a.len() != b.len() {
91 return false;
92 }
93 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
94}
95
96#[derive(Serialize)]
99struct Health {
100 version: &'static str,
101 torrents: usize,
102}
103
104async fn health(State(state): State<Arc<AppState>>) -> Json<Health> {
105 Json(Health {
106 version: VERSION,
107 torrents: state.daemon.views().len(),
108 })
109}
110
111async fn list_torrents(State(state): State<Arc<AppState>>) -> Json<Vec<TorrentView>> {
112 Json(state.daemon.views())
113}
114
115#[derive(Serialize)]
116struct FileInfo {
117 id: usize,
118 name: String,
119 length: u64,
120 included: bool,
121}
122
123async fn torrent_files(
124 State(state): State<Arc<AppState>>,
125 Path(id): Path<String>,
126) -> Result<Json<Vec<FileInfo>>, ApiError> {
127 let details = state
128 .daemon
129 .engine()
130 .api()
131 .api_torrent_details(TorrentIdOrHash::parse(&id)?)?;
132 let files = details
133 .files
134 .unwrap_or_default()
135 .iter()
136 .enumerate()
137 .map(|(i, f)| FileInfo {
138 id: i,
139 name: f.components.join("/"),
140 length: f.length,
141 included: f.included,
142 })
143 .collect();
144 Ok(Json(files))
145}
146
147async fn stream_file(
151 State(state): State<Arc<AppState>>,
152 Path((id, file_id)): Path<(String, usize)>,
153 headers: HeaderMap,
154) -> Result<Response, ApiError> {
155 let parsed = TorrentIdOrHash::parse(&id)?;
156 let api = state.daemon.engine().api();
157 let details = api.api_torrent_details(parsed)?;
158 let files = details.files.as_deref().unwrap_or_default();
159 let file = files
160 .get(file_id)
161 .ok_or_else(|| ApiError::NotFound(format!("file {file_id} not found")))?;
162 let total = file.length;
163
164 let mut stream = api.api_stream(parsed, file_id)?;
165 let range = headers
166 .get(header::RANGE)
167 .and_then(|v| v.to_str().ok())
168 .and_then(|r| parse_range(r, total));
169 let (status, start, end) = match range {
170 Some((s, e)) => (StatusCode::PARTIAL_CONTENT, s, e),
171 None => (StatusCode::OK, 0, total.saturating_sub(1)),
172 };
173 if start > 0 {
174 use tokio::io::AsyncSeekExt;
175 stream.seek(std::io::SeekFrom::Start(start)).await?;
176 }
177 let len = end - start + 1;
178
179 let mut headers = HeaderMap::new();
180 headers.insert("Accept-Ranges", HeaderValue::from_static("bytes"));
181 headers.insert(
182 header::CONTENT_TYPE,
183 HeaderValue::from_static(mime_for(&file.name)),
184 );
185 if status == StatusCode::PARTIAL_CONTENT {
186 headers.insert(
187 header::CONTENT_RANGE,
188 HeaderValue::from_str(&format!("bytes {start}-{end}/{total}")).expect("valid header"),
189 );
190 }
191 headers.insert(
192 header::CONTENT_LENGTH,
193 HeaderValue::from_str(&len.to_string()).expect("valid header"),
194 );
195
196 let body = Body::from_stream(ReaderStream::with_capacity(stream.take(len), 64 * 1024));
197 Ok((status, headers, body).into_response())
198}
199
200fn parse_range(header: &str, total: u64) -> Option<(u64, u64)> {
202 if total == 0 {
203 return None;
204 }
205 let spec = header.strip_prefix("bytes=")?;
206 let (start_str, end_str) = spec.split_once('-')?;
207 if start_str.is_empty() {
208 let n = end_str.parse::<u64>().ok()?;
210 if n == 0 {
211 return None;
212 }
213 let start = total.saturating_sub(n);
214 return Some((start, total - 1));
215 }
216 let start = start_str.parse::<u64>().ok()?;
217 if start >= total {
218 return None;
219 }
220 let end = if end_str.is_empty() {
221 total - 1
222 } else {
223 end_str.parse::<u64>().ok()?.min(total - 1)
224 };
225 (end >= start).then_some((start, end))
226}
227
228fn mime_for(name: &str) -> &'static str {
229 match name
230 .rsplit('.')
231 .next()
232 .unwrap_or("")
233 .to_ascii_lowercase()
234 .as_str()
235 {
236 "mp4" | "m4v" => "video/mp4",
237 "mkv" => "video/x-matroska",
238 "webm" => "video/webm",
239 "avi" => "video/x-msvideo",
240 "mov" => "video/quicktime",
241 "ts" => "video/mp2t",
242 "wmv" => "video/x-ms-wmv",
243 "flv" => "video/x-flv",
244 "mp3" => "audio/mpeg",
245 "m4a" | "aac" => "audio/mp4",
246 "flac" => "audio/flac",
247 "ogg" | "opus" => "audio/ogg",
248 _ => "application/octet-stream",
249 }
250}
251
252#[derive(Deserialize)]
253struct SearchReq {
254 q: String,
255 #[serde(default)]
257 sources: Option<String>,
258}
259
260async fn search(
263 State(state): State<Arc<AppState>>,
264 Query(req): Query<SearchReq>,
265) -> Json<torq_sources::SearchReport> {
266 let only = req
267 .sources
268 .as_deref()
269 .map(|s| s.split(',').map(str::to_string).collect::<Vec<_>>());
270 let report = torq_sources::aggregate::search_all(
271 &state.sources.sources,
272 &state.client,
273 &req.q,
274 only.as_deref(),
275 )
276 .await;
277 Json(report)
278}
279
280#[derive(Deserialize)]
281struct AddReq {
282 #[serde(default)]
283 magnet: String,
284 #[serde(default)]
285 paused: bool,
286 #[serde(default)]
288 torrent_b64: Option<String>,
289}
290
291async fn add_torrent(
292 State(state): State<Arc<AppState>>,
293 Json(req): Json<AddReq>,
294) -> Result<Json<TorrentView>, ApiError> {
295 let view = match req.torrent_b64 {
296 Some(b64) => {
297 use base64::Engine;
298 let bytes = base64::engine::general_purpose::STANDARD
299 .decode(b64.trim())
300 .map_err(|e| ApiError::BadRequest(format!("invalid torrent_b64: {e}")))?;
301 state.daemon.add_torrent_bytes(bytes, req.paused).await?
302 }
303 None if req.magnet.trim().is_empty() => {
304 return Err(ApiError::BadRequest(
305 "provide a magnet or torrent_b64".into(),
306 ));
307 }
308 None => state.daemon.add_magnet(&req.magnet, req.paused).await?,
309 };
310 Ok(Json(view))
311}
312
313#[derive(Deserialize, Default)]
314struct RemoveReq {
315 #[serde(default, deserialize_with = "deserialize_bool_flag")]
316 delete_files: bool,
317}
318
319fn deserialize_bool_flag<'de, D>(d: D) -> Result<bool, D::Error>
322where
323 D: serde::Deserializer<'de>,
324{
325 let s = String::deserialize(d)?;
326 match s.as_str() {
327 "true" | "1" => Ok(true),
328 "false" | "0" => Ok(false),
329 other => Err(serde::de::Error::custom(format!(
330 "expected true/false/1/0, got {other:?}"
331 ))),
332 }
333}
334
335async fn remove_torrent(
336 State(state): State<Arc<AppState>>,
337 Path(id): Path<String>,
338 Query(req): Query<RemoveReq>,
339) -> Result<StatusCode, ApiError> {
340 let parsed = TorrentIdOrHash::parse(&id)?;
341 state.daemon.remove(parsed, req.delete_files).await?;
342 Ok(StatusCode::NO_CONTENT)
343}
344
345async fn pause_torrent(
346 State(state): State<Arc<AppState>>,
347 Path(id): Path<String>,
348) -> Result<StatusCode, ApiError> {
349 state.daemon.pause(TorrentIdOrHash::parse(&id)?).await?;
350 Ok(StatusCode::NO_CONTENT)
351}
352
353async fn resume_torrent(
354 State(state): State<Arc<AppState>>,
355 Path(id): Path<String>,
356) -> Result<StatusCode, ApiError> {
357 state.daemon.resume(TorrentIdOrHash::parse(&id)?).await?;
358 Ok(StatusCode::NO_CONTENT)
359}
360
361async fn list_rss(State(state): State<Arc<AppState>>) -> Json<Vec<crate::rss::Subscription>> {
362 Json(state.daemon.rss.list())
363}
364
365#[derive(Deserialize)]
366struct AddRssReq {
367 url: String,
368 #[serde(default)]
369 title_re: Option<String>,
370 #[serde(default)]
371 min_size: Option<u64>,
372 #[serde(default)]
373 max_size: Option<u64>,
374 #[serde(default = "default_sub_interval")]
375 interval_secs: u64,
376}
377
378fn default_sub_interval() -> u64 {
379 300
380}
381
382async fn add_rss(
383 State(state): State<Arc<AppState>>,
384 Json(req): Json<AddRssReq>,
385) -> Result<Json<crate::rss::Subscription>, ApiError> {
386 let sub = state.daemon.rss.add(
387 &req.url,
388 req.title_re,
389 req.min_size,
390 req.max_size,
391 req.interval_secs,
392 )?;
393 Ok(Json(sub))
394}
395
396async fn remove_rss(
397 State(state): State<Arc<AppState>>,
398 Path(id): Path<u64>,
399) -> Result<StatusCode, ApiError> {
400 if state.daemon.rss.remove(id) {
401 Ok(StatusCode::NO_CONTENT)
402 } else {
403 Err(ApiError::NotFound(format!("subscription {id} not found")))
404 }
405}
406
407#[derive(Serialize)]
408struct LibraryStatus {
409 indexed: usize,
410 dirs: Vec<PathBuf>,
411}
412
413async fn library_status(State(state): State<Arc<AppState>>) -> Json<LibraryStatus> {
414 Json(LibraryStatus {
415 indexed: state.daemon.library.count(),
416 dirs: state.daemon.library.dirs(),
417 })
418}
419
420async fn library_scan(State(state): State<Arc<AppState>>) -> Result<Json<LibraryStatus>, ApiError> {
422 state.daemon.library.scan()?;
423 Ok(Json(LibraryStatus {
424 indexed: state.daemon.library.count(),
425 dirs: state.daemon.library.dirs(),
426 }))
427}
428
429#[derive(Deserialize)]
430struct LimitsReq {
431 upload_bps: Option<u32>,
432 download_bps: Option<u32>,
433}
434
435async fn set_limits(
438 State(state): State<Arc<AppState>>,
439 Json(req): Json<LimitsReq>,
440) -> Result<StatusCode, ApiError> {
441 state
442 .daemon
443 .engine()
444 .set_limits(req.upload_bps, req.download_bps);
445 let mut config = crate::config::Config::load()?;
446 config.upload_bps = req.upload_bps;
447 config.download_bps = req.download_bps;
448 config.save()?;
449 Ok(StatusCode::NO_CONTENT)
450}
451
452async fn events(
453 State(state): State<Arc<AppState>>,
454) -> Sse<impl Stream<Item = Result<SseEvent, Infallible>>> {
455 let stream = BroadcastStream::new(state.daemon.subscribe()).map(|item| {
456 let data = match item {
457 Ok(Event::TorrentFailed { id, error }) => {
458 serde_json::json!({"type": "torrent_failed", "id": id, "error": error}).to_string()
459 }
460 Ok(ev) => serde_json::to_string(&ev).unwrap_or_else(|_| "{}".into()),
461 Err(_) => "{}".into(), };
463 Ok(SseEvent::default().event("torq").data(data))
464 });
465 Sse::new(stream).keep_alive(KeepAlive::default())
466}
467
468pub enum ApiError {
471 NotFound(String),
472 BadRequest(String),
473 Internal(anyhow::Error),
474}
475
476impl IntoResponse for ApiError {
477 fn into_response(self) -> Response {
478 match self {
479 Self::NotFound(m) => (StatusCode::NOT_FOUND, m).into_response(),
480 Self::BadRequest(m) => (StatusCode::BAD_REQUEST, m).into_response(),
481 Self::Internal(e) => {
482 tracing::error!("api error: {e:#}");
483 (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response()
484 }
485 }
486 }
487}
488
489impl From<anyhow::Error> for ApiError {
490 fn from(e: anyhow::Error) -> Self {
491 let msg = e.to_string();
492 if msg.contains("not found") || msg.contains("not managed") {
493 Self::NotFound(msg)
494 } else if msg.contains("not a valid magnet") || msg.contains("failed to parse") {
495 Self::BadRequest(msg)
496 } else {
497 Self::Internal(e)
498 }
499 }
500}
501
502impl From<librqbit::ApiError> for ApiError {
503 fn from(e: librqbit::ApiError) -> Self {
504 let msg = e.to_string();
505 if msg.contains("not found") {
506 Self::NotFound(msg)
507 } else {
508 Self::Internal(anyhow::anyhow!(msg))
509 }
510 }
511}
512
513impl From<std::io::Error> for ApiError {
514 fn from(e: std::io::Error) -> Self {
515 Self::Internal(anyhow::anyhow!(e))
516 }
517}