feat(api): series files keyed by episode id
This commit is contained in:
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "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",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "episode_id!: i64",
|
||||
"ordinal": 0,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "path!: String",
|
||||
"ordinal": 1,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "size!: i64",
|
||||
"ordinal": 2,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "probed?: serde_json::Value",
|
||||
"ordinal": 3,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "waiver?: String",
|
||||
"ordinal": 4,
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "17c53bd3b8dee72d62a23d41b7bcd6329037fe678ed628ea3ee92d14c7692540"
|
||||
}
|
||||
@@ -92,6 +92,7 @@ fn api_router() -> OpenApiRouter<AppState> {
|
||||
.routes(routes!(series::search_season))
|
||||
.routes(routes!(series::season_releases))
|
||||
.routes(routes!(series::grab_season_release))
|
||||
.routes(routes!(series::files))
|
||||
.routes(routes!(series::list_owners))
|
||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||
.routes(routes!(owners::list, owners::create))
|
||||
|
||||
@@ -1140,6 +1140,39 @@ pub async fn grab_season_release(
|
||||
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<serde_json::Value>,
|
||||
/// The §5.7 rule relaxed to allow this import, when one was.
|
||||
pub waiver: Option<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<EpisodeFile>>, 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")),
|
||||
@@ -1956,6 +1989,57 @@ mod tests {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user