feat(api): owner tags and filtered movie views (#70)
ci / web (push) Successful in 52s
ci / rust (push) Successful in 1m21s
e2e / e2e (push) Successful in 1m41s

This commit was merged in pull request #70.
This commit is contained in:
2026-08-22 21:43:03 +01:00
parent 8444b6ebe5
commit 0b7565189e
13 changed files with 744 additions and 7 deletions
+113 -7
View File
@@ -1,10 +1,11 @@
use axum::extract::{Path, State};
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use utoipa::{IntoParams, ToSchema};
use crate::owners::Owner;
use crate::state::{AppState, MovieCommand};
#[derive(Debug, Clone, Serialize, ToSchema)]
@@ -84,6 +85,7 @@ pub struct ErrorBody {
#[derive(Debug)]
pub enum ApiError {
NotFound,
OwnerNotFound,
Conflict(String),
Invalid(String),
Unavailable,
@@ -94,6 +96,7 @@ impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, error) = match self {
Self::NotFound => (StatusCode::NOT_FOUND, "movie not found".to_string()),
Self::OwnerNotFound => (StatusCode::NOT_FOUND, "owner not found".to_string()),
Self::Conflict(error) => (StatusCode::CONFLICT, error),
Self::Invalid(error) => (StatusCode::UNPROCESSABLE_ENTITY, error),
Self::Unavailable => (
@@ -150,7 +153,7 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
Ok(())
}
fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
pub(crate) fn pool(state: &AppState) -> Result<&sqlx::SqlitePool, ApiError> {
state
.database()
.map(arr_db::Db::pool)
@@ -163,18 +166,34 @@ async fn load_movie(state: &AppState, id: i64) -> Result<Movie, ApiError> {
.await?)
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct ListMoviesQuery {
/// Restrict to movies tagged with this owner (DESIGN.md §4.3).
pub owner_id: Option<i64>,
}
#[utoipa::path(
get, path = "/api/movies", tag = "movies",
params(ListMoviesQuery),
responses(
(status = 200, body = [Movie]),
(status = 500, body = ErrorBody),
(status = 503, body = ErrorBody)
)
)]
pub async fn list(State(state): State<AppState>) -> Result<Json<Vec<Movie>>, ApiError> {
let movies = sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?;
pub async fn list(
State(state): State<AppState>,
Query(query): Query<ListMoviesQuery>,
) -> Result<Json<Vec<Movie>>, ApiError> {
let movies = if let Some(owner_id) = query.owner_id {
sqlx::query_as!(Movie, r#"SELECT m.id AS "id!: i64", m.tmdb_id AS "tmdb_id!: i64", m.title AS "title!: String", m.year, m.original_language, m.root_id AS "root_id!: i64", m.wanted AS "wanted!: bool", m.overrides AS "overrides!: serde_json::Value", m.state AS "state!: String", m.blocked AS "blocked!: bool", m.search_attempts AS "search_attempts!: i64", m.last_searched_at FROM movies m JOIN title_owners t ON t.title_kind = 'movie' AND t.title_id = m.id WHERE t.owner_id = ? ORDER BY m.title, m.year, m.id"#, owner_id)
.fetch_all(pool(&state)?)
.await?
} else {
sqlx::query_as!(Movie, r#"SELECT id AS "id!: i64", tmdb_id AS "tmdb_id!: i64", title AS "title!: String", year, original_language, root_id AS "root_id!: i64", wanted AS "wanted!: bool", overrides AS "overrides!: serde_json::Value", state AS "state!: String", blocked AS "blocked!: bool", search_attempts AS "search_attempts!: i64", last_searched_at FROM movies ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
Ok(Json(movies))
}
@@ -407,6 +426,93 @@ pub async fn attention(State(state): State<AppState>) -> Result<Json<AttentionQu
}))
}
#[utoipa::path(
get, path = "/api/movies/{movie_id}/owners", tag = "movies",
params(("movie_id" = i64, Path, description = "Movie 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<AppState>,
Path(id): Path<i64>,
) -> Result<Json<Vec<Owner>>, ApiError> {
load_movie(&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 = 'movie' AND t.title_id = ?
ORDER BY o.name"#,
id
)
.fetch_all(pool(&state)?)
.await?;
Ok(Json(owners))
}
#[utoipa::path(
put, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies",
params(("movie_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<AppState>,
Path((movie_id, owner_id)): Path<(i64, i64)>,
) -> Result<StatusCode, ApiError> {
load_movie(&state, movie_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 ('movie', ?, ?) ON CONFLICT DO NOTHING",
movie_id, owner_id
)
.execute(pool(&state)?)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[utoipa::path(
delete, path = "/api/movies/{movie_id}/owners/{owner_id}", tag = "movies",
params(("movie_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<AppState>,
Path((movie_id, owner_id)): Path<(i64, i64)>,
) -> Result<StatusCode, ApiError> {
load_movie(&state, movie_id).await?;
sqlx::query!(
"DELETE FROM title_owners WHERE title_kind = 'movie' AND title_id = ? AND owner_id = ?",
movie_id,
owner_id
)
.execute(pool(&state)?)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::*;