Merge #120: series.tvdb_id and tvsearch by ID

Closes #120
This commit is contained in:
Miguel Palhas
2026-08-23 16:35:34 +01:00
20 changed files with 376 additions and 128 deletions
@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ",
"describe": {
"columns": [
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "original_language",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "0a3680a6cf353c210c1609bef5061f5026f61256c901564548d56f816dd4c68d"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", 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\" 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",
"query": "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\" 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",
"describe": {
"columns": [
{
@@ -14,43 +14,48 @@
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 9,
"ordinal": 10,
"type_info": "Integer"
}
],
@@ -60,6 +65,7 @@
"nullable": [
false,
false,
true,
false,
true,
true,
@@ -70,5 +76,5 @@
false
]
},
"hash": "f547ac991490ac5af5c16e707a2b52e83207a194e496dcaecfa253cdc0757463"
"hash": "45d5b3f5e133289014b2f6e10fc14070b4b4ec2f18d97f7546f3b866004c404b"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", 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\" FROM series WHERE id = ?",
"query": "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\" FROM series WHERE id = ?",
"describe": {
"columns": [
{
@@ -14,43 +14,48 @@
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 9,
"ordinal": 10,
"type_info": "Integer"
}
],
@@ -60,6 +65,7 @@
"nullable": [
false,
false,
true,
false,
true,
true,
@@ -70,5 +76,5 @@
false
]
},
"hash": "1a2db8b6fd48a7f3a32cd2ce33e4dbab9e46e99e004fd5a913e12ac6110071c2"
"hash": "71534a7c100984bfe082a1348474263fa67c980e532fc037cc77b82c1109ecaa"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT id AS \"id!: i64\", tmdb_id AS \"tmdb_id!: i64\", 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\" FROM series ORDER BY title, year, id",
"query": "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\" FROM series ORDER BY title, year, id",
"describe": {
"columns": [
{
@@ -14,43 +14,48 @@
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"ordinal": 5,
"type_info": "Text"
},
{
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "auto_track!: bool",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"name": "auto_track!: bool",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "overrides!: serde_json::Value",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "upstream_ended!: bool",
"ordinal": 8,
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "blocked!: bool",
"ordinal": 9,
"ordinal": 10,
"type_info": "Integer"
}
],
@@ -60,6 +65,7 @@
"nullable": [
false,
false,
true,
false,
true,
true,
@@ -70,5 +76,5 @@
false
]
},
"hash": "950ae08a8a33d4ce3b088ae8ebac734baefdc2e6da90ac144d2dc0466e8cbe26"
"hash": "7c76ab54e1c32a1e98cfc04ffb709382d09fc1eea9f1ddf3b613446a4c1bdb31"
}
@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "88f8bbd8844d26dd81973b5cfd4cb5df34d72f53cd68578a47773eff0a10e054"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.title AS \"series_title!: String\", s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
"query": "SELECT s.title AS \"series_title!: String\", s.tvdb_id AS series_tvdb_id, s.original_language,\n se.number AS \"season_number!: i64\", e.number AS \"episode_number!: i64\"\n FROM episodes e\n JOIN seasons se ON se.id = e.season_id\n JOIN series s ON s.id = se.series_id\n WHERE e.id = ?",
"describe": {
"columns": [
{
@@ -9,18 +9,23 @@
"type_info": "Text"
},
{
"name": "original_language",
"name": "series_tvdb_id",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "season_number!: i64",
"ordinal": 2,
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "episode_number!: i64",
"ordinal": 3,
"ordinal": 4,
"type_info": "Integer"
}
],
@@ -30,9 +35,10 @@
"nullable": [
false,
true,
true,
false,
false
]
},
"hash": "6a164386b22fa587458007a843d0bfe7030ef0665dd989c1018dd83e2ec330cd"
"hash": "c6c072e04e334786f6cbd514cdfd4f6b493df9a5f8e140138cce32395b9d937d"
}
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\",\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id",
"query": "SELECT s.id AS \"id!: i64\", s.tmdb_id AS \"tmdb_id!: i64\", s.tvdb_id,\n s.title AS \"title!: String\", s.year, s.root_id AS \"root_id!: i64\",\n r.path AS \"root_path!: String\",\n s.upstream_ended AS \"upstream_ended!: bool\",\n s.created_at AS \"created_at!: String\"\n FROM series s JOIN roots r ON r.id = s.root_id\n WHERE (?1 IS NULL OR s.id = ?1) AND (?2 IS NULL OR s.tmdb_id = ?2)\n ORDER BY s.title, s.year, s.id",
"describe": {
"columns": [
{
@@ -14,33 +14,38 @@
"type_info": "Integer"
},
{
"name": "title!: String",
"name": "tvdb_id",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "title!: String",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "year",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "root_id!: i64",
"ordinal": 4,
"type_info": "Integer"
},
{
"name": "root_path!: String",
"name": "root_id!: i64",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "root_path!: String",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "upstream_ended!: bool",
"ordinal": 6,
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "created_at!: String",
"ordinal": 7,
"ordinal": 8,
"type_info": "Text"
}
],
@@ -50,6 +55,7 @@
"nullable": [
false,
false,
true,
false,
true,
false,
@@ -58,5 +64,5 @@
false
]
},
"hash": "2934322298dad8dfe3583122fc56e10dd8785b11d41338c7936e47e94590ab37"
"hash": "c724a7b776e5fcafadce90cb7bd235220cda3d8894d7cb5c2a411e816eb2ce0e"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "e69149d2ede23a1fd668de5163aa85192473cbfc4e68656a11c58d66184fdcd0"
}
@@ -0,0 +1,44 @@
{
"db_name": "SQLite",
"query": "\n SELECT se.id AS \"season_id!: i64\",\n se.number AS \"season_number!: i64\",\n s.title AS \"series_title!: String\",\n s.tvdb_id AS series_tvdb_id,\n s.original_language\n FROM seasons se\n JOIN series s ON s.id = se.series_id\n WHERE s.blocked = 0\n AND EXISTS (\n SELECT 1 FROM episodes e\n WHERE e.season_id = se.id\n AND e.wanted = 1\n AND NOT EXISTS (\n SELECT 1 FROM media_files f\n WHERE f.owner_kind = 'episode' AND f.owner_id = e.id\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'episode' AND g.target_id = e.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n )\n AND NOT EXISTS (\n SELECT 1 FROM grabs g\n WHERE g.target_kind = 'season' AND g.target_id = se.id\n AND g.state IN ('sent', 'downloaded', 'imported')\n )\n ORDER BY se.id\n ",
"describe": {
"columns": [
{
"name": "season_id!: i64",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "season_number!: i64",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "series_title!: String",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "series_tvdb_id",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "original_language",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
true,
true
]
},
"hash": "e8258c109f1d5a3fab62f702fbc274701e8b508de480e03bab593e449ef8d9d4"
}
+113 -6
View File
@@ -331,16 +331,17 @@ async fn movie_releases(
/// Classified releases for one episode (`DESIGN.md` §6.1, §9.3).
///
/// The series carries no TVDB ID yet, so `tv_request` falls back to a text
/// search built from the title and the `SxxEyy` tag. Both widen the result
/// set rather than narrowing it, which the buckets already handle.
/// A series with a stored TVDB id is searched by it; `tv_request` falls back
/// to a text search built from the title and the `SxxEyy` tag when the id is
/// missing or the indexer does not take `tvdbid`. Both widen the result set
/// rather than narrowing it, which the buckets already handle.
async fn episode_releases(
state: &AppState,
episode_id: i64,
) -> Result<Vec<ClassifiedRelease>, ApiError> {
let database = state.database().ok_or(ApiError::Unavailable)?;
let episode = sqlx::query!(
r#"SELECT s.title AS "series_title!: String", s.original_language,
r#"SELECT s.title AS "series_title!: String", s.tvdb_id AS series_tvdb_id, s.original_language,
se.number AS "season_number!: i64", e.number AS "episode_number!: i64"
FROM episodes e
JOIN seasons se ON se.id = e.season_id
@@ -365,7 +366,7 @@ async fn episode_releases(
let original_language = title_language(original_language, &[]);
let target = TvTarget {
tvdb_id: None,
tvdb_id: episode.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
title: episode.series_title,
selector: TvSelector::Episode {
season: u32::try_from(episode.season_number).unwrap_or_default(),
@@ -456,7 +457,7 @@ fn prowlarr_client(state: &AppState) -> Result<ProwlarrClient, ApiError> {
ProwlarrClient::new(upstreams.prowlarr_url.clone(), api_key).map_err(|_| ApiError::Unavailable)
}
fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
pub(crate) fn tmdb_client(state: &AppState) -> Result<arr_meta::TmdbClient, ApiError> {
let upstreams = state.upstreams();
let key = upstreams
.tmdb_api_key
@@ -820,6 +821,112 @@ mod tests {
assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2);
}
#[tokio::test]
async fn episode_releases_search_by_tvdb_id_when_the_indexer_takes_one() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])),
)
.mount(&prowlarr)
.await;
Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps"))
.respond_with(ResponseTemplate::new(200).set_body_string("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,tvdbid,season,ep\"/></searching></caps>"))
.mount(&prowlarr).await;
Mock::given(method("GET"))
.and(path("/9/api"))
.and(query_param("t", "tvsearch"))
.and(query_param("tvdbid", "361391"))
.and(query_param("season", "1"))
.and(query_param("ep", "2"))
.respond_with(ResponseTemplate::new(200).set_body_string(r"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
.mount(&prowlarr).await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await;
let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}"))
.await
.expect("releases");
assert_eq!(response.status(), 200);
let releases: Vec<serde_json::Value> = response.json().await.expect("json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["guid"], "ep");
}
/// §6.1: an indexer whose caps do not advertise `tvdbid` still gets
/// searched, by text — a missing or unsupported id widens the query,
/// it never skips the indexer.
#[tokio::test]
async fn an_indexer_without_id_search_still_gets_a_text_query() {
let tmdb = MockServer::start().await;
let prowlarr = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/indexer"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!([{"id":9,"name":"tracker","enable":true}])),
)
.mount(&prowlarr)
.await;
Mock::given(method("GET")).and(path("/9/api")).and(query_param("t", "caps"))
.respond_with(ResponseTemplate::new(200).set_body_string("<caps><searching><search available=\"yes\" supportedParams=\"q\"/><tv-search available=\"yes\" supportedParams=\"q,season,ep\"/></searching></caps>"))
.mount(&prowlarr).await;
Mock::given(method("GET"))
.and(path("/9/api"))
.and(query_param("t", "search"))
.and(query_param("q", "Bluey S01E02"))
.respond_with(ResponseTemplate::new(200).set_body_string(r"<rss><channel><item><title>Bluey.S01E02.1080p.WEB-DL</title><guid>ep</guid><link>https://tracker/ep</link><size>1500000000</size></item></channel></rss>"))
.mount(&prowlarr).await;
let (_dir, state, base) = application(&tmdb, &prowlarr).await;
let episode_id = episode_with_tvdb_id(&state, Some(361_391)).await;
let response = reqwest::get(format!("{base}/api/releases?episode_id={episode_id}"))
.await
.expect("releases");
assert_eq!(response.status(), 200);
let releases: Vec<serde_json::Value> = response.json().await.expect("json");
assert_eq!(releases.len(), 1);
assert_eq!(releases[0]["parsed"]["episode"]["episodes"][0], 2);
}
async fn episode_with_tvdb_id(state: &crate::state::AppState, tvdb_id: Option<i64>) -> i64 {
use sqlx::Row as _;
let pool = state.database().expect("database").pool();
let root_id: i64 =
sqlx::query_scalar("SELECT id FROM roots WHERE kind = 'tv' AND audience = 'main'")
.fetch_one(pool)
.await
.expect("TV root");
let series_id: i64 = sqlx::query(
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id) VALUES (82728, ?, 'Bluey', 2018, 'en', ?)",
)
.bind(tvdb_id)
.bind(root_id)
.execute(pool)
.await
.expect("series")
.last_insert_rowid();
let season_id: i64 =
sqlx::query("INSERT INTO seasons (series_id, number) VALUES (?, 1) RETURNING id")
.bind(series_id)
.fetch_one(pool)
.await
.expect("season")
.get(0);
sqlx::query_scalar(
"INSERT INTO episodes (season_id, number, title, wanted) VALUES (?, 2, 'Hospital', 1) RETURNING id",
)
.bind(season_id)
.fetch_one(pool)
.await
.expect("episode")
}
#[tokio::test]
async fn a_manual_search_names_exactly_one_title() {
let tmdb = MockServer::start().await;
+25 -5
View File
@@ -31,6 +31,7 @@ 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};
/// A series with the status derived from its episodes (§4.2).
@@ -38,6 +39,9 @@ use crate::state::{AppState, EpisodeCommand};
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<i64>,
pub title: String,
pub year: Option<i64>,
pub original_language: Option<String>,
@@ -171,6 +175,7 @@ fn validate_overrides(value: &serde_json::Value) -> Result<(), ApiError> {
struct SeriesRow {
id: i64,
tmdb_id: i64,
tvdb_id: Option<i64>,
title: String,
year: Option<i64>,
original_language: Option<String>,
@@ -287,6 +292,7 @@ fn with_status(
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(),
@@ -353,7 +359,7 @@ async fn tv_by_series(
}
async fn load_series_row(state: &AppState, id: i64) -> Result<SeriesRow, ApiError> {
sqlx::query_as!(SeriesRow, 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", auto_track AS "auto_track!: bool", overrides AS "overrides!: serde_json::Value", upstream_ended AS "upstream_ended!: bool", blocked AS "blocked!: bool" FROM series WHERE id = ?"#, id)
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" FROM series WHERE id = ?"#, id)
.fetch_optional(pool(state)?)
.await?
.ok_or(ApiError::SeriesNotFound)
@@ -417,11 +423,11 @@ pub async fn list(
Query(query): Query<ListSeriesQuery>,
) -> Result<Json<Vec<Series>>, 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.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" 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)
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" 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", 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" FROM series ORDER BY title, year, id"#)
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" FROM series ORDER BY title, year, id"#)
.fetch_all(pool(&state)?)
.await?
};
@@ -466,9 +472,13 @@ pub async fn create(
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).
let tvdb_id = lookup_tvdb_id(&state, input.tmdb_id).await;
let result = sqlx::query!(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, title, input.year, input.original_language, input.root_id,
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended, blocked, overrides) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
input.tmdb_id, tvdb_id, title, input.year, input.original_language, input.root_id,
input.auto_track, input.upstream_ended, input.blocked, overrides
)
.execute(pool(&state)?)
@@ -479,6 +489,16 @@ pub async fn create(
))
}
/// Best effort: `None` when TMDB has no id or cannot be reached.
async fn lookup_tvdb_id(state: &AppState, tmdb_id: i64) -> Option<i64> {
let client = tmdb_client(state).ok()?;
let ids = client
.series_external_ids(u32::try_from(tmdb_id).ok()?)
.await
.ok()?;
ids.tvdb_id.map(i64::from)
}
#[utoipa::path(
get, path = "/api/series/{series_id}", tag = "series",
params(("series_id" = i64, Path, description = "Series row id")),
+1 -1
View File
@@ -409,7 +409,7 @@ impl SeriesResource {
title: row.title.clone(),
sort_title: row.title.to_lowercase(),
year: row.year.unwrap_or_default(),
tvdb_id: 0,
tvdb_id: row.tvdb_id.unwrap_or(0),
tmdb_id: row.tmdb_id,
title_slug: title_slug(&row.title, row.tmdb_id),
path: format!("{}/{folder}", row.root_path),
+6 -3
View File
@@ -15,6 +15,7 @@ use crate::CompatState;
pub(crate) struct SeriesRow {
pub(crate) id: i64,
pub(crate) tmdb_id: i64,
pub(crate) tvdb_id: Option<i64>,
pub(crate) title: String,
pub(crate) year: Option<i64>,
pub(crate) root_id: i64,
@@ -98,10 +99,11 @@ pub(crate) async fn add(
let mut tx = state.pool().begin().await?;
let series_id = sqlx::query(
"INSERT INTO series (tmdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \
VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id",
"INSERT INTO series (tmdb_id, tvdb_id, title, year, original_language, root_id, auto_track, upstream_ended) \
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id",
)
.bind(input.tmdb_id)
.bind(metadata.tvdb_id.map(i64::from))
.bind(input.title.trim())
.bind(input.year.or_else(|| metadata.year().map(i64::from)))
.bind(&metadata.original_language)
@@ -227,7 +229,7 @@ async fn load_series(
tmdb_id: Option<i64>,
) -> Result<Vec<SeriesRow>, CompatError> {
let rows = sqlx::query!(
r#"SELECT s.id AS "id!: i64", s.tmdb_id AS "tmdb_id!: i64",
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.root_id AS "root_id!: i64",
r.path AS "root_path!: String",
s.upstream_ended AS "upstream_ended!: bool",
@@ -251,6 +253,7 @@ async fn load_series(
.map(|row| SeriesRow {
id: row.id,
tmdb_id: row.tmdb_id,
tvdb_id: row.tvdb_id,
title: row.title,
year: row.year,
root_id: row.root_id,
+5 -3
View File
@@ -370,9 +370,7 @@ impl TvGrabAction {
blacklist: &Blacklist,
) -> Result<Vec<TvCandidate>, GrabError> {
let target = TvTarget {
// The series table carries no TVDB id yet; `tv_request` falls
// back to a text search built from the title and the tag.
tvdb_id: None,
tvdb_id: season.series_tvdb_id.and_then(|id| u64::try_from(id).ok()),
title: season.series_title.clone(),
selector,
};
@@ -472,6 +470,8 @@ struct PendingSeason {
season_id: i64,
season_number: i64,
series_title: String,
/// §6.1: `t=tvsearch` is addressed by this when present.
series_tvdb_id: Option<i64>,
original_language: Option<String>,
}
@@ -509,6 +509,7 @@ async fn pending_seasons(database: &Db) -> Result<Vec<PendingSeason>, GrabError>
SELECT se.id AS "season_id!: i64",
se.number AS "season_number!: i64",
s.title AS "series_title!: String",
s.tvdb_id AS series_tvdb_id,
s.original_language
FROM seasons se
JOIN series s ON s.id = se.series_id
@@ -544,6 +545,7 @@ async fn pending_seasons(database: &Db) -> Result<Vec<PendingSeason>, GrabError>
season_id: row.season_id,
season_number: row.season_number,
series_title: row.series_title,
series_tvdb_id: row.series_tvdb_id,
original_language: row.original_language,
})
.collect())
@@ -0,0 +1,6 @@
-- Torznab `t=tvsearch` is addressed by TVDB id (§6.1); the title text query
-- is only the fallback. Nullable, because TMDB does not know one for every
-- series and #121 backfills it on refresh; unique when present so two rows
-- cannot claim the same show.
ALTER TABLE series ADD COLUMN tvdb_id INTEGER;
CREATE UNIQUE INDEX series_tvdb_id_unique ON series (tvdb_id) WHERE tvdb_id IS NOT NULL;
+16 -2
View File
@@ -9,8 +9,8 @@ use serde::de::DeserializeOwned;
use crate::cache::Cache;
use crate::error::{Error, Result};
use crate::model::{
Movie, MovieSearchResult, RawFindPage, RawMovie, RawSearchPage, RawSeason, RawSeries,
RawSeriesSearchPage, Season, Series, SeriesSearchResult,
ExternalIds, Movie, MovieSearchResult, RawExternalIds, RawFindPage, RawMovie, RawSearchPage,
RawSeason, RawSeries, RawSeriesSearchPage, Season, Series, SeriesSearchResult,
};
/// TMDB's v3 API root.
@@ -157,6 +157,20 @@ impl TmdbClient {
Ok(raw.into())
}
/// External ids for one TV series, of which the TVDB id is the one this
/// project needs (§6.1).
///
/// # Errors
///
/// [`Error::NotFound`] when TMDB has no such id, otherwise any of [`Error`].
pub async fn series_external_ids(&self, tmdb_id: u32) -> Result<ExternalIds> {
let path = format!("tv/{tmdb_id}/external_ids");
let raw: RawExternalIds = self.get_json(&path, &[]).await?;
Ok(ExternalIds {
tvdb_id: raw.tvdb_id,
})
}
/// Episodes in one TV season.
///
/// # Errors
+3 -1
View File
@@ -23,4 +23,6 @@ mod model;
pub use client::{TmdbClient, TmdbClientBuilder, DEFAULT_BASE_URL, DEFAULT_CACHE_TTL};
pub use error::{Error, Result};
pub use model::{Episode, Movie, MovieSearchResult, Season, Series, SeriesSearchResult};
pub use model::{
Episode, ExternalIds, Movie, MovieSearchResult, Season, Series, SeriesSearchResult,
};
+9 -2
View File
@@ -218,9 +218,16 @@ pub(crate) struct RawSeries {
external_ids: Option<RawExternalIds>,
}
/// The slice of `/tv/{id}/external_ids` this project reads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalIds {
/// The Torznab `tvdbid` parameter (§6.1), when TMDB knows one.
pub tvdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct RawExternalIds {
tvdb_id: Option<u32>,
pub(crate) struct RawExternalIds {
pub(crate) tvdb_id: Option<u32>,
}
#[derive(Debug, Deserialize)]
+11
View File
@@ -0,0 +1,11 @@
{
"id": 82728,
"imdb_id": "tt7614372",
"freebase_id": "/m/0vpgz0b",
"freebase_mid": "/m/0vpgz0b",
"tvdb_id": 361391,
"tvrage_id": null,
"facebook_id": "OfficialBluey",
"instagram_id": "officialblueytv",
"twitter_id": "officialbluey"
}
+40
View File
@@ -18,6 +18,7 @@ const MOVIE_CIDADE_DE_DEUS: &str = include_str!("fixtures/movie_cidade_de_deus.j
const MOVIE_UNRELEASED: &str = include_str!("fixtures/movie_unreleased.json");
const MOVIE_THEATRICAL_ONLY: &str = include_str!("fixtures/movie_theatrical_only.json");
const MOVIE_FUTURE_DIGITAL: &str = include_str!("fixtures/movie_future_digital.json");
const SERIES_EXTERNAL_IDS: &str = include_str!("fixtures/series_external_ids.json");
fn client(server: &MockServer) -> TmdbClient {
TmdbClient::builder("test-key")
@@ -461,3 +462,42 @@ async fn debug_output_does_not_leak_the_api_key() {
assert!(!rendered.contains("test-key"), "{rendered}");
assert!(rendered.contains("redacted"), "{rendered}");
}
/// §6.1: `t=tvsearch` is addressed by TVDB id, and `/tv/{id}/external_ids` is
/// where TMDB keeps the mapping.
#[tokio::test]
async fn series_external_ids_carries_the_tvdb_id() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/82728/external_ids"))
.respond_with(ResponseTemplate::new(200).set_body_string(SERIES_EXTERNAL_IDS))
.expect(1)
.mount(&server)
.await;
let ids = client(&server)
.series_external_ids(82_728)
.await
.expect("lookup succeeds");
assert_eq!(ids.tvdb_id, Some(361_391));
}
/// A series TMDB has no TVDB id for stays null rather than zero, so callers
/// can tell "unknown" from a real id and fall back to the text query.
#[tokio::test]
async fn series_without_a_tvdb_id_maps_to_none() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/3/tv/1/external_ids"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id": 1, "tvdb_id": null}"#))
.mount(&server)
.await;
let ids = client(&server)
.series_external_ids(1)
.await
.expect("lookup succeeds");
assert_eq!(ids.tvdb_id, None);
}