can't be bothered writing a commit message
This commit is contained in:
commit
697b009627
55 changed files with 21320 additions and 0 deletions
200
breadarrd/src/metadata/anime_map.rs
Normal file
200
breadarrd/src/metadata/anime_map.rs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Fribb/anime-lists cross-references AniDB anime entries (roughly one per
|
||||
/// TV season/cour) to TVDB/TMDB ids and season numbers — this is what
|
||||
/// Sonarr itself relies on for anime scene-numbering, and unlike
|
||||
/// manami-project/anime-offline-database it actually carries TVDB/TMDB ids
|
||||
/// (verified: the offline-database has none at all).
|
||||
const SOURCE_URL: &str =
|
||||
"https://raw.githubusercontent.com/Fribb/anime-lists/master/anime-list-full.json";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Entry {
|
||||
#[serde(default)]
|
||||
anidb_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
tvdb_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
themoviedb_id: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
season: Option<SeasonField>,
|
||||
#[serde(default)]
|
||||
episode_offset: Option<OffsetField>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SeasonField {
|
||||
tvdb: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct OffsetField {
|
||||
tvdb: Option<i64>,
|
||||
}
|
||||
|
||||
/// Downloads the current Fribb/anime-lists dataset and upserts it into
|
||||
/// `anime_mapping` (TV) and `anime_tmdb_movie` (movies). Returns the number
|
||||
/// of `anime_mapping` entries processed (movie ids aren't counted the same
|
||||
/// way — one entry can contribute several).
|
||||
pub async fn refresh(conn: &mut Connection, client: &reqwest::Client) -> Result<usize> {
|
||||
let bytes = client
|
||||
.get(SOURCE_URL)
|
||||
.send()
|
||||
.await
|
||||
.context("anime-lists download failed")?
|
||||
.error_for_status()
|
||||
.context("anime-lists download returned an error status")?
|
||||
.bytes()
|
||||
.await
|
||||
.context("anime-lists download body read failed")?;
|
||||
|
||||
let entries: Vec<Entry> =
|
||||
serde_json::from_slice(&bytes).context("anime-lists response was not valid JSON")?;
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
let mut written = 0;
|
||||
for e in &entries {
|
||||
// `themoviedb_id` carries either a `tv` id (a single number) or a
|
||||
// `movie` id list (rereleases/split cuts can give one AniDB entry
|
||||
// several TMDB movie ids) — never both in practice, but nothing
|
||||
// guarantees that, so both are checked independently rather than
|
||||
// assuming one implies the absence of the other.
|
||||
let tv_tmdb_id = e
|
||||
.themoviedb_id
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("tv"))
|
||||
.and_then(|v| v.as_i64());
|
||||
for movie_id in e
|
||||
.themoviedb_id
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("movie"))
|
||||
.and_then(|v| v.as_array())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_i64())
|
||||
{
|
||||
tx.execute(
|
||||
"INSERT OR IGNORE INTO anime_tmdb_movie (tmdb_id) VALUES (?1)",
|
||||
params![movie_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
// ~63% of entries have no AniDB cross-reference at all (AniList/MAL/Kitsu-only
|
||||
// listings) — irrelevant to a table keyed on anidb_id, skip them.
|
||||
let Some(anidb_id) = e.anidb_id else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let season_number = e.season.as_ref().and_then(|s| s.tvdb);
|
||||
let episode_offset = e.episode_offset.as_ref().and_then(|o| o.tvdb).unwrap_or(0);
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO anime_mapping (anidb_id, tvdb_id, tmdb_id, season_offset, episode_offset)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(anidb_id) DO UPDATE SET
|
||||
tvdb_id = excluded.tvdb_id,
|
||||
tmdb_id = excluded.tmdb_id,
|
||||
season_offset = excluded.season_offset,
|
||||
episode_offset = excluded.episode_offset",
|
||||
params![
|
||||
anidb_id,
|
||||
e.tvdb_id,
|
||||
tv_tmdb_id,
|
||||
season_number,
|
||||
episode_offset
|
||||
],
|
||||
)?;
|
||||
written += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
Ok(written)
|
||||
}
|
||||
|
||||
/// Resolves an absolute anime episode number to a (season, episode) pair
|
||||
/// for a known TVDB series.
|
||||
///
|
||||
/// One TVDB season is often stitched together from several AniDB cours,
|
||||
/// each its own `anime_mapping` row with its own `episode_offset` — e.g.
|
||||
/// tvdb_id 366263 (Ascendance of a Bookworm) has cours starting at absolute
|
||||
/// episodes 1, 15, 27, 37 (offsets 0, 14, 26, 36). The matching cour is the
|
||||
/// one with the largest offset that's still below the target absolute
|
||||
/// episode (offset = episode count preceding that cour, so
|
||||
/// `local_episode = absolute - offset`).
|
||||
pub fn resolve_absolute_episode(
|
||||
conn: &Connection,
|
||||
tvdb_id: i64,
|
||||
absolute_episode: u32,
|
||||
) -> Result<Option<(u32, u32)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT season_offset, episode_offset FROM anime_mapping
|
||||
WHERE tvdb_id = ?1 AND season_offset IS NOT NULL",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map(params![tvdb_id], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
|
||||
let absolute = absolute_episode as i64;
|
||||
let best = rows
|
||||
.into_iter()
|
||||
.filter(|&(_, offset)| offset < absolute)
|
||||
.max_by_key(|&(_, offset)| offset);
|
||||
|
||||
Ok(best.map(|(season, offset)| (season as u32, (absolute - offset) as u32)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn seeded_conn() -> Connection {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE anime_mapping (
|
||||
anidb_id INTEGER PRIMARY KEY, tvdb_id INTEGER, tmdb_id INTEGER,
|
||||
season_offset INTEGER, episode_offset INTEGER NOT NULL DEFAULT 0
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
// Mirrors real data observed for tvdb_id 366263: one TVDB season
|
||||
// stitched from four AniDB cours starting at absolute eps 1/15/27/37.
|
||||
for (anidb_id, offset) in [(1, 0), (2, 14), (3, 26), (4, 36)] {
|
||||
conn.execute(
|
||||
"INSERT INTO anime_mapping (anidb_id, tvdb_id, season_offset, episode_offset)
|
||||
VALUES (?1, 366263, 1, ?2)",
|
||||
params![anidb_id, offset],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_first_cour() {
|
||||
let conn = seeded_conn();
|
||||
assert_eq!(
|
||||
resolve_absolute_episode(&conn, 366263, 5).unwrap(),
|
||||
Some((1, 5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_later_cour_using_its_offset() {
|
||||
let conn = seeded_conn();
|
||||
// absolute ep 20 falls in the cour starting at 15 (offset 14)
|
||||
assert_eq!(
|
||||
resolve_absolute_episode(&conn, 366263, 20).unwrap(),
|
||||
Some((1, 6))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_for_unmapped_series() {
|
||||
let conn = seeded_conn();
|
||||
assert_eq!(resolve_absolute_episode(&conn, 999999, 1).unwrap(), None);
|
||||
}
|
||||
}
|
||||
148
breadarrd/src/metadata/mod.rs
Normal file
148
breadarrd/src/metadata/mod.rs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
pub mod anime_map;
|
||||
pub mod tmdb;
|
||||
pub mod tvdb;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use anyhow::Result;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SeriesSearchResult {
|
||||
pub external_id: String,
|
||||
pub name: String,
|
||||
pub year: Option<u32>,
|
||||
pub aliases: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MovieSearchResult {
|
||||
pub external_id: String,
|
||||
pub title: String,
|
||||
pub year: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct EpisodeInfo {
|
||||
pub season_number: u32,
|
||||
pub episode_number: u32,
|
||||
pub absolute_number: Option<u32>,
|
||||
pub title: Option<String>,
|
||||
pub air_date: Option<String>,
|
||||
}
|
||||
|
||||
/// Inserts a series and its full episode list into the library, using a
|
||||
/// TVDB series id as the source of episode data. Returns the new
|
||||
/// `media_item.id`.
|
||||
///
|
||||
/// Split into a fetch step and a write step (see [`insert_series`]) rather
|
||||
/// than one function that both awaits and holds `conn`, because a caller
|
||||
/// using `Mutex<Connection>` (e.g. an axum handler) can't hold a sync
|
||||
/// `MutexGuard` across an `.await` point — this convenience wrapper is only
|
||||
/// safe for callers with an owned, unshared `Connection` (e.g. the debug
|
||||
/// CLI commands).
|
||||
pub async fn add_series(
|
||||
conn: &Connection,
|
||||
tvdb: &tvdb::TvdbClient,
|
||||
tvdb_series_id: &str,
|
||||
title: &str,
|
||||
year: Option<u32>,
|
||||
aliases: &[String],
|
||||
root_folder: &str,
|
||||
quality_profile_id: i64,
|
||||
) -> Result<i64> {
|
||||
let episodes = tvdb.episodes(tvdb_series_id).await?;
|
||||
insert_series(
|
||||
conn,
|
||||
tvdb_series_id,
|
||||
title,
|
||||
year,
|
||||
aliases,
|
||||
root_folder,
|
||||
quality_profile_id,
|
||||
&episodes,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn insert_series(
|
||||
conn: &Connection,
|
||||
tvdb_series_id: &str,
|
||||
title: &str,
|
||||
year: Option<u32>,
|
||||
aliases: &[String],
|
||||
root_folder: &str,
|
||||
quality_profile_id: i64,
|
||||
episodes: &[EpisodeInfo],
|
||||
) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_item (kind, title, year, tvdb_id, monitored, quality_profile_id, root_folder)
|
||||
VALUES ('series', ?1, ?2, ?3, 1, ?4, ?5)",
|
||||
params![
|
||||
title,
|
||||
year,
|
||||
tvdb_series_id.parse::<i64>().ok(),
|
||||
quality_profile_id,
|
||||
root_folder
|
||||
],
|
||||
)?;
|
||||
let media_item_id = conn.last_insert_rowid();
|
||||
|
||||
for alias in aliases {
|
||||
conn.execute(
|
||||
"INSERT INTO alias (media_item_id, text, source) VALUES (?1, ?2, 'tvdb')",
|
||||
params![media_item_id, alias],
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut seasons_seen = HashSet::new();
|
||||
for ep in episodes {
|
||||
if seasons_seen.insert(ep.season_number) {
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO season (media_item_id, season_number, monitored) VALUES (?1, ?2, 1)",
|
||||
params![media_item_id, ep.season_number],
|
||||
)?;
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO episode
|
||||
(media_item_id, season_number, episode_number, absolute_number, title, air_date, monitored, has_file)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1, 0)",
|
||||
params![
|
||||
media_item_id,
|
||||
ep.season_number,
|
||||
ep.episode_number,
|
||||
ep.absolute_number,
|
||||
ep.title,
|
||||
ep.air_date
|
||||
],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(media_item_id)
|
||||
}
|
||||
|
||||
/// Inserts a movie into the library. No fetch step needed (unlike
|
||||
/// `add_series`/`insert_series`) — TMDB's movie search result already
|
||||
/// carries everything a movie needs (title, year); there's no separate
|
||||
/// episode-list call the way a series has.
|
||||
pub fn insert_movie(
|
||||
conn: &Connection,
|
||||
tmdb_movie_id: &str,
|
||||
title: &str,
|
||||
year: Option<u32>,
|
||||
root_folder: &str,
|
||||
quality_profile_id: i64,
|
||||
) -> Result<i64> {
|
||||
conn.execute(
|
||||
"INSERT INTO media_item (kind, title, year, tmdb_id, monitored, quality_profile_id, root_folder)
|
||||
VALUES ('movie', ?1, ?2, ?3, 1, ?4, ?5)",
|
||||
params![
|
||||
title,
|
||||
year,
|
||||
tmdb_movie_id.parse::<i64>().ok(),
|
||||
quality_profile_id,
|
||||
root_folder
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
145
breadarrd/src/metadata/tmdb.rs
Normal file
145
breadarrd/src/metadata/tmdb.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{EpisodeInfo, MovieSearchResult, SeriesSearchResult};
|
||||
|
||||
pub struct TmdbClient {
|
||||
bearer_token: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TmdbClient {
|
||||
pub fn new(bearer_token: impl Into<String>) -> Self {
|
||||
Self {
|
||||
bearer_token: bearer_token.into(),
|
||||
// No total timeout is reqwest's default — the background loop
|
||||
// holds the DB mutex across calls into this client, so a
|
||||
// stalled connection would hang the whole daemon.
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("reqwest client build"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn search_tv(&self, query: &str) -> Result<Vec<SeriesSearchResult>> {
|
||||
#[derive(Deserialize)]
|
||||
struct SearchResponse {
|
||||
results: Vec<TvItem>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct TvItem {
|
||||
id: u64,
|
||||
name: String,
|
||||
first_air_date: Option<String>,
|
||||
}
|
||||
|
||||
let resp: SearchResponse = self
|
||||
.client
|
||||
.get("https://api.themoviedb.org/3/search/tv")
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.query(&[("query", query)])
|
||||
.send()
|
||||
.await
|
||||
.context("tmdb tv search request failed")?
|
||||
.error_for_status()
|
||||
.context("tmdb tv search returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tmdb tv search response was not valid JSON")?;
|
||||
|
||||
Ok(resp
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|item| SeriesSearchResult {
|
||||
external_id: item.id.to_string(),
|
||||
name: item.name,
|
||||
year: year_from_date(item.first_air_date.as_deref()),
|
||||
aliases: Vec::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn search_movie(&self, query: &str) -> Result<Vec<MovieSearchResult>> {
|
||||
#[derive(Deserialize)]
|
||||
struct SearchResponse {
|
||||
results: Vec<MovieItem>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct MovieItem {
|
||||
id: u64,
|
||||
title: String,
|
||||
release_date: Option<String>,
|
||||
}
|
||||
|
||||
let resp: SearchResponse = self
|
||||
.client
|
||||
.get("https://api.themoviedb.org/3/search/movie")
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.query(&[("query", query)])
|
||||
.send()
|
||||
.await
|
||||
.context("tmdb movie search request failed")?
|
||||
.error_for_status()
|
||||
.context("tmdb movie search returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tmdb movie search response was not valid JSON")?;
|
||||
|
||||
Ok(resp
|
||||
.results
|
||||
.into_iter()
|
||||
.map(|item| MovieSearchResult {
|
||||
external_id: item.id.to_string(),
|
||||
title: item.title,
|
||||
year: year_from_date(item.release_date.as_deref()),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn tv_season_episodes(&self, tv_id: u64, season: u32) -> Result<Vec<EpisodeInfo>> {
|
||||
#[derive(Deserialize)]
|
||||
struct SeasonResponse {
|
||||
#[serde(default)]
|
||||
episodes: Vec<EpisodeItem>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct EpisodeItem {
|
||||
season_number: u32,
|
||||
episode_number: u32,
|
||||
name: Option<String>,
|
||||
air_date: Option<String>,
|
||||
}
|
||||
|
||||
let resp: SeasonResponse = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api.themoviedb.org/3/tv/{tv_id}/season/{season}"
|
||||
))
|
||||
.bearer_auth(&self.bearer_token)
|
||||
.send()
|
||||
.await
|
||||
.context("tmdb season request failed")?
|
||||
.error_for_status()
|
||||
.context("tmdb season returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tmdb season response was not valid JSON")?;
|
||||
|
||||
Ok(resp
|
||||
.episodes
|
||||
.into_iter()
|
||||
.map(|e| EpisodeInfo {
|
||||
season_number: e.season_number,
|
||||
episode_number: e.episode_number,
|
||||
absolute_number: None,
|
||||
title: e.name,
|
||||
air_date: e.air_date,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn year_from_date(date: Option<&str>) -> Option<u32> {
|
||||
date.and_then(|d| d.get(0..4)).and_then(|y| y.parse().ok())
|
||||
}
|
||||
162
breadarrd/src/metadata/tvdb.rs
Normal file
162
breadarrd/src/metadata/tvdb.rs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{EpisodeInfo, SeriesSearchResult};
|
||||
|
||||
/// TVDB v4 JWTs are valid for roughly a month; refresh well before that so
|
||||
/// clock skew or early invalidation on their end doesn't strand us.
|
||||
const TOKEN_TTL: Duration = Duration::from_secs(20 * 60 * 60);
|
||||
|
||||
pub struct TvdbClient {
|
||||
api_key: String,
|
||||
client: reqwest::Client,
|
||||
token: Mutex<Option<(String, Instant)>>,
|
||||
}
|
||||
|
||||
impl TvdbClient {
|
||||
pub fn new(api_key: impl Into<String>) -> Self {
|
||||
Self {
|
||||
api_key: api_key.into(),
|
||||
// No total timeout is reqwest's default — the background loop
|
||||
// holds the DB mutex across calls into this client, so a
|
||||
// stalled connection would hang the whole daemon.
|
||||
client: reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("reqwest client build"),
|
||||
token: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn token(&self) -> Result<String> {
|
||||
let cached = self.token.lock().unwrap().clone();
|
||||
if let Some((token, obtained_at)) = cached {
|
||||
if obtained_at.elapsed() < TOKEN_TTL {
|
||||
return Ok(token);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct LoginResponse {
|
||||
data: LoginData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct LoginData {
|
||||
token: String,
|
||||
}
|
||||
|
||||
let resp: LoginResponse = self
|
||||
.client
|
||||
.post("https://api4.thetvdb.com/v4/login")
|
||||
.json(&serde_json::json!({ "apikey": self.api_key }))
|
||||
.send()
|
||||
.await
|
||||
.context("tvdb login request failed")?
|
||||
.error_for_status()
|
||||
.context("tvdb login returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tvdb login response was not valid JSON")?;
|
||||
|
||||
*self.token.lock().unwrap() = Some((resp.data.token.clone(), Instant::now()));
|
||||
Ok(resp.data.token)
|
||||
}
|
||||
|
||||
pub async fn search_series(&self, query: &str) -> Result<Vec<SeriesSearchResult>> {
|
||||
let token = self.token().await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SearchResponse {
|
||||
data: Vec<SearchItem>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct SearchItem {
|
||||
tvdb_id: Option<String>,
|
||||
name: Option<String>,
|
||||
year: Option<String>,
|
||||
#[serde(default)]
|
||||
aliases: Vec<String>,
|
||||
}
|
||||
|
||||
let resp: SearchResponse = self
|
||||
.client
|
||||
.get("https://api4.thetvdb.com/v4/search")
|
||||
.bearer_auth(token)
|
||||
.query(&[("query", query), ("type", "series")])
|
||||
.send()
|
||||
.await
|
||||
.context("tvdb search request failed")?
|
||||
.error_for_status()
|
||||
.context("tvdb search returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tvdb search response was not valid JSON")?;
|
||||
|
||||
Ok(resp
|
||||
.data
|
||||
.into_iter()
|
||||
.filter_map(|item| {
|
||||
Some(SeriesSearchResult {
|
||||
external_id: item.tvdb_id?,
|
||||
name: item.name?,
|
||||
year: item.year.and_then(|y| y.parse().ok()),
|
||||
aliases: item.aliases,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn episodes(&self, series_id: &str) -> Result<Vec<EpisodeInfo>> {
|
||||
let token = self.token().await?;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EpisodesResponse {
|
||||
data: EpisodesData,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct EpisodesData {
|
||||
episodes: Vec<EpisodeItem>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct EpisodeItem {
|
||||
#[serde(rename = "seasonNumber")]
|
||||
season_number: u32,
|
||||
number: u32,
|
||||
#[serde(rename = "absoluteNumber")]
|
||||
absolute_number: Option<u32>,
|
||||
name: Option<String>,
|
||||
aired: Option<String>,
|
||||
}
|
||||
|
||||
let resp: EpisodesResponse = self
|
||||
.client
|
||||
.get(format!(
|
||||
"https://api4.thetvdb.com/v4/series/{series_id}/episodes/default"
|
||||
))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.context("tvdb episodes request failed")?
|
||||
.error_for_status()
|
||||
.context("tvdb episodes returned an error status")?
|
||||
.json()
|
||||
.await
|
||||
.context("tvdb episodes response was not valid JSON")?;
|
||||
|
||||
Ok(resp
|
||||
.data
|
||||
.episodes
|
||||
.into_iter()
|
||||
.map(|e| EpisodeInfo {
|
||||
season_number: e.season_number,
|
||||
episode_number: e.number,
|
||||
absolute_number: e.absolute_number.filter(|&n| n != 0),
|
||||
title: e.name,
|
||||
air_date: e.aired,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue