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::search_season))
|
||||||
.routes(routes!(series::season_releases))
|
.routes(routes!(series::season_releases))
|
||||||
.routes(routes!(series::grab_season_release))
|
.routes(routes!(series::grab_season_release))
|
||||||
|
.routes(routes!(series::files))
|
||||||
.routes(routes!(series::list_owners))
|
.routes(routes!(series::list_owners))
|
||||||
.routes(routes!(series::tag_owner, series::untag_owner))
|
.routes(routes!(series::tag_owner, series::untag_owner))
|
||||||
.routes(routes!(owners::list, owners::create))
|
.routes(routes!(owners::list, owners::create))
|
||||||
|
|||||||
@@ -1140,6 +1140,39 @@ pub async fn grab_season_release(
|
|||||||
Ok((StatusCode::ACCEPTED, Json(Accepted { accepted: true })))
|
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(
|
#[utoipa::path(
|
||||||
get, path = "/api/series/{series_id}/owners", tag = "series",
|
get, path = "/api/series/{series_id}/owners", tag = "series",
|
||||||
params(("series_id" = i64, Path, description = "Series row id")),
|
params(("series_id" = i64, Path, description = "Series row id")),
|
||||||
@@ -1956,6 +1989,57 @@ mod tests {
|
|||||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
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
|
/// 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.
|
/// under the series' root is left alone, whatever the row says.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
Reference in New Issue
Block a user