can't be bothered writing a commit message

This commit is contained in:
Breadway 2026-07-16 22:22:53 +08:00
commit 697b009627
55 changed files with 21320 additions and 0 deletions

View 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);
}
}

View 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())
}

View 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())
}

View 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())
}
}