//! The TV side of the library API. See DESIGN.md §4, §4.1 and §4.2. //! //! Nothing here is generic over media kind (§11): movies are concrete in //! `movies.rs` and series are concrete here. What the two do share is the //! policy loader, the release table and the owner tags, which already exist. //! //! Two rules shape the endpoints: //! //! - **Intent lives at the leaf** (§4.1). `wanted` is set on an episode. //! `auto_track` on the series and `tracked` on the season are rules that //! decide what happens to episodes a metadata refresh reveals; neither is //! intent, and neither is read to answer "is this wanted". //! - **Status is derived, never stored** (§4.2). Every series the API returns //! carries a status computed from its episodes at request time. use std::collections::HashMap; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use arr_core::tracking::{apply_auto_track, apply_tracked, RefreshedSeason}; use arr_core::{ derive_series_status, EpisodeId, Language, MediaState, RootId, SeasonId, SeriesId, SeriesStatus, TitleOverrides, }; use arr_db::policy::language; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::Json; use chrono::{DateTime, NaiveDate, NaiveTime, Utc}; use serde::{Deserialize, Serialize}; use utoipa::{IntoParams, ToSchema}; use crate::movies::{pool, rescore, Accepted, ApiError, ErrorBody, Release}; use crate::owners::Owner; use crate::search::tmdb_client; use crate::state::{AppState, EpisodeCommand, MetadataCommand, SeasonCommand}; /// A series with the status derived from its episodes (§4.2). #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Series { pub id: i64, pub tmdb_id: i64, /// The Torznab `tvdbid` (§6.1), when TMDB knows one. Null means indexer /// searches fall back to the title text query. pub tvdb_id: Option, pub title: String, pub year: Option, pub original_language: Option, pub root_id: i64, /// §4.1. A rule about seasons metadata reveals, not intent. pub auto_track: bool, pub overrides: serde_json::Value, /// Whether the show finished upstream, which `ended` is derived from. pub upstream_ended: bool, pub blocked: bool, /// Path fragment, not a URL; stored at add time and refreshed with /// metadata (#145), so pure-SQL views never need TMDB (§9.6). pub poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. pub vote_average: Option, /// NULL until a metadata refresh has stamped it (#160). Issue #177: this /// is what tells the SPA a series with no seasons yet is still seeding /// its first refresh, rather than a title upstream genuinely lists none. pub metadata_refreshed_at: Option, /// `airing`, `incomplete`, `waiting`, `complete` or `ended` (§4.2). pub status: String, /// Episodes currently marked wanted (§4.1 — the only intent). pub wanted_episodes: i64, /// Wanted episodes already on disk. pub available_episodes: i64, } #[derive(Debug, Deserialize, ToSchema)] pub struct CreateSeries { pub tmdb_id: i64, pub title: String, pub year: Option, pub original_language: Option, pub root_id: i64, #[serde(default)] pub auto_track: bool, #[serde(default)] pub blocked: bool, #[serde(default)] pub upstream_ended: bool, #[serde(default = "empty_overrides")] pub overrides: serde_json::Value, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateSeries { pub title: Option, pub year: Option>, pub original_language: Option>, pub root_id: Option, pub auto_track: Option, pub blocked: Option, pub upstream_ended: Option, pub overrides: Option, } /// A season and every episode in it, which is how the UI reads it. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Season { pub id: i64, pub series_id: i64, pub number: i64, /// §4.1. Whether new episodes of this season arrive wanted. pub tracked: bool, /// #141. The season vanished upstream while files remained under it, /// which is why the row still exists. A conflict for the operator to /// resolve; nothing was deleted from disk. pub vanished: bool, pub episodes: Vec, } #[derive(Debug, Clone, Serialize, ToSchema)] pub struct Episode { pub id: i64, /// The owning series — the episode deck route needs it without walking /// seasons first. pub series_id: i64, pub season_id: i64, pub season_number: i64, pub number: i64, pub title: String, pub air_date: Option, /// §4.1. The only intent in the TV aggregate. pub wanted: bool, pub state: String, /// #122. The episode vanished upstream while a file of its own remained, /// which is why the row still exists. A conflict for the operator to /// resolve; nothing was deleted from disk. pub vanished: bool, pub search_attempts: i64, pub last_searched_at: Option, } /// A season revealed by metadata, with the episodes it holds. /// /// The series' `auto_track` decides whether those episodes arrive wanted /// (§4.1); the request never says so directly. #[derive(Debug, Deserialize, ToSchema)] pub struct CreateSeason { pub number: i64, #[serde(default)] pub episodes: Vec, } #[derive(Debug, Deserialize, ToSchema)] pub struct CreateEpisode { pub number: i64, pub title: String, /// ISO-8601 date, as TMDB gives it. pub air_date: Option, } /// Both fields of a season a person can set by hand. /// /// Turning `tracked` on marks every already-revealed episode wanted, and /// episodes revealed later follow while it stays on; turning it off clears /// `wanted` on all of them (§4.1, #171) — idempotently, so re-sending /// `tracked: false` to an already-untracked season still clears its /// stranded wanted rows. `wanted` is the one-click "grab this season", and /// writes intent onto every episode already in it — the two are deliberately /// separate (§4.1). #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateSeason { pub tracked: Option, pub wanted: Option, } #[derive(Debug, Deserialize, ToSchema)] pub struct UpdateEpisode { pub wanted: Option, } fn empty_overrides() -> serde_json::Value { serde_json::json!({}) } fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> { let Some(object) = value.as_object() else { return Err(ApiError::Invalid("overrides must be an object".into())); }; if object .keys() .any(|key| key != "only_4k" && key != "allow_english_audio") { return Err(ApiError::Invalid( "overrides supports only only_4k and allow_english_audio".into(), )); } if object.values().any(|value| !value.is_boolean()) { return Err(ApiError::Invalid("override values must be booleans".into())); } Ok(()) } /// The stored columns, before the derived status is attached. struct SeriesRow { id: i64, tmdb_id: i64, tvdb_id: Option, title: String, year: Option, original_language: Option, root_id: i64, auto_track: bool, overrides: serde_json::Value, upstream_ended: bool, blocked: bool, /// Stored at add time and refreshed with metadata (#145), so pure-SQL /// views never need TMDB (§9.6). poster_path: Option, /// TMDB's rating, out of 10; `null` when TMDB has no votes for it. vote_average: Option, /// NULL until a metadata refresh has stamped it (#160): the fact the /// auto-track rule needs to tell a seeding refresh from a later one. metadata_refreshed_at: Option, } struct EpisodeRow { series_id: i64, id: i64, season_id: i64, season_number: i64, number: i64, title: String, air_date: Option, wanted: bool, state: String, vanished: bool, search_attempts: i64, last_searched_at: Option, } fn media_state(value: &str) -> MediaState { match value { "downloading" => MediaState::Downloading, "available" => MediaState::Available, _ => MediaState::Missing, } } /// An `air_date` as TMDB writes it, or as a full timestamp if one ever /// arrives that way. Anything else is treated as unknown, which §4.2 already /// has a meaning for: it cannot pull a series into `airing`. fn air_date(value: Option<&str>) -> Option { let value = value?; let timestamp = if let Ok(date) = value.parse::() { date.and_time(NaiveTime::MIN).and_utc().timestamp() } else { value.parse::>().ok()?.timestamp() }; let seconds = u64::try_from(timestamp.abs()).ok()?; if timestamp < 0 { UNIX_EPOCH.checked_sub(Duration::from_secs(seconds)) } else { UNIX_EPOCH.checked_add(Duration::from_secs(seconds)) } } fn core_series(row: &SeriesRow) -> arr_core::Series { arr_core::Series { id: SeriesId(row.id), tmdb_id: u64::try_from(row.tmdb_id).unwrap_or_default(), title: row.title.clone(), year: row .year .and_then(|year| u16::try_from(year).ok()) .unwrap_or_default(), original_language: row .original_language .as_deref() .map_or(Language::Other(String::new()), language), root_id: RootId(row.root_id), auto_track: row.auto_track, overrides: TitleOverrides::default(), upstream_ended: row.upstream_ended, blocked: row.blocked, } } fn core_episode(row: &EpisodeRow) -> arr_core::Episode { arr_core::Episode { id: EpisodeId(row.id), season_id: SeasonId(row.season_id), season_number: u16::try_from(row.season_number).unwrap_or_default(), number: u16::try_from(row.number).unwrap_or_default(), title: row.title.clone(), air_date: air_date(row.air_date.as_deref()), wanted: row.wanted, state: media_state(&row.state), search_attempts: u32::try_from(row.search_attempts).unwrap_or_default(), last_searched_at: None, } } fn status_name(status: SeriesStatus) -> &'static str { match status { SeriesStatus::Airing => "airing", SeriesStatus::Incomplete => "incomplete", SeriesStatus::Waiting => "waiting", SeriesStatus::Complete => "complete", SeriesStatus::Ended => "ended", } } fn with_status(row: &SeriesRow, episodes: &[arr_core::Episode], now: SystemTime) -> Series { // §4.2: season 0 is invisible to status, so it stays out of the counters // too. A series reading `complete` next to `42/52 eps` is the confusion // this avoids. The season number rides on each episode (#131), so no // parallel slice can be forgotten. let wanted = episodes .iter() .filter(|episode| episode.wanted && episode.season_number != 0); let available = wanted .clone() .filter(|episode| episode.state == MediaState::Available); Series { id: row.id, tmdb_id: row.tmdb_id, tvdb_id: row.tvdb_id, title: row.title.clone(), year: row.year, original_language: row.original_language.clone(), root_id: row.root_id, auto_track: row.auto_track, overrides: row.overrides.clone(), upstream_ended: row.upstream_ended, blocked: row.blocked, poster_path: row.poster_path.clone(), vote_average: row.vote_average, metadata_refreshed_at: row.metadata_refreshed_at.clone(), status: status_name(derive_series_status(&core_series(row), episodes, now)).to_owned(), wanted_episodes: i64::try_from(wanted.count()).unwrap_or(i64::MAX), available_episodes: i64::try_from(available.count()).unwrap_or(i64::MAX), } } /// Every episode in the library, keyed by the series it belongs to. /// /// One query rather than one per series: the whole table is a few thousand /// rows for a single household (§10), and the status of every listed series /// needs all of them anyway. async fn tv_by_series(state: &AppState) -> Result>, ApiError> { let rows = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id"# ) .fetch_all(pool(state)?) .await?; let mut episodes: HashMap> = HashMap::new(); for row in &rows { episodes .entry(row.series_id) .or_default() .push(core_episode(row)); } Ok(episodes) } async fn load_series_row(state: &AppState, id: i64) -> Result { sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series WHERE id = ?"#, id) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::SeriesNotFound) } async fn load_series(state: &AppState, id: i64) -> Result { let row = load_series_row(state, id).await?; let episodes = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, id ) .fetch_all(pool(state)?) .await?; let episodes: Vec<_> = episodes.iter().map(core_episode).collect(); Ok(with_status(&row, &episodes, SystemTime::now())) } async fn require_tv_root(state: &AppState, root_id: i64) -> Result<(), ApiError> { let tv_root = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM roots WHERE id = ? AND kind = 'tv') AS 'exists!: bool'", root_id ) .fetch_one(pool(state)?) .await?; if tv_root { Ok(()) } else { Err(ApiError::Invalid("root_id must name a TV root".into())) } } #[derive(Debug, Deserialize, IntoParams)] pub struct ListSeriesQuery { /// Restrict to series tagged with this owner (DESIGN.md §4.3). pub owner_id: Option, } #[utoipa::path( get, path = "/api/series", tag = "series", params(ListSeriesQuery), responses( (status = 200, body = [Series]), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn list( State(state): State, Query(query): Query, ) -> Result>, ApiError> { let rows = if let Some(owner_id) = query.owner_id { sqlx::query_as!(SeriesRow, r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64", s.tvdb_id, s.title AS "title!: String", s.year, s.original_language, s.root_id AS "root_id!: i64", s.auto_track AS "auto_track!: bool", s.overrides AS "overrides!: serde_json::Value", s.upstream_ended AS "upstream_ended!: bool", s.blocked AS "blocked!: bool", poster_path, vote_average, s.metadata_refreshed_at FROM series s JOIN title_owners t ON t.title_kind = 'series' AND t.title_id = s.id WHERE t.owner_id = ? ORDER BY s.title, s.year, s.id"#, owner_id) .fetch_all(pool(&state)?) .await? } else { sqlx::query_as!(SeriesRow, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", tvdb_id, title AS "title!: String", year, original_language, root_id AS "root_id!: i64", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool", poster_path, vote_average, metadata_refreshed_at FROM series ORDER BY title, year, id"#) .fetch_all(pool(&state)?) .await? }; let episodes = tv_by_series(&state).await?; let now = SystemTime::now(); let no_episodes: Vec = Vec::new(); Ok(Json( rows.iter() .map(|row| with_status(row, episodes.get(&row.id).unwrap_or(&no_episodes), now)) .collect(), )) } #[utoipa::path( post, path = "/api/series", tag = "series", request_body = CreateSeries, responses( (status = 201, body = Series), (status = 409, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn create( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), ApiError> { if input.title.trim().is_empty() || input.tmdb_id <= 0 { return Err(ApiError::Invalid("tmdb_id and title are required".into())); } validate_overrides(&input.overrides)?; require_tv_root(&state, input.root_id).await?; let overrides = serde_json::to_string(&input.overrides) .map_err(|error| ApiError::Invalid(error.to_string()))?; let title = input.title.trim(); // §6.1: the TVDB id is what `t=tvsearch` is addressed by, but TMDB not // knowing one must not block adding the series — the search falls back // to the title text query until a refresh fills it in (#121). The same // response carries §9.6's stored artwork fields, so a series added today // has a poster before tomorrow's refresh. Best effort either way. let tmdb_series = lookup_tmdb_series(&state, input.tmdb_id).await; let tvdb_id = tmdb_series.as_ref().and_then(|s| s.tvdb_id).map(i64::from); let poster_path = tmdb_series.as_ref().and_then(|s| s.poster_path.clone()); let backdrop_path = tmdb_series.as_ref().and_then(|s| s.backdrop_path.clone()); let vote_average = tmdb_series.as_ref().and_then(|s| s.vote_average); let result = sqlx::query!( "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, poster_path, backdrop_path, vote_average) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id, input.auto_track, input.upstream_ended, input.blocked, overrides, poster_path, backdrop_path, vote_average, ) .execute(pool(&state)?) .await?; let series_id = result.last_insert_rowid(); // Issue #176: a new series has no seasons until a refresh reveals them, // and the metadata lane is daily. Ask it to run now. Asynchronous by // design — the add is already committed and must not wait on, or fail // because of, TMDB. If nothing is draining, the daily sweep still picks // the series up: `metadata_refreshed_at` is NULL. if let Err(error) = state.send_metadata_command(MetadataCommand::Series { series_id }) { tracing::warn!(series_id, %error, "metadata refresh not queued for the new series"); } Ok(( StatusCode::CREATED, Json(load_series(&state, series_id).await?), )) } /// Best effort: `None` when TMDB has no such id or cannot be reached. async fn lookup_tmdb_series(state: &AppState, tmdb_id: i64) -> Option { let client = tmdb_client(state).ok()?; client.series(u32::try_from(tmdb_id).ok()?).await.ok() } #[utoipa::path( get, path = "/api/series/{series_id}", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = Series), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn get( State(state): State, Path(id): Path, ) -> Result, ApiError> { Ok(Json(load_series(&state, id).await?)) } #[utoipa::path( post, path = "/api/series/{series_id}/refresh-metadata", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 202, body = Accepted), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] /// Issue #177: the same on-demand command `create` sends on add, resent by /// hand when a page gave up polling for it. Same terms — asynchronous, best /// effort, and a failure leaves `metadata_refreshed_at` NULL for the daily /// sweep to pick up. pub async fn refresh_metadata( State(state): State, Path(series_id): Path, ) -> Result<(StatusCode, Json), ApiError> { load_series_row(&state, series_id).await?; state .send_metadata_command(MetadataCommand::Series { series_id }) .map_err(|_| ApiError::Unavailable)?; Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } #[utoipa::path( patch, path = "/api/series/{series_id}", tag = "series", request_body = UpdateSeries, params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = Series), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn update( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { let current = load_series_row(&state, id).await?; let title = input.title.unwrap_or(current.title); if title.trim().is_empty() { return Err(ApiError::Invalid("title cannot be empty".into())); } let overrides = input.overrides.unwrap_or(current.overrides); validate_overrides(&overrides)?; let overrides = serde_json::to_string(&overrides).map_err(|error| ApiError::Invalid(error.to_string()))?; let title = title.trim(); let year = input.year.unwrap_or(current.year); let original_language = input.original_language.unwrap_or(current.original_language); let root_id = input.root_id.unwrap_or(current.root_id); if input.root_id.is_some() { require_tv_root(&state, root_id).await?; } let auto_track = input.auto_track.unwrap_or(current.auto_track); let upstream_ended = input.upstream_ended.unwrap_or(current.upstream_ended); let blocked = input.blocked.unwrap_or(current.blocked); sqlx::query!("UPDATE series SET title = ?, year = ?, original_language = ?, root_id = ?, auto_track = ?, upstream_ended = ?, blocked = ?, overrides = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides, id) .execute(pool(&state)?) .await?; Ok(Json(load_series(&state, id).await?)) } #[utoipa::path( delete, path = "/api/series/{series_id}", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 204), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn delete( State(state): State, Path(id): Path, ) -> Result { // The row is loaded first so a missing series is 404 before anything // touches the disk. load_series_row(&state, id).await?; // The whole-title scope of the same removal the season and episode // endpoints use (#174). The intent clear it performs is redundant here — // the episode rows go with the series row below — but sharing one path // is what keeps the three scopes from drifting apart. remove_scope_files(&state, FileScope::Series(id)).await?; // Owner tags go with the title they tagged. sqlx::query!( "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ?", id ) .execute(pool(&state)?) .await?; let result = sqlx::query!("DELETE FROM series WHERE id = ?", id) .execute(pool(&state)?) .await?; if result.rows_affected() == 0 { return Err(ApiError::SeriesNotFound); } Ok(StatusCode::NO_CONTENT) } /// What one removal call covers. The series variant is the whole title; the /// other two are the sub-series scopes #174 adds, and they must never widen /// past themselves — removing one episode leaves its siblings and the rest /// of the season on disk. #[derive(Debug, Clone, Copy)] enum FileScope { /// Series row id. Series(i64), /// Season row id, not its number. Season(i64), /// Episode row id. Episode(i64), } /// Unlink what this scope put under its series' root. Mirrors the movie /// handler in `movies.rs`, and is the only unlink path below a series (#174). /// /// The service knows only what it wrote (§2), so the targets come from /// `media_files`, never from a scan and never from re-deriving the §7.4 name /// — a series renamed after import would derive a folder that does not exist /// while the real one stayed. /// /// What a target *is* depends on the scope. A whole series resolves each file /// to its title folder, which makes that delete atomic (§7.4): season /// subfolders, sidecar subtitles and artwork go with it. A season or a single /// episode resolves to the recorded file and nothing else — the title folder /// holds the siblings this call must not touch, and a season subfolder would /// have to be re-derived to be named, which §2 forbids. Sidecars beside a /// removed episode therefore stay; they are not rows this service wrote. /// /// The torrent is untouched (§7.3). It keeps seeding under its own rule and /// the reaper deletes it; a hardlinked file loses only its library name. /// /// Failure leaves the database alone, so the operator sees the files still /// recorded and can retry rather than losing the record of what is on disk. async fn remove_library_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> { let root = scope_root(state, scope).await?; let paths = scope_paths(state, scope).await?; let mut targets: Vec = Vec::new(); for path in &paths { let resolved = match scope { FileScope::Series(_) => crate::movies::title_target(&root, path), FileScope::Season(_) | FileScope::Episode(_) => contained_file(&root, path), }; let Some(target) = resolved else { // Outside its own root: not ours to delete. The row still goes, // so the operator sees the file leave the library and stay on // disk. tracing::warn!(%path, %root, "media file is outside its root, not deleted"); continue; }; if !targets.contains(&target) { targets.push(target); } } for target in targets { let metadata = match tokio::fs::symlink_metadata(&target).await { Ok(metadata) => metadata, // Already gone is the state we wanted. Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, Err(error) => return Err(ApiError::Filesystem(error.to_string())), }; let removed = if metadata.is_dir() { tokio::fs::remove_dir_all(&target).await } else { tokio::fs::remove_file(&target).await }; match removed { Ok(()) => tracing::info!(target = %target.display(), "removed library files"), Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => return Err(ApiError::Filesystem(error.to_string())), } } Ok(()) } /// The library root the scope's series sits on. Every unlink is measured /// against it, so it is looked up rather than assumed. async fn scope_root(state: &AppState, scope: FileScope) -> Result { Ok(match scope { FileScope::Series(id) => { sqlx::query_scalar!( r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id WHERE s.id = ?"#, id ) .fetch_one(pool(state)?) .await? } FileScope::Season(id) => { sqlx::query_scalar!( r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id JOIN seasons se ON se.series_id = s.id WHERE se.id = ?"#, id ) .fetch_one(pool(state)?) .await? } FileScope::Episode(id) => { sqlx::query_scalar!( r#"SELECT r.path AS "path!: String" FROM roots r JOIN series s ON s.root_id = r.id JOIN seasons se ON se.series_id = s.id JOIN episodes e ON e.season_id = se.id WHERE e.id = ?"#, id ) .fetch_one(pool(state)?) .await? } }) } /// Every file this service recorded for the scope, and nothing else. The /// `WHERE` clause is the whole guard against a narrow call widening. async fn scope_paths(state: &AppState, scope: FileScope) -> Result, ApiError> { Ok(match scope { FileScope::Series(id) => { sqlx::query_scalar!( r#"SELECT mf.path AS "path!: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, id ) .fetch_all(pool(state)?) .await? } FileScope::Season(id) => { sqlx::query_scalar!( r#"SELECT mf.path AS "path!: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id WHERE e.season_id = ?"#, id ) .fetch_all(pool(state)?) .await? } FileScope::Episode(id) => { sqlx::query_scalar!( r#"SELECT path AS "path!: String" FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?"#, id ) .fetch_all(pool(state)?) .await? } }) } /// The one recorded file, when it really sits inside the root. `None` when it /// does not, which is the guard that keeps a sub-series delete inside the /// library it belongs to. /// /// Unlike [`crate::movies::title_target`] this keeps the whole relative path /// rather than its first component, so it can only ever name the file the row /// records. Every component must be a plain name: one `..` anywhere would /// climb back out of the root it just proved it was under. fn contained_file(root: &str, file: &str) -> Option { let root = std::path::Path::new(root); let relative = std::path::Path::new(file).strip_prefix(root).ok()?; if relative.as_os_str().is_empty() { // The root itself is never a file of ours. return None; } if !relative .components() .all(|component| matches!(component, std::path::Component::Normal(_))) { return None; } Some(root.join(relative)) } /// Removes the files a scope below a series covers, drops their `media_files` /// rows and clears the intent behind them (#174). /// /// One action, both halves: the operator asked for removal and un-wanting /// together, not two controls to remember to use in order. /// /// The season and episode rows themselves stay. TMDB owns that metadata and /// the next refresh would recreate them, which is what separates this from /// `DELETE /api/series/{id}`. /// /// Disk first, database second, so a filesystem failure leaves the rows /// describing what is still there. async fn remove_scope_files(state: &AppState, scope: FileScope) -> Result<(), ApiError> { remove_library_files(state, scope).await?; // One transaction: the file rows and the intent they carried land // together or not at all. let mut transaction = pool(state)?.begin().await?; // `media_files.path` is UNIQUE and the owner is polymorphic, so nothing // cascades and nothing here deletes an owning row: leaving these behind // would block re-importing the same path after a re-grab. match scope { FileScope::Series(id) => { sqlx::query!( "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN ( SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?)", id ) .execute(&mut *transaction) .await?; } FileScope::Season(id) => { sqlx::query!( "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id IN ( SELECT e.id FROM episodes e WHERE e.season_id = ?)", id ) .execute(&mut *transaction) .await?; } FileScope::Episode(id) => { sqlx::query!( "DELETE FROM media_files WHERE owner_kind = 'episode' AND owner_id = ?", id ) .execute(&mut *transaction) .await?; } } // The intent goes through `arr_core::tracking::apply_tracked` with // `false`, the same §4.1 rule an untracked season runs (#171), so both // paths agree on what clearing intent means. An episode-scoped call // hands it a slice of one: the rule cannot reach a sibling it was not // given. let rows = match scope { FileScope::Series(id) => { sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?"#, id ) .fetch_all(&mut *transaction) .await? } FileScope::Season(id) => { sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#, id ) .fetch_all(&mut *transaction) .await? } FileScope::Episode(id) => { sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#, id ) .fetch_all(&mut *transaction) .await? } }; let mut episodes: Vec<_> = rows.iter().map(core_episode).collect(); apply_tracked(false, &mut episodes); for episode in &episodes { // `available` was true of an episode with a file. It no longer has // one, so it goes back to `missing` the way a failed import already // puts it — `wanted` is now 0, so this opens no gap. `downloading` // is left alone: that grab is still in flight. sqlx::query!( "UPDATE episodes SET wanted = ?, state = CASE WHEN state = 'available' THEN 'missing' ELSE state END, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", episode.wanted, episode.id.0 ) .execute(&mut *transaction) .await?; } transaction.commit().await?; Ok(()) } #[utoipa::path( delete, path = "/api/series/{series_id}/seasons/{season_number}/files", tag = "series", params( ("series_id" = i64, Path, description = "Series row id"), ("season_number" = i64, Path, description = "Season number, not its row id") ), responses( (status = 204), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn delete_season_files( State(state): State, Path((series_id, number)): Path<(i64, i64)>, ) -> Result { // Resolved first so an unknown series or season is 404 before anything // touches the disk. A season with nothing on disk is not an error — the // call still clears intent. load_series_row(&state, series_id).await?; let season_id = sqlx::query_scalar!( r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, series_id, number ) .fetch_optional(pool(&state)?) .await? .ok_or(ApiError::SeasonNotFound)?; remove_scope_files(&state, FileScope::Season(season_id)).await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( delete, path = "/api/episodes/{episode_id}/files", tag = "series", params(("episode_id" = i64, Path, description = "Episode row id")), responses( (status = 204), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn delete_episode_files( State(state): State, Path(id): Path, ) -> Result { // 404 before the disk, and an episode with no file still clears intent. load_episode(&state, id).await?; remove_scope_files(&state, FileScope::Episode(id)).await?; Ok(StatusCode::NO_CONTENT) } async fn load_seasons(state: &AppState, series_id: i64) -> Result, ApiError> { let seasons = sqlx::query!( // §9.6: newest-first, so season 0 lands last under plain numeric // descending order — exactly where the design puts it. r#"SELECT id AS "id!: i64", series_id AS "series_id!: i64", number AS "number!: i64", tracked AS "tracked!: bool", vanished AS "vanished!: bool" FROM seasons WHERE series_id = ? ORDER BY number DESC"#, series_id ) .fetch_all(pool(state)?) .await?; let episodes = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY se.number DESC, e.number DESC"#, series_id ) .fetch_all(pool(state)?) .await?; Ok(seasons .into_iter() .map(|season| Season { id: season.id, series_id: season.series_id, number: season.number, tracked: season.tracked, vanished: season.vanished, episodes: episodes .iter() .filter(|episode| episode.season_id == season.id) .map(|episode| Episode { id: episode.id, series_id, season_id: episode.season_id, season_number: episode.season_number, number: episode.number, title: episode.title.clone(), air_date: episode.air_date.clone(), wanted: episode.wanted, state: episode.state.clone(), vanished: episode.vanished, search_attempts: episode.search_attempts, last_searched_at: episode.last_searched_at.clone(), }) .collect(), }) .collect()) } #[utoipa::path( get, path = "/api/series/{series_id}/seasons", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = [Season]), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn seasons( State(state): State, Path(series_id): Path, ) -> Result>, ApiError> { load_series_row(&state, series_id).await?; Ok(Json(load_seasons(&state, series_id).await?)) } /// Records a season metadata has revealed, applying the series' tracking rule. #[utoipa::path( post, path = "/api/series/{series_id}/seasons", tag = "series", request_body = CreateSeason, params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 201, body = Season), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 422, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn create_season( State(state): State, Path(series_id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), ApiError> { let series = load_series_row(&state, series_id).await?; if input.number < 0 { return Err(ApiError::Invalid("season number cannot be negative".into())); } if input.episodes.iter().any(|episode| episode.number < 0) { return Err(ApiError::Invalid( "episode number cannot be negative".into(), )); } // The column rejects the empty string (#153); say so before the database // has to. if input .episodes .iter() .any(|episode| episode.title.trim().is_empty()) { return Err(ApiError::Invalid("episode title cannot be empty".into())); } let mut numbers: Vec = input .episodes .iter() .map(|episode| episode.number) .collect(); numbers.sort_unstable(); if numbers.windows(2).any(|pair| pair[0] == pair[1]) { return Err(ApiError::Invalid( "episode numbers must be unique within the season".into(), )); } // §4.1. The request does not say whether the episodes are wanted; the // series' auto_track rule does, through the one function that owns it. // #160: whether a refresh has happened yet rides in from the row — a // hand-added season on a never-refreshed series is still seeding, and // §4.1 does not track what was there at add time. let mut revealed = [RefreshedSeason { season: arr_core::Season { id: SeasonId(0), series_id: SeriesId(series_id), number: u16::try_from(input.number).unwrap_or_default(), tracked: false, }, episodes: input .episodes .iter() .map(|episode| arr_core::Episode { id: EpisodeId(0), season_id: SeasonId(0), season_number: u16::try_from(input.number).unwrap_or_default(), number: u16::try_from(episode.number).unwrap_or_default(), title: episode.title.clone(), air_date: air_date(episode.air_date.as_deref()), wanted: false, state: MediaState::Missing, search_attempts: 0, last_searched_at: None, }) .collect(), is_new: true, }]; apply_auto_track( &core_series(&series), series.metadata_refreshed_at.is_some(), &mut revealed, ); let [revealed] = revealed; // One transaction: a rejected episode must not leave the season behind, // or the retry that fixes the request collides with it instead. let mut transaction = pool(&state)?.begin().await?; let season_id = sqlx::query!( "INSERT INTO seasons (series_id, number, tracked) VALUES (?, ?, ?)", series_id, input.number, revealed.season.tracked ) .execute(&mut *transaction) .await? .last_insert_rowid(); for (episode, source) in revealed.episodes.iter().zip(&input.episodes) { sqlx::query!( "INSERT INTO episodes (season_id, number, title, air_date, wanted) VALUES (?, ?, ?, ?, ?)", season_id, source.number, source.title, source.air_date, episode.wanted ) .execute(&mut *transaction) .await?; } transaction.commit().await?; let seasons = load_seasons(&state, series_id).await?; let season = seasons .into_iter() .find(|season| season.id == season_id) .ok_or(ApiError::SeasonNotFound)?; Ok((StatusCode::CREATED, Json(season))) } #[utoipa::path( patch, path = "/api/series/{series_id}/seasons/{season_number}", tag = "series", request_body = UpdateSeason, params( ("series_id" = i64, Path, description = "Series row id"), ("season_number" = i64, Path, description = "Season number, not its row id") ), responses( (status = 200, body = Season), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn update_season( State(state): State, Path((series_id, number)): Path<(i64, i64)>, Json(input): Json, ) -> Result, ApiError> { load_series_row(&state, series_id).await?; let season = sqlx::query!( r#"SELECT id AS "id!: i64", tracked AS "tracked!: bool" FROM seasons WHERE series_id = ? AND number = ?"#, series_id, number ) .fetch_optional(pool(&state)?) .await? .ok_or(ApiError::SeasonNotFound)?; let season_id = season.id; // One transaction: the flag write and the episode intent it implies // land together or not at all (#171). let mut transaction = pool(&state)?.begin().await?; if let Some(tracked) = input.tracked { sqlx::query!( "UPDATE seasons SET tracked = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", tracked, season_id ) .execute(&mut *transaction) .await?; // §4.1, as `arr_core::tracking::apply_tracked` decides it: turning // tracking on marks every already-revealed episode wanted; turning // it off clears them all, season 0 included. The rule runs even when // the flag already holds this value (#171): re-sending `false` to an // untracked season is how stranded wanted rows get cleared. let rows = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.season_id = ?"#, season_id ) .fetch_all(&mut *transaction) .await?; let mut episodes: Vec<_> = rows.iter().map(core_episode).collect(); apply_tracked(tracked, &mut episodes); for episode in &episodes { sqlx::query!( "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", episode.wanted, episode.id.0 ) .execute(&mut *transaction) .await?; } } // §4.1. Marking a season wanted is intent written onto its episodes, so // an untracked series with one wanted season needs no special case. if let Some(wanted) = input.wanted { sqlx::query!( "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE season_id = ?", wanted, season_id ) .execute(&mut *transaction) .await?; } transaction.commit().await?; let seasons = load_seasons(&state, series_id).await?; seasons .into_iter() .find(|season| season.id == season_id) .map(Json) .ok_or(ApiError::SeasonNotFound) } async fn load_episode(state: &AppState, id: i64) -> Result { let row = sqlx::query_as!( EpisodeRow, r#"SELECT se.series_id AS "series_id!: i64", e.id AS "id!: i64", e.season_id AS "season_id!: i64", se.number AS "season_number!: i64", e.number AS "number!: i64", e.title AS "title!: String", e.air_date, e.wanted AS "wanted!: bool", e.state AS "state!: String", e.vanished AS "vanished!: bool", e.search_attempts AS "search_attempts!: i64", e.last_searched_at FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE e.id = ?"#, id ) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::EpisodeNotFound)?; Ok(Episode { id: row.id, series_id: row.series_id, season_id: row.season_id, season_number: row.season_number, number: row.number, title: row.title, air_date: row.air_date, wanted: row.wanted, state: row.state, vanished: row.vanished, search_attempts: row.search_attempts, last_searched_at: row.last_searched_at, }) } #[utoipa::path( get, path = "/api/episodes/{episode_id}", tag = "series", params(("episode_id" = i64, Path, description = "Episode row id")), responses( (status = 200, body = Episode), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn get_episode( State(state): State, Path(id): Path, ) -> Result, ApiError> { Ok(Json(load_episode(&state, id).await?)) } /// Sets the only intent the TV aggregate carries (§4.1). #[utoipa::path( patch, path = "/api/episodes/{episode_id}", tag = "series", request_body = UpdateEpisode, params(("episode_id" = i64, Path, description = "Episode row id")), responses( (status = 200, body = Episode), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn update_episode( State(state): State, Path(id): Path, Json(input): Json, ) -> Result, ApiError> { load_episode(&state, id).await?; if let Some(wanted) = input.wanted { sqlx::query!( "UPDATE episodes SET wanted = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", wanted, id ) .execute(pool(&state)?) .await?; } Ok(Json(load_episode(&state, id).await?)) } #[utoipa::path( post, path = "/api/episodes/{episode_id}/search", tag = "series", params(("episode_id" = i64, Path, description = "Episode row id")), responses( (status = 202, body = Accepted), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn search_episode( State(state): State, Path(id): Path, ) -> Result<(StatusCode, Json), ApiError> { load_episode(&state, id).await?; // §6.3. `blocked` stops targeted search for the whole series. let blocked = sqlx::query_scalar!( r#"SELECT s.blocked AS "blocked!: bool" FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series s ON s.id = se.series_id WHERE e.id = ?"#, id ) .fetch_one(pool(&state)?) .await?; if blocked { return Err(ApiError::Conflict("series is blocked".into())); } state .send_episode_command(EpisodeCommand::Search { episode_id: id }) .map_err(|_| ApiError::Unavailable)?; Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } #[utoipa::path( get, path = "/api/episodes/{episode_id}/releases", tag = "series", params(("episode_id" = i64, Path, description = "Episode row id")), responses( (status = 200, body = [Release]), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn episode_releases( State(state): State, Path(id): Path, ) -> Result>, ApiError> { load_episode(&state, id).await?; let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN episode_releases er ON er.release_id = r.id WHERE er.episode_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, id) .fetch_all(pool(&state)?) .await?; let policy = state .database() .ok_or(ApiError::Unavailable)? .episode_policy(id) .await .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::EpisodeNotFound)? .policy; rescore(&mut releases, &policy)?; Ok(Json(releases)) } #[utoipa::path( post, path = "/api/episodes/{episode_id}/releases/{release_id}/grab", tag = "series", params(("episode_id" = i64, Path), ("release_id" = i64, Path)), responses( (status = 202, body = Accepted), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn grab_episode( State(state): State, Path((episode_id, release_id)): Path<(i64, i64)>, ) -> Result<(StatusCode, Json), ApiError> { let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM episode_releases WHERE episode_id = ? AND release_id = ?) AS 'exists!: bool'", episode_id, release_id) .fetch_one(pool(&state)?) .await?; if !exists { return Err(ApiError::EpisodeNotFound); } state .send_episode_command(EpisodeCommand::Grab { episode_id, release_id, }) .map_err(|_| ApiError::Unavailable)?; Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } /// Resolves a season by its number within one series, so the deck is /// addressed the way the UI shows seasons (`/series/{id}/seasons/{n}`). async fn load_season_id(state: &AppState, series_id: i64, number: i64) -> Result { sqlx::query_scalar!( r#"SELECT id AS "id!: i64" FROM seasons WHERE series_id = ? AND number = ?"#, series_id, number ) .fetch_optional(pool(state)?) .await? .ok_or(ApiError::SeasonNotFound) } #[utoipa::path( post, path = "/api/series/{series_id}/seasons/{season_number}/search", tag = "series", params( ("series_id" = i64, Path, description = "Series row id"), ("season_number" = i64, Path, description = "Season number, not its row id") ), responses( (status = 202, body = Accepted), (status = 404, body = ErrorBody), (status = 409, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn search_season( State(state): State, Path((series_id, number)): Path<(i64, i64)>, ) -> Result<(StatusCode, Json), ApiError> { load_series_row(&state, series_id).await?; let season_id = load_season_id(&state, series_id, number).await?; // §6.3. `blocked` stops targeted search for the whole series. let blocked = sqlx::query_scalar!( r#"SELECT blocked AS "blocked!: bool" FROM series WHERE id = ?"#, series_id ) .fetch_one(pool(&state)?) .await?; if blocked { return Err(ApiError::Conflict("series is blocked".into())); } state .send_season_command(SeasonCommand::Search { season_id }) .map_err(|_| ApiError::Unavailable)?; Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } #[utoipa::path( get, path = "/api/series/{series_id}/seasons/{season_number}/releases", tag = "series", params( ("series_id" = i64, Path, description = "Series row id"), ("season_number" = i64, Path, description = "Season number, not its row id") ), responses( (status = 200, body = [Release]), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn season_releases( State(state): State, Path((series_id, number)): Path<(i64, i64)>, ) -> Result>, ApiError> { load_series_row(&state, series_id).await?; let season_id = load_season_id(&state, series_id, number).await?; let mut releases = sqlx::query_as!(Release, r#"SELECT r.id AS "id!: i64", r.indexer_id AS "indexer_id!: i64", r.guid AS "guid!: String", r.name AS "name!: String", r.size AS "size!: i64", r.seeders, r.publish_date, r.download_url AS "download_url!: String", r.parsed AS "parsed!: serde_json::Value", r.score, r.verdict, r.rejected_rule FROM releases r JOIN season_releases sr ON sr.release_id = r.id WHERE sr.season_id = ? ORDER BY CASE r.verdict WHEN 'eligible' THEN 0 WHEN 'waived' THEN 1 ELSE 2 END, r.score DESC, r.id"#, season_id) .fetch_all(pool(&state)?) .await?; let policy = state .database() .ok_or(ApiError::Unavailable)? .season_policy(season_id) .await .map_err(|error| ApiError::Database(error.to_string()))? .ok_or(ApiError::SeasonNotFound)? .policy; rescore(&mut releases, &policy)?; Ok(Json(releases)) } #[utoipa::path( post, path = "/api/series/{series_id}/seasons/{season_number}/releases/{release_id}/grab", tag = "series", params(("series_id" = i64, Path), ("season_number" = i64, Path), ("release_id" = i64, Path)), responses( (status = 202, body = Accepted), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn grab_season_release( State(state): State, Path((series_id, number, release_id)): Path<(i64, i64, i64)>, ) -> Result<(StatusCode, Json), ApiError> { load_series_row(&state, series_id).await?; let season_id = load_season_id(&state, series_id, number).await?; let exists = sqlx::query_scalar!("SELECT EXISTS(SELECT 1 FROM season_releases WHERE season_id = ? AND release_id = ?) AS 'exists!: bool'", season_id, release_id) .fetch_one(pool(&state)?) .await?; if !exists { return Err(ApiError::SeasonNotFound); } state .send_season_command(SeasonCommand::Grab { season_id, release_id, }) .map_err(|_| ApiError::Unavailable)?; Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true }))) } /// One imported episode file, keyed to its episode so the detail view can /// attach the file's probed §7.4 attributes to the episode row. #[derive(Debug, Clone, Serialize, ToSchema)] pub struct EpisodeFile { pub episode_id: i64, pub path: String, pub size: i64, pub probed: Option, /// The §5.7 rule relaxed to allow this import, when one was. pub waiver: Option, } #[utoipa::path( get, path = "/api/series/{series_id}/files", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = [EpisodeFile]), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn files( State(state): State, Path(id): Path, ) -> Result>, ApiError> { load_series_row(&state, id).await?; let files = sqlx::query_as!(EpisodeFile, r#"SELECT e.id AS "episode_id!: i64", mf.path AS "path!: String", mf.size AS "size!: i64", mf.probed AS "probed?: serde_json::Value", json_extract(mf.waiver, '$.rule') AS "waiver?: String" FROM media_files mf JOIN episodes e ON mf.owner_kind = 'episode' AND e.id = mf.owner_id JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ? ORDER BY mf.path"#, id) .fetch_all(pool(&state)?) .await?; Ok(Json(files)) } #[utoipa::path( get, path = "/api/series/{series_id}/owners", tag = "series", params(("series_id" = i64, Path, description = "Series row id")), responses( (status = 200, body = [Owner]), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn list_owners( State(state): State, Path(id): Path, ) -> Result>, ApiError> { load_series_row(&state, id).await?; let owners = sqlx::query_as!( Owner, r#"SELECT o.id AS "id!: i64", o.name AS "name!: String", o.ntfy_topic AS "ntfy_topic!: String" FROM owners o JOIN title_owners t ON t.owner_id = o.id WHERE t.title_kind = 'series' AND t.title_id = ? ORDER BY o.name"#, id ) .fetch_all(pool(&state)?) .await?; Ok(Json(owners)) } #[utoipa::path( put, path = "/api/series/{series_id}/owners/{owner_id}", tag = "series", params(("series_id" = i64, Path), ("owner_id" = i64, Path)), responses( (status = 204), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn tag_owner( State(state): State, Path((series_id, owner_id)): Path<(i64, i64)>, ) -> Result { load_series_row(&state, series_id).await?; let owner_exists = sqlx::query_scalar!( "SELECT EXISTS(SELECT 1 FROM owners WHERE id = ?) AS 'exists!: bool'", owner_id ) .fetch_one(pool(&state)?) .await?; if !owner_exists { return Err(ApiError::OwnerNotFound); } sqlx::query!( "INSERT INTO title_owners (title_kind, title_id, owner_id) VALUES ('series', ?, ?) ON CONFLICT DO NOTHING", series_id, owner_id ) .execute(pool(&state)?) .await?; Ok(StatusCode::NO_CONTENT) } #[utoipa::path( delete, path = "/api/series/{series_id}/owners/{owner_id}", tag = "series", params(("series_id" = i64, Path), ("owner_id" = i64, Path)), responses( (status = 204), (status = 404, body = ErrorBody), (status = 500, body = ErrorBody), (status = 503, body = ErrorBody) ) )] pub async fn untag_owner( State(state): State, Path((series_id, owner_id)): Path<(i64, i64)>, ) -> Result { load_series_row(&state, series_id).await?; sqlx::query!( "DELETE FROM title_owners WHERE title_kind = 'series' AND title_id = ? AND owner_id = ?", series_id, owner_id ) .execute(pool(&state)?) .await?; Ok(StatusCode::NO_CONTENT) } #[cfg(test)] mod tests { use super::*; use crate::{router, Upstreams}; async fn application() -> (tempfile::TempDir, AppState, String) { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); let state = AppState::new(Upstreams::new( "http://127.0.0.1:1".into(), "http://127.0.0.1:1".into(), )) .expect("state") .with_database(database); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); let app = router(state.clone()); tokio::spawn(async move { axum::serve(listener, app).await.expect("serve") }); (dir, state, format!("http://{address}")) } async fn tv_root(state: &AppState, audience: &str) -> i64 { sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = ?") .bind(audience) .fetch_one(state.database().expect("database").pool()) .await .expect("TV root") } async fn add_series(base: &str, root_id: i64, auto_track: bool) -> serde_json::Value { let response = reqwest::Client::new() .post(format!("{base}/api/series")) .json(&serde_json::json!({ "tmdb_id": 82_728, "title": "Bluey", "year": 2018, "original_language": "en", "root_id": root_id, "auto_track": auto_track })) .send() .await .expect("create series"); assert_eq!(response.status(), StatusCode::CREATED); response.json().await.expect("series json") } async fn add_season( base: &str, series_id: i64, number: i64, episodes: serde_json::Value, ) -> serde_json::Value { let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/seasons")) .json(&serde_json::json!({ "number": number, "episodes": episodes })) .send() .await .expect("create season"); assert_eq!(response.status(), StatusCode::CREATED); response.json().await.expect("season json") } /// §9.6: the three stored artwork fields come off the series detail /// response at add time — the same call that used to fetch only the /// TVDB id — so a series added today has a poster before tomorrow's /// refresh. #[tokio::test] async fn creating_a_series_stores_artwork_from_tmdb() { let tmdb = wiremock::MockServer::start().await; wiremock::Mock::given(wiremock::matchers::method("GET")) .and(wiremock::matchers::path("/tv/82728")) .respond_with( wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({ "id": 82_728, "name": "Bluey", "status": "Returning Series", "poster_path": "/bluey.jpg", "backdrop_path": "/bluey-wide.jpg", "vote_average": 8.417, "external_ids": {"tvdb_id": 361_391} })), ) .mount(&tmdb) .await; let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); let state = AppState::new( Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into()) .with_tmdb_url(tmdb.uri()) .with_tmdb_api_key(Some("key".into())), ) .expect("state") .with_database(database); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); let served = state.clone(); tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") }); let base = format!("http://{address}"); let response = reqwest::Client::new() .post(format!("{base}/api/series")) .json(&serde_json::json!({ "tmdb_id": 82_728, "title": "Bluey", "original_language": "en", "root_id": 3 })) .send() .await .expect("create series"); assert_eq!(response.status(), StatusCode::CREATED); let series_id = response.json::().await.expect("json")["id"] .as_i64() .expect("series id"); let (tvdb_id, poster, backdrop, vote): ( Option, Option, Option, Option, ) = sqlx::query_as( "SELECT tvdb_id, poster_path, backdrop_path, vote_average FROM series WHERE id = ?", ) .bind(series_id) .fetch_one(state.database().expect("database").pool()) .await .expect("series row"); assert_eq!(tvdb_id, Some(361_391)); assert_eq!(poster.as_deref(), Some("/bluey.jpg")); assert_eq!(backdrop.as_deref(), Some("/bluey-wide.jpg")); assert_eq!(vote, Some(8.417)); } /// Issue #176: the daily metadata lane is what reveals a new series' /// seasons, so adding one queues a refresh instead of leaving the page /// empty for up to a day. TMDB here is a closed port: the add still /// returns 201 and the command is still queued, because the refresh is /// the lane's problem and not the request's. #[tokio::test] async fn adding_a_series_queues_a_refresh_even_with_tmdb_down() { let dir = tempfile::tempdir().expect("tempdir"); let database = arr_db::Db::connect(dir.path().join("arr.db")) .await .expect("connect database"); database.migrate().await.expect("migrate database"); let state = AppState::new( Upstreams::new("http://127.0.0.1:1".into(), "http://127.0.0.1:1".into()) .with_tmdb_url("http://127.0.0.1:1".into()) .with_tmdb_api_key(Some("key".into())), ) .expect("state") .with_database(database); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind"); let address = listener.local_addr().expect("address"); let served = state.clone(); tokio::spawn(async move { axum::serve(listener, router(served)).await.expect("serve") }); let base = format!("http://{address}"); let response = reqwest::Client::new() .post(format!("{base}/api/series")) .json(&serde_json::json!({ "tmdb_id": 82_728, "title": "Bluey", "root_id": 3 })) .send() .await .expect("create series"); assert_eq!(response.status(), StatusCode::CREATED); let series_id = response.json::().await.expect("json")["id"] .as_i64() .expect("series id"); assert_eq!( state.next_metadata_command().await.expect("command"), MetadataCommand::Series { series_id } ); let refreshed_at: Option = sqlx::query_scalar("SELECT metadata_refreshed_at FROM series WHERE id = ?") .bind(series_id) .fetch_one(state.database().expect("database").pool()) .await .expect("series row"); assert!( refreshed_at.is_none(), "an unrefreshed series stays due for the scheduled sweep" ); } /// Issue #177: the series response carries `metadata_refreshed_at` so /// the SPA can tell a never-refreshed series from a settled empty one. #[tokio::test] async fn series_response_exposes_metadata_refreshed_at() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; assert!(series["metadata_refreshed_at"].is_null()); } /// Issue #177: the retry control a gave-up poll offers resends the same /// on-demand command `create` sends, so a second refresh attempt does /// not need the scheduled sweep to come around. #[tokio::test] async fn refreshing_metadata_by_hand_queues_the_same_command() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("series id"); assert_eq!( state .next_metadata_command() .await .expect("create's command"), MetadataCommand::Series { series_id } ); let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/refresh-metadata")) .send() .await .expect("refresh metadata"); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( state .next_metadata_command() .await .expect("retry's command"), MetadataCommand::Series { series_id } ); } #[tokio::test] async fn refreshing_metadata_for_an_unknown_series_is_a_404() { let (_dir, _state, base) = application().await; let response = reqwest::Client::new() .post(format!("{base}/api/series/404/refresh-metadata")) .send() .await .expect("refresh metadata"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn series_must_sit_on_a_tv_root() { let (_dir, state, base) = application().await; let movie_root: i64 = sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'movie' LIMIT 1") .fetch_one(state.database().expect("database").pool()) .await .expect("movie root"); let response = reqwest::Client::new() .post(format!("{base}/api/series")) .json(&serde_json::json!({ "tmdb_id": 82_728, "title": "Bluey", "root_id": movie_root })) .send() .await .expect("create series"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } /// #160. A season added before the series' first metadata refresh is part /// of the back catalogue at add time, so the rule does not track it. Once /// a refresh has happened, a hand-revealed season is genuinely new and /// arrives tracked with its episodes wanted. #[tokio::test] async fn auto_track_decides_whether_a_new_season_arrives_wanted() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "kids").await; let tracked = add_series(&base, root_id, true).await; let tracked_id = tracked["id"].as_i64().expect("id"); // Seeding: no refresh has ever stamped this series. let season = add_season( &base, tracked_id, 1, serde_json::json!([ {"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"}, {"number": 2, "title": "Hospital", "air_date": "2018-10-02"} ]), ) .await; assert_eq!(season["tracked"], false); assert!( season["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == false), "§4.1: the back catalogue at add time is never auto-tracked" ); sqlx::query( "UPDATE series SET metadata_refreshed_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?", ) .bind(tracked_id) .execute(state.database().expect("database").pool()) .await .expect("stamp refreshed"); let season = add_season( &base, tracked_id, 2, serde_json::json!([ {"number": 1, "title": "Dance Mode", "air_date": "2019-04-01"} ]), ) .await; assert_eq!(season["tracked"], true); assert!( season["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == true), "§4.1: from the second refresh onward the rule tracks what is new" ); let untracked_id = sqlx::query_scalar::<_, i64>( "INSERT INTO series (tmdb_id, title, root_id, auto_track) VALUES (1668, 'Friends', ?, 0) RETURNING id", ) .bind(root_id) .fetch_one(state.database().expect("database").pool()) .await .expect("untracked series"); let season = add_season( &base, untracked_id, 2, serde_json::json!([{"number": 1, "title": "The One", "air_date": "1995-09-21"}]), ) .await; assert_eq!(season["tracked"], false); assert_eq!(season["episodes"][0]["wanted"], false); } /// #153. An empty episode title is rejected up front — the column and the /// TMDB boundary both refuse it, so the API must too. #[tokio::test] async fn an_empty_episode_title_is_rejected() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); for title in ["", " "] { let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/seasons")) .json(&serde_json::json!({"number": 1, "episodes": [ {"number": 1, "title": title} ]})) .send() .await .expect("create season"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } } #[tokio::test] async fn a_rejected_season_leaves_nothing_behind_to_retry_over() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/seasons")) .json(&serde_json::json!({"number": 1, "episodes": [ {"number": 1, "title": "One"}, {"number": 1, "title": "One again"} ]})) .send() .await .expect("create season"); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons") .fetch_one(state.database().expect("database").pool()) .await .expect("count seasons"); assert_eq!(seasons, 0, "the season number stays free for the retry"); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "One"}]), ) .await; assert_eq!(season["number"], 1); } #[tokio::test] async fn a_vanished_season_reaches_the_seasons_endpoint() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let clean = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "One"}]), ) .await; assert_eq!(clean["vanished"], false); add_season( &base, series_id, 2, serde_json::json!([{"number": 1, "title": "One"}]), ) .await; sqlx::query("UPDATE seasons SET vanished = 1 WHERE series_id = ? AND number = 2") .bind(series_id) .execute(state.database().expect("database").pool()) .await .expect("flag season vanished"); let seasons: serde_json::Value = reqwest::Client::new() .get(format!("{base}/api/series/{series_id}/seasons")) .send() .await .expect("list seasons") .json() .await .expect("seasons json"); let seasons = seasons.as_array().expect("seasons array"); let vanished = |number: i64| { seasons .iter() .find(|season| season["number"] == number) .expect("season")["vanished"] .clone() }; assert_eq!(vanished(1), false); assert_eq!( vanished(2), true, "#141: the conflict flag is not stopped at the database" ); } /// §9.6: seasons come back newest-first within a series and episodes /// newest-first within each season, with season 0 wherever descending /// numeric order puts it — last. The SPA renders this order as given, /// so it is the API's to get right (#162). #[tokio::test] async fn seasons_and_episodes_are_returned_newest_first() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); for (season, episodes) in [ ( 0, serde_json::json!([ {"number": 1, "title": "Special one"}, {"number": 2, "title": "Special two"} ]), ), (1, serde_json::json!([{"number": 1, "title": "Pilot"}])), ( 2, serde_json::json!([ {"number": 1, "title": "One"}, {"number": 2, "title": "Two"}, {"number": 3, "title": "Three"} ]), ), ] { add_season(&base, series_id, season, episodes).await; } let seasons: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}/seasons")) .await .expect("list seasons") .json() .await .expect("seasons json"); let seasons = seasons.as_array().expect("seasons array"); let numbers: Vec = seasons .iter() .map(|season| season["number"].as_i64().expect("number")) .collect(); assert_eq!(numbers, [2, 1, 0], "§9.6: newest-first within the series"); let episode_numbers = |index: usize| -> Vec { seasons[index]["episodes"] .as_array() .expect("episodes") .iter() .map(|episode| episode["number"].as_i64().expect("episode number")) .collect() }; assert_eq!( episode_numbers(0), [3, 2, 1], "§9.6: newest-first in a season" ); assert_eq!(episode_numbers(1), [1]); assert_eq!(episode_numbers(2), [2, 1]); } #[tokio::test] async fn intent_is_set_on_seasons_and_on_single_episodes() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 2, serde_json::json!([ {"number": 1, "title": "One", "air_date": "2020-01-01"}, {"number": 2, "title": "Two", "air_date": "2020-01-08"} ]), ) .await; assert_eq!(season["episodes"][0]["wanted"], false); // The whole season, in one click. let updated: serde_json::Value = reqwest::Client::new() .patch(format!("{base}/api/series/{series_id}/seasons/2")) .json(&serde_json::json!({"wanted": true})) .send() .await .expect("update season") .json() .await .expect("season json"); assert!(updated["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == true)); assert_eq!( updated["tracked"], false, "§4.1: wanting a season is not tracking the series" ); // And one episode on its own. let episode_id = updated["episodes"][1]["id"].as_i64().expect("episode id"); let episode: serde_json::Value = reqwest::Client::new() .patch(format!("{base}/api/episodes/{episode_id}")) .json(&serde_json::json!({"wanted": false})) .send() .await .expect("update episode") .json() .await .expect("episode json"); assert_eq!(episode["wanted"], false); } /// #171. The §4.1 rule runs whenever the flag is sent: off clears /// wanted — including on a season that was already untracked, which is /// how rows stranded by the old ruling get out — and on marks every /// revealed episode wanted. #[tokio::test] async fn untracking_a_season_clears_its_episodes_wanted() { async fn patch_season( base: &str, series_id: i64, body: serde_json::Value, ) -> serde_json::Value { reqwest::Client::new() .patch(format!("{base}/api/series/{series_id}/seasons/1")) .json(&body) .send() .await .expect("update season") .json() .await .expect("season json") } let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([ {"number": 1, "title": "One", "air_date": "2020-01-01"}, {"number": 2, "title": "Two", "air_date": "2020-01-08"} ]), ) .await; assert_eq!(season["tracked"], false); assert_eq!(season["episodes"][0]["wanted"], false); // A season stranded by the old ruling: untracked, yet its episodes // are all wanted. Re-sending the flag it already holds must still // do the work (#171). let updated = patch_season(&base, series_id, serde_json::json!({"wanted": true})).await; assert!(updated["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == true)); let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; assert_eq!(updated["tracked"], false); assert!( updated["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == false), "#171: clearing an already-untracked season is the way out" ); let updated = patch_season(&base, series_id, serde_json::json!({"tracked": true})).await; assert_eq!(updated["tracked"], true); assert!( updated["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == true), "§4.1: turning tracked on marks every revealed episode wanted" ); let updated = patch_season(&base, series_id, serde_json::json!({"tracked": false})).await; assert_eq!(updated["tracked"], false); assert!( updated["episodes"] .as_array() .expect("episodes") .iter() .all(|episode| episode["wanted"] == false), "§4.1: turning tracked off clears every revealed episode" ); } /// #171. Untracking a season with no episodes touches nothing and is not /// an error. #[tokio::test] async fn untracking_an_empty_season_is_a_no_op() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season(&base, series_id, 1, serde_json::json!([])).await; assert_eq!(season["tracked"], false); let response = reqwest::Client::new() .patch(format!("{base}/api/series/{series_id}/seasons/1")) .json(&serde_json::json!({"tracked": false})) .send() .await .expect("update season"); assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] async fn listed_series_carry_a_derived_status() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); assert_eq!( series["status"], "complete", "nothing wanted is nothing missing" ); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); reqwest::Client::new() .patch(format!("{base}/api/episodes/{episode_id}")) .json(&serde_json::json!({"wanted": true})) .send() .await .expect("want the episode"); let listed: Vec = reqwest::get(format!("{base}/api/series")) .await .expect("list") .json() .await .expect("list json"); assert_eq!(listed.len(), 1); assert_eq!( listed[0]["status"], "incomplete", "§4.2: an aired wanted episode with no file" ); assert_eq!(listed[0]["wanted_episodes"], 1); assert_eq!(listed[0]["available_episodes"], 0); sqlx::query("UPDATE episodes SET state = 'available' WHERE id = ?") .bind(episode_id) .execute(state.database().expect("database").pool()) .await .expect("import the episode"); let after: serde_json::Value = reqwest::get(format!("{base}/api/series/{series_id}")) .await .expect("get") .json() .await .expect("series json"); assert_eq!(after["status"], "complete"); } #[tokio::test] async fn manual_episode_actions_are_scoped_and_respect_blocked() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); let response = reqwest::Client::new() .post(format!("{base}/api/episodes/{episode_id}/search")) .send() .await .expect("search"); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( state.next_episode_command().await.expect("command"), EpisodeCommand::Search { episode_id } ); let pool = state.database().expect("database").pool(); let release_id = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, score, verdict) VALUES (7, 'guid', 'Bluey S01E01 1080p WEB-DL', 1000, 'url', '{}', 42, 'eligible') RETURNING id") .fetch_one(pool) .await .expect("release"); sqlx::query("INSERT INTO episode_releases (episode_id, release_id) VALUES (?, ?)") .bind(episode_id) .bind(release_id) .execute(pool) .await .expect("association"); let releases: Vec = reqwest::get(format!("{base}/api/episodes/{episode_id}/releases")) .await .expect("releases") .json() .await .expect("releases json"); assert_eq!(releases[0]["verdict"], "eligible"); let response = reqwest::Client::new() .post(format!( "{base}/api/episodes/{episode_id}/releases/{release_id}/grab" )) .send() .await .expect("grab"); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( state.next_episode_command().await.expect("command"), EpisodeCommand::Grab { episode_id, release_id } ); // A release belonging to another episode is not grabbable through // this one. let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id") .fetch_one(pool) .await .expect("other release"); let response = reqwest::Client::new() .post(format!( "{base}/api/episodes/{episode_id}/releases/{other}/grab" )) .send() .await .expect("grab"); assert_eq!(response.status(), StatusCode::NOT_FOUND); reqwest::Client::new() .patch(format!("{base}/api/series/{series_id}")) .json(&serde_json::json!({"blocked": true})) .send() .await .expect("block the series"); let response = reqwest::Client::new() .post(format!("{base}/api/episodes/{episode_id}/search")) .send() .await .expect("blocked search"); assert_eq!(response.status(), StatusCode::CONFLICT); } /// Issue #125: seasons get the same §9.3 surface as movies and episodes — /// a targeted search, a deck scoped to one season and rescored against /// the current policy, and a grab by release id. #[tokio::test] async fn manual_season_actions_are_scoped_and_rescore_the_deck() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), ) .await; let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/seasons/1/search")) .send() .await .expect("search"); assert_eq!(response.status(), StatusCode::ACCEPTED); let season_id = season["id"].as_i64().expect("season id"); assert_eq!( state.next_season_command().await.expect("command"), SeasonCommand::Search { season_id } ); // A stale stored score proves the deck is rescored at read time, // like the movie deck (#114). let pool = state.database().expect("database").pool(); let name = "Bluey S01 1080p WEB-DL x264-GROUP"; let parsed = arr_parse::parse(name); let release_id = sqlx::query_scalar::<_, i64>( "INSERT INTO releases (indexer_id, guid, name, size, seeders, download_url, parsed, score, verdict) VALUES (7, 'pack', ?, 1000, 50, 'url', ?, -12345, 'eligible') RETURNING id", ) .bind(name) .bind(serde_json::to_string(&parsed).expect("parsed json")) .fetch_one(pool) .await .expect("release"); sqlx::query("INSERT INTO season_releases (season_id, release_id) VALUES (?, ?)") .bind(season_id) .bind(release_id) .execute(pool) .await .expect("association"); let releases: Vec = reqwest::get(format!("{base}/api/series/{series_id}/seasons/1/releases")) .await .expect("releases") .json() .await .expect("releases json"); assert_eq!(releases.len(), 1); assert_eq!(releases[0]["verdict"], "eligible"); assert_ne!( releases[0]["score"].as_f64(), Some(-12345.0), "the deck reflects the current policy, not the stored score" ); let response = reqwest::Client::new() .post(format!( "{base}/api/series/{series_id}/seasons/1/releases/{release_id}/grab" )) .send() .await .expect("grab"); assert_eq!(response.status(), StatusCode::ACCEPTED); assert_eq!( state.next_season_command().await.expect("command"), SeasonCommand::Grab { season_id, release_id } ); // A release belonging to another season is not grabbable through // this one, and an unknown season number is not another season. let other = sqlx::query_scalar::<_, i64>("INSERT INTO releases (indexer_id, guid, name, size, download_url, parsed, verdict) VALUES (7, 'other', 'other', 1, 'url', '{}', 'eligible') RETURNING id") .fetch_one(pool) .await .expect("other release"); let response = reqwest::Client::new() .post(format!( "{base}/api/series/{series_id}/seasons/1/releases/{other}/grab" )) .send() .await .expect("grab"); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = reqwest::get(format!("{base}/api/series/{series_id}/seasons/2/releases")) .await .expect("unknown season"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] async fn a_blocked_series_refuses_a_season_search() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, false).await; let series_id = series["id"].as_i64().expect("id"); add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot"}]), ) .await; reqwest::Client::new() .patch(format!("{base}/api/series/{series_id}")) .json(&serde_json::json!({"blocked": true})) .send() .await .expect("block the series"); let response = reqwest::Client::new() .post(format!("{base}/api/series/{series_id}/seasons/1/search")) .send() .await .expect("blocked search"); assert_eq!(response.status(), StatusCode::CONFLICT); } #[tokio::test] async fn owner_tags_filter_the_series_list() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "kids").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let owner_id = sqlx::query_scalar::<_, i64>( "INSERT INTO owners (name, ntfy_topic) VALUES ('kid', 'arr-kid') RETURNING id", ) .fetch_one(state.database().expect("database").pool()) .await .expect("owner"); let empty: Vec = reqwest::get(format!("{base}/api/series?owner_id={owner_id}")) .await .expect("filtered list") .json() .await .expect("json"); assert!(empty.is_empty()); let tagged = reqwest::Client::new() .put(format!("{base}/api/series/{series_id}/owners/{owner_id}")) .send() .await .expect("tag"); assert_eq!(tagged.status(), StatusCode::NO_CONTENT); let filtered: Vec = reqwest::get(format!("{base}/api/series?owner_id={owner_id}")) .await .expect("filtered list") .json() .await .expect("json"); assert_eq!(filtered.len(), 1); assert_eq!(filtered[0]["id"], series_id); let owners: Vec = reqwest::get(format!("{base}/api/series/{series_id}/owners")) .await .expect("owners") .json() .await .expect("json"); assert_eq!(owners.len(), 1); reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}/owners/{owner_id}")) .send() .await .expect("untag"); let owners: Vec = reqwest::get(format!("{base}/api/series/{series_id}/owners")) .await .expect("owners") .json() .await .expect("json"); assert!(owners.is_empty()); } #[tokio::test] async fn deleting_a_series_takes_its_seasons_and_episodes() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), ) .await; let response = reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}")) .send() .await .expect("delete"); assert_eq!(response.status(), StatusCode::NO_CONTENT); let episodes: i64 = sqlx::query_scalar("SELECT count(*) FROM episodes") .fetch_one(state.database().expect("database").pool()) .await .expect("count episodes"); assert_eq!(episodes, 0); } /// A series on disk for one test: the §7.4 title folder with a season /// subfolder holding one episode file and one sidecar subtitle. async fn library_on_disk( state: &AppState, episode_id: i64, root: &std::path::Path, ) -> std::path::PathBuf { let folder = root.join("Bluey (2018) [tmdbid-82728]"); let season = folder.join("Season 01"); tokio::fs::create_dir_all(&season) .await .expect("create title folder"); let feature = season.join("Bluey (2018) - S01E01 - Pilot [1080p][WEB-DL].mkv"); tokio::fs::write(&feature, b"episode").await.expect("write"); tokio::fs::write(season.join("bluey.s01e01.pt.srt"), b"subs") .await .expect("write sidecar"); let pool = state.database().expect("database").pool(); let root_path = root.to_str().expect("utf-8 root"); sqlx::query("UPDATE roots SET path = ? WHERE kind = 'tv' AND audience = 'main'") .bind(root_path) .execute(pool) .await .expect("point the root at the tempdir"); sqlx::query( "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)", ) .bind(episode_id) .bind(feature.to_str().expect("utf-8 path")) .execute(pool) .await .expect("media file"); folder } /// The §7.4 title folder is the unit of deletion, so the season /// subfolder and sidecars go with it — and the root is never touched. #[tokio::test] async fn deleting_a_series_removes_the_whole_title_folder() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot", "air_date": "2001-01-01"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); let root = tempfile::tempdir().expect("root"); let folder = library_on_disk(&state, episode_id, root.path()).await; let response = reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}")) .send() .await .expect("delete"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!( !folder.exists(), "the title folder, its seasons and its sidecars are gone" ); assert!(root.path().exists(), "the root survives its titles"); let pool = state.database().expect("database").pool(); let orphans: i64 = sqlx::query_scalar("SELECT count(*) FROM media_files WHERE owner_kind = 'episode'") .fetch_one(pool) .await .expect("count files"); assert_eq!(orphans, 0, "the file rows go with the files"); } /// A missing series is 404 before anything touches the disk. #[tokio::test] async fn deleting_a_missing_series_is_a_404() { let (_dir, _state, base) = application().await; let response = reqwest::Client::new() .delete(format!("{base}/api/series/999")) .send() .await .expect("delete"); assert_eq!(response.status(), StatusCode::NOT_FOUND); } /// The detail view reads its episode files through the series, keyed by /// episode id, so one request carries every §7.4 attribute tag it shows. #[tokio::test] async fn series_files_are_keyed_by_episode() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "kids").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([ {"number": 1, "title": "The Magic Xylophone", "air_date": "2018-10-01"}, {"number": 2, "title": "Hospital", "air_date": "2018-10-02"} ]), ) .await; let episodes = season["episodes"].as_array().expect("episodes"); let first = episodes[0]["id"].as_i64().expect("episode id"); let pool = state.database().expect("database").pool(); sqlx::query( r"INSERT INTO media_files (owner_kind, owner_id, path, size, probed, waiver) VALUES ('episode', ?, ?, 7, ?, ?)", ) .bind(first) .bind("/mnt/media/tv/kids/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv") .bind(r#"{"resolution":"1080p","source":null,"hdr":"SDR","audio_tracks":[{"language":"pt-PT","title":null,"handler_name":null}],"sub_tracks":[]}"#) .bind(r#"{"rule":"required_audio"}"#) .execute(pool) .await .expect("media file"); let response = reqwest::get(format!("{base}/api/series/{series_id}/files")) .await .expect("fetch files"); assert_eq!(response.status(), StatusCode::OK); let files: serde_json::Value = response.json().await.expect("files json"); let rows = files.as_array().expect("array"); assert_eq!(rows.len(), 1, "only imported episodes carry a file"); assert_eq!(rows[0]["episode_id"], first); assert_eq!(rows[0]["probed"]["resolution"], "1080p"); assert_eq!(rows[0]["probed"]["audio_tracks"][0]["language"], "pt-PT"); assert_eq!(rows[0]["waiver"], "required_audio"); let missing = reqwest::get(format!("{base}/api/series/999/files")) .await .expect("fetch files"); assert_eq!(missing.status(), StatusCode::NOT_FOUND); } /// The guard that keeps a delete inside the library: a path that is not /// under the series' root is left alone, whatever the row says. #[tokio::test] async fn a_series_file_outside_its_root_is_never_unlinked() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "Pilot"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("episode id"); let root = tempfile::tempdir().expect("root"); sqlx::query("UPDATE roots SET path = ? WHERE id = ?") .bind(root.path().to_str().expect("utf-8 root")) .bind(root_id) .execute(state.database().expect("database").pool()) .await .expect("point the root at the tempdir"); let elsewhere = tempfile::tempdir().expect("elsewhere"); let stray = elsewhere.path().join("not-ours.mkv"); tokio::fs::write(&stray, b"stray").await.expect("write"); sqlx::query( "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)", ) .bind(episode_id) .bind(stray.to_str().expect("utf-8 path")) .execute(state.database().expect("database").pool()) .await .expect("media file"); let response = reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}")) .send() .await .expect("delete"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!( stray.exists(), "a path outside the root is not ours to delete" ); } /// Puts one episode file plus a sidecar into a §7.4 season folder and /// records the file. Returns the file's path. async fn episode_file_on_disk( state: &AppState, episode_id: i64, root: &std::path::Path, season: i64, name: &str, ) -> std::path::PathBuf { let folder = root .join("Bluey (2018) [tmdbid-82728]") .join(format!("Season {season:02}")); tokio::fs::create_dir_all(&folder) .await .expect("create season folder"); let file = folder.join(name); tokio::fs::write(&file, b"episode").await.expect("write"); sqlx::query( "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 7)", ) .bind(episode_id) .bind(file.to_str().expect("utf-8 path")) .execute(state.database().expect("database").pool()) .await .expect("media file"); file } async fn point_root_at(state: &AppState, root_id: i64, path: &std::path::Path) { sqlx::query("UPDATE roots SET path = ? WHERE id = ?") .bind(path.to_str().expect("utf-8 root")) .bind(root_id) .execute(state.database().expect("database").pool()) .await .expect("point the root at the tempdir"); } async fn wanted_of(state: &AppState, episode_id: i64) -> bool { sqlx::query_scalar::<_, bool>("SELECT wanted FROM episodes WHERE id = ?") .bind(episode_id) .fetch_one(state.database().expect("database").pool()) .await .expect("wanted") } /// #174: one call unlinks the season's files, drops their `media_files` /// rows and clears the intent behind them. The season and episode rows /// stay — TMDB owns that metadata — and the next season is untouched. #[tokio::test] async fn removing_a_season_takes_its_files_and_its_wanted() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let root = tempfile::tempdir().expect("root"); point_root_at(&state, root_id, root.path()).await; let first = add_season( &base, series_id, 1, serde_json::json!([ {"number": 1, "title": "The Magic Xylophone"}, {"number": 2, "title": "Hospital"} ]), ) .await; let second = add_season( &base, series_id, 2, serde_json::json!([{"number": 1, "title": "Dance Mode"}]), ) .await; let s01e01 = first["episodes"] .as_array() .expect("episodes") .iter() .find(|episode| episode["number"] == 1) .expect("s01e01")["id"] .as_i64() .expect("id"); let s01e02 = first["episodes"] .as_array() .expect("episodes") .iter() .find(|episode| episode["number"] == 2) .expect("s01e02")["id"] .as_i64() .expect("id"); let s02e01 = second["episodes"][0]["id"].as_i64().expect("id"); let one = episode_file_on_disk(&state, s01e01, root.path(), 1, "Bluey - S01E01.mkv").await; let two = episode_file_on_disk(&state, s01e02, root.path(), 1, "Bluey - S01E02.mkv").await; let other = episode_file_on_disk(&state, s02e01, root.path(), 2, "Bluey - S02E01.mkv").await; let pool = state.database().expect("database").pool(); for episode in [s01e01, s01e02, s02e01] { sqlx::query("UPDATE episodes SET wanted = 1, state = 'available' WHERE id = ?") .bind(episode) .execute(pool) .await .expect("seed wanted"); } let response = reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}/seasons/1/files")) .send() .await .expect("delete season files"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!(!one.exists(), "the season's files are gone from disk"); assert!(!two.exists(), "the season's files are gone from disk"); assert!(other.exists(), "another season's file is not in scope"); let rows: Vec = sqlx::query_scalar("SELECT path FROM media_files WHERE owner_kind = 'episode'") .fetch_all(pool) .await .expect("files"); assert_eq!( rows, vec![other.to_str().expect("utf-8").to_string()], "only the season's rows go" ); assert!(!wanted_of(&state, s01e01).await, "intent cleared"); assert!(!wanted_of(&state, s01e02).await, "across the whole season"); assert!(wanted_of(&state, s02e01).await, "and nowhere else"); let state_of: String = sqlx::query_scalar("SELECT state FROM episodes WHERE id = ?") .bind(s01e01) .fetch_one(pool) .await .expect("state"); assert_eq!( state_of, "missing", "an episode with no file is not available" ); let seasons: i64 = sqlx::query_scalar("SELECT count(*) FROM seasons WHERE series_id = ?") .bind(series_id) .fetch_one(pool) .await .expect("seasons"); let episodes: i64 = sqlx::query_scalar( "SELECT count(*) FROM episodes e JOIN seasons se ON se.id = e.season_id WHERE se.series_id = ?", ) .bind(series_id) .fetch_one(pool) .await .expect("episodes"); assert_eq!(seasons, 2, "TMDB owns the season rows, so they stay"); assert_eq!(episodes, 3, "and the episode rows with them"); } /// #174: the narrow scope really is narrow. Removing one episode leaves /// its sibling's file, row and intent exactly as they were, and leaves /// the season folder standing. #[tokio::test] async fn removing_one_episode_leaves_its_siblings_alone() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let root = tempfile::tempdir().expect("root"); point_root_at(&state, root_id, root.path()).await; let season = add_season( &base, series_id, 1, serde_json::json!([ {"number": 1, "title": "The Magic Xylophone"}, {"number": 2, "title": "Hospital"} ]), ) .await; let episodes = season["episodes"].as_array().expect("episodes"); let first = episodes .iter() .find(|episode| episode["number"] == 1) .expect("s01e01")["id"] .as_i64() .expect("id"); let second = episodes .iter() .find(|episode| episode["number"] == 2) .expect("s01e02")["id"] .as_i64() .expect("id"); let one = episode_file_on_disk(&state, first, root.path(), 1, "Bluey - S01E01.mkv").await; let two = episode_file_on_disk(&state, second, root.path(), 1, "Bluey - S01E02.mkv").await; let pool = state.database().expect("database").pool(); for episode in [first, second] { sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?") .bind(episode) .execute(pool) .await .expect("seed wanted"); } let response = reqwest::Client::new() .delete(format!("{base}/api/episodes/{first}/files")) .send() .await .expect("delete episode files"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!(!one.exists(), "the episode's file is gone"); assert!(two.exists(), "its sibling's file is not in scope"); assert!( two.parent().expect("season folder").exists(), "and neither is the season folder around them" ); assert!(!wanted_of(&state, first).await, "intent cleared for it"); assert!(wanted_of(&state, second).await, "and not for its sibling"); let remaining: Vec = sqlx::query_scalar("SELECT owner_id FROM media_files WHERE owner_kind = 'episode'") .fetch_all(pool) .await .expect("files"); assert_eq!(remaining, vec![second], "only the episode's row goes"); } /// #174: removal is not conditional on there being anything to remove. /// A scope with no files still clears intent, and still answers 204. #[tokio::test] async fn removing_a_scope_with_no_files_still_clears_intent() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let root = tempfile::tempdir().expect("root"); point_root_at(&state, root_id, root.path()).await; let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("id"); sqlx::query("UPDATE episodes SET wanted = 1 WHERE id = ?") .bind(episode_id) .execute(state.database().expect("database").pool()) .await .expect("seed wanted"); let response = reqwest::Client::new() .delete(format!("{base}/api/series/{series_id}/seasons/1/files")) .send() .await .expect("delete season files"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!(!wanted_of(&state, episode_id).await, "intent still cleared"); // And again on the episode, which now has neither file nor intent. let response = reqwest::Client::new() .delete(format!("{base}/api/episodes/{episode_id}/files")) .send() .await .expect("delete episode files"); assert_eq!(response.status(), StatusCode::NO_CONTENT); } /// #174: an unknown scope is 404, and an unknown series is 404 even for /// a season number that exists under some other series. #[tokio::test] async fn removing_files_from_an_unknown_scope_is_a_404() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]), ) .await; let client = reqwest::Client::new(); for path in [ format!("api/series/{series_id}/seasons/9/files"), "api/series/999/seasons/1/files".to_string(), "api/episodes/999/files".to_string(), ] { let response = client .delete(format!("{base}/{path}")) .send() .await .expect("delete"); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); } } /// #174 reuses the §7.4 containment guard: a recorded path that is not /// under the series' root is left on disk, whatever the row says. Its /// row still goes, so the library stops claiming the file. #[tokio::test] async fn an_episode_file_outside_its_root_is_never_unlinked() { let (_dir, state, base) = application().await; let root_id = tv_root(&state, "main").await; let series = add_series(&base, root_id, true).await; let series_id = series["id"].as_i64().expect("id"); let root = tempfile::tempdir().expect("root"); point_root_at(&state, root_id, root.path()).await; let season = add_season( &base, series_id, 1, serde_json::json!([{"number": 1, "title": "The Magic Xylophone"}]), ) .await; let episode_id = season["episodes"][0]["id"].as_i64().expect("id"); let elsewhere = tempfile::tempdir().expect("elsewhere"); let stray = elsewhere.path().join("not-ours.mkv"); tokio::fs::write(&stray, b"stray").await.expect("write"); sqlx::query( "INSERT INTO media_files (owner_kind, owner_id, path, size) VALUES ('episode', ?, ?, 5)", ) .bind(episode_id) .bind(stray.to_str().expect("utf-8 path")) .execute(state.database().expect("database").pool()) .await .expect("media file"); let response = reqwest::Client::new() .delete(format!("{base}/api/episodes/{episode_id}/files")) .send() .await .expect("delete episode files"); assert_eq!(response.status(), StatusCode::NO_CONTENT); assert!( stray.exists(), "a path outside the root is not ours to delete" ); } /// The sub-series guard keeps the whole relative path, so it can only /// name the recorded file — and one `..` anywhere is enough to refuse. #[test] fn a_contained_file_is_the_recorded_path_under_the_root() { let root = "/mnt/media/tv/main"; assert_eq!( contained_file( root, "/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv" ), Some(std::path::PathBuf::from( "/mnt/media/tv/main/Bluey (2018) [tmdbid-82728]/Season 01/Bluey S01E01.mkv" )) ); assert_eq!(contained_file(root, "/mnt/media/tv/kids/other.mkv"), None); assert_eq!(contained_file(root, root), None); assert_eq!( contained_file(root, "/mnt/media/tv/main/../kids/other.mkv"), None, "one `..` climbs back out of the root it was under" ); } #[test] fn air_dates_parse_as_dates_and_as_timestamps() { assert_eq!( air_date(Some("1970-01-02")), Some(UNIX_EPOCH + Duration::from_hours(24)) ); assert_eq!( air_date(Some("1970-01-02T00:00:00Z")), Some(UNIX_EPOCH + Duration::from_hours(24)) ); assert_eq!(air_date(Some("not a date")), None); assert_eq!(air_date(None), None); } }