Metadata-Version: 2.4
Name: anidb-client
Version: 3.0.0rc4
Summary: Object-oriented UDP client library for AniDB
Project-URL: Homepage, https://github.com/boomshadow/anidb-client
Project-URL: Source, https://github.com/boomshadow/anidb-client
Project-URL: Issues, https://github.com/boomshadow/anidb-client/issues
Author-email: Jacob Tirey <jacob@tirey.io>
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Keywords: anidb,anime,api,client,mylist,udp
Classifier: Development Status :: 5 - Production/Stable
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Database :: Front-Ends
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.14.5
Requires-Dist: pycryptodome==3.23.0
Requires-Dist: sqlalchemy==2.0.51
Description-Content-Type: text/markdown

# anidb-client

An object-oriented UDP client library for [AniDB](https://anidb.net).

`anidb-client` wraps the AniDB UDP API in ordinary Python objects — `Anime`,
`Episode`, `File`, `Group` — and keeps an aggressive local cache in front of it,
because the UDP API is strictly rate-limited and will ban clients that talk to it
too often. You ask for an attribute; the library serves it from cache when it can
and goes to the network only when it must.

The typical use is mylist management: identifying local files by ed2k hash and
adding, editing or removing them from your AniDB mylist.

```python
import anidb_client

anidb_client.init("sqlite:///anidb.db", api_user="myuser", api_pass="mypassword")

anime = anidb_client.Anime("Kemono no Souja Erin")
print(f"{anime.title} has {anime.nr_of_episodes} episodes and is a {anime.type}")

anidb_client.close()
```

## Lineage

This is an independent fork of [adbb](https://github.com/winterbird-code/adbb)
by Winterbird, which was itself forked from `adba`. Considerable thanks are owed
to those projects — the protocol handling, the caching design and the
title-matching heuristics here all originate with them.

It is a hard fork, not a soft one. This project tracks no upstream, makes its own
API decisions, and is narrower in scope: the `arrange_anime`,
`jellyfin_anime_sync` and `adbb_cache` command-line tools that upstream ships are
deliberately **not** part of this package. `anidb-client` is a library.

## Requirements

* Python 3.14.5 or newer
* A SQLAlchemy-compatible database for the cache:
  * SQLite (simplest; a file is all you need)
  * PostgreSQL
  * MySQL / MariaDB
* An AniDB account

Runtime dependencies (`pycryptodome`, `sqlalchemy`) are installed automatically.

## Installation

```console
pip install anidb-client
```

Reading files over NFS (`File(path="nfs://...")`) additionally needs the
[`libnfs`](https://pypi.org/project/libnfs/) Python module. It is not declared as
an extra because its only release is a source distribution that compiles against
libnfs system headers, so installing it is left to you:

```console
pip install libnfs   # requires libnfs development headers
```

Without it, only local paths work; nothing else is affected.

### Type checking

The package is annotated throughout and ships a [PEP 561](https://peps.python.org/pep-0561/)
`py.typed` marker, so mypy, pyright and friends read those annotations directly. No
stub package, and no `ignore_missing_imports` or `follow_untyped_imports` waiver.

One honest limit. The attributes on `Anime`, `Episode`, `File` and `Group` that come
from the cached row are resolved dynamically, so a type checker sees them as `Any`
rather than as their real types. What is checked is the surface each class declares
for itself, along with `init()`, the exceptions and the enumerations — which is the
boundary a wrapping application actually calls across.

## Registering a client with AniDB

**AniDB will not authenticate an unregistered client.** The `AUTH` command carries
a client name and an integer client version, and that pair must be registered
through [AniDB's client registration](https://wiki.anidb.net/UDP_API_DEV) before
it will work. If you are embedding this library in your own application, register
your own client and set it at init time:

```python
anidb_client.init(..., client_name="myclient", client_version=1)
```

The defaults identify this library. They are unrelated to the version of the
package itself — a `pip install --upgrade` does not change the identity AniDB
sees.

## Rate limits and bans, briefly

AniDB's UDP API is unusually strict, and the consequence for getting it wrong is
a temporary IP ban rather than an error response. The library defends against
this on your behalf, and it is worth knowing how:

* Requests are paced automatically (a short delay between commands, longer once a
  burst builds up). You cannot send faster by asking.
* Every response is cached in your database. The shortest caching period is one
  day; beyond that a probability score decides whether to refresh, so a large
  collection does not re-fetch everything at once.
* On a ban or server-busy response the client backs off exponentially rather than
  retrying immediately.
* The anime-titles and anime-list XML files are fetched over HTTPS at most once
  every 36 hours and cached on disk.

If you are testing an integration, test against a fake server rather than the
real API. This repository's own suite does exactly that and never sends a packet
off the loopback interface.

## Caching

All information fetched from AniDB is cached in the SQL database you pass to
`init()`. The shortest caching period is one day. After that, a probability score
based on the age of the data decides whether a given object is refreshed — the
intent is that a cache warms up over time instead of expiring all at once. The
scoring is heuristic and unlikely to be optimal for every use case.

You can always force a refresh with an object's `update()` method.

Anime title search uses the `anime-titles.xml.gz` file published by AniDB. It is
downloaded automatically and stored in the system temporary directory
(`/var/tmp/anime-titles.xml.gz` on POSIX systems), then reused for 36 hours
before being refreshed. Deleting the cached file forces an immediate update.

tvdb / tmdb / imdb mapping comes from
[Anime-Lists](https://github.com/Anime-Lists/anime-lists), cached the same way as
`/var/tmp/anime-list.xml`.

## Usage

```python
import anidb_client

# The database URL is the first argument. Credentials may be passed directly or
# read from a netrc file (see below).
anidb_client.init(
    "sqlite:///anidb.db",
    api_user="<anidb-username>",
    api_pass="<anidb-password>",
)

# An Anime can be created from a title or from an AniDB anime ID.
anime = anidb_client.Anime("Kemono no Souja Erin")
# anime = anidb_client.Anime(6187)

# "Kemono no Souja Erin has 50 episodes and is a TV Series"
print(f"{anime.title} has {anime.nr_of_episodes} episodes and is a {anime.type}")

# An Episode can be created from anime + episode number, or from an AniDB eid.
episode = anidb_client.Episode(anime=anime, epno=5)
# episode = anidb_client.Episode(eid=96461)

# "'Kemono no Souja Erin' episode 5 has title 'Erin and the Egg Thieves'"
print(f"'{episode.anime.title}' episode {episode.episode_number} has title '{episode.title_eng}'")

# A File can be created from a local path, an AniDB file ID, or anime + episode.
file = anidb_client.File(path="/media/Anime/Kemono no Souja Erin/[BD] Kemono no Souja Erin - 05.mkv")
# file = anidb_client.File(fid=<some-fid>)
# file = anidb_client.File(anime=anime, episode=episode)

# This usually works even for a file AniDB has never seen.
print(f"'{file.path}' contains episode {file.episode.episode_number} of "
      f"'{file.anime.title}'. Mylist state is '{file.mylist_state}'")

# Posters for Anime and Group objects.
# NOTE: the AniDB CDN has added a CAPTCHA, so this is unreliable. See Fanart below.
with open("poster.jpg", "wb") as f:
    anidb_client.download_image(f, anime)

# Always close before exiting: it logs out cleanly and, just as importantly, gives
# back the pinned UDP port and the cache's connections.
anidb_client.close()
```

### init()

```python
anidb_client.init(
    sql_db_url,
    api_user=None,
    api_pass=None,
    debug=False,
    loglevel="info",
    logger=None,
    netrc_file=None,
    outgoing_udp_port=None,  # None means the pinned default, 9876
    api_key=None,
    fanart_api_key=None,
    db_only=False,
    client_name=None,
    client_version=None,
    db_pool_size=10,
    rate_limiter=None,  # None means the transport builds its own
)
```

`sql_db_url` is a SQLAlchemy URL and is the only required argument. Credentials
come either from `api_user`/`api_pass` or from a [netrc file](#netrc); unless
`db_only=True`, `init()` raises if it finds neither rather than failing later on
the first request. Pass
`db_only=True` to work entirely from cache without opening a UDP session, and
`client_name`/`client_version` to authenticate as your own
[registered client](#registering-a-client-with-anidb).

`outgoing_udp_port` is the local UDP port this client sends from. It defaults to
a **fixed** port (9876) rather than a random one, because AniDB meters and bans
by the address a datagram arrives from — and a UDP source address includes the
port, so a client that rolls a new one per `init()` presents a single host as a
stream of distinct clients and gets the IP banned for flooding. AniDB's own
guidance is to pick one local port above 1024 and reuse it. Give each client its
own port if you run several at once; two clients will not share one, and the
second to start fails to bind with an error naming the port. See ADR-007.

`db_pool_size` bounds the connection pool the cache uses. The default suits a
client of this library; raise or lower it if your application knows better. The
pool is deliberately not unlimited — an unbounded one lets a connection leak
consume your database server's connection slots, or your process's file
descriptors, before anything points back here.

**An in-memory SQLite URL (`sqlite://` or `sqlite:///:memory:`) is only allowed
with `db_only=True`.** Outside cache-only mode this library runs a thread per API
reply, each with its own connection — and every connection to an in-memory
database is a *separate* database, so those threads would find one with no tables
in it. `init()` refuses such a URL rather than appearing to work.

**A SQLite cache is put into [WAL mode](https://www.sqlite.org/wal.html)**, so
that a write does not lock out readers. This creates `-wal` and `-shm` files
beside your database file. WAL does not work over a network filesystem, and the
request is not fatal when it is refused: if SQLite answers with some other mode,
the cache runs in that mode and logs which one it is; if the request fails
outright, the cache keeps whatever mode it had and logs that WAL was not granted.
Foreign keys are enforced on every SQLite connection.

`rate_limiter` lets the pacing and ban state outlive the process. Everything the
limiter knows — the burst allowance, the last send, how much back-off is left and
how far it has doubled — is per-process, so a restart starts fresh: the first
command goes out with no delay and a short burst follows. That is inside AniDB's
allowance once per deploy; it is not inside it on the tenth restart of a crash
loop. Keep the state wherever you like and hand back a limiter that knows it:

```python
from anidb_client import BanCause, RateLimiter

# Whatever you stored last time this process shut down.
limiter = RateLimiter(
    banned_for=900,               # seconds of back-off remaining, not a deadline
    ban_multiplier=2,             # so the next ban is longer, not a fresh one
    ban_cause=BanCause.SILENCE,
    seconds_since_last_send=30,
)
anidb_client.init("sqlite:///anidb.db", rate_limiter=limiter)
```

Read the same values back with `ban_remaining()`, `ban_multiplier`, `ban_cause`
and `seconds_since_last_send()` — everything you can seed, you can capture.

**Pass durations, never timestamps.** These are measured on a monotonic clock,
whose zero point is undefined and does not survive the process. A deadline stored
by one process means nothing in the next, and it would mean nothing *quietly* —
reading as already elapsed, or as hours away, with no error either way. "900
seconds left" survives a restart, a reboot and a move to another host.

Incoherent combinations raise rather than being silently corrected. A back-off
with no multiplier is the one that bites: whether a client is banned is read from
the multiplier, so such a limiter would hold a deadline while reporting itself
unbanned, and the client would send straight through the ban.

**`init()` refuses to run twice.** This library holds one client in module state,
and its transport binds one fixed UDP source port that it will not share, so a
second client could not open anyway — it used to fail with an address-in-use error
naming a port nothing visible was using. Call `close()` first if you mean to
re-initialise. A failed `init()` leaves nothing running, so you can correct
whatever it complained about and call it again.

### connect()

```python
anidb_client.connect(timeout=None)
```

`init()` sends nothing — the login is lazy, and happens whenever something first
needs a session. That means a wrong password or a standing ban surfaces on the
first real request rather than at startup. If you are writing a long-running
service, that is the difference between a process that refuses to start and one
that starts, looks healthy, and fails in front of a user an hour later.

`connect()` asks at startup instead. It establishes the session, or raises the
reason it cannot — the refusal AniDB gave, the back-off that forbade sending, or
a timeout — on a bounded wait.

```python
anidb_client.init("sqlite:///anidb.db", api_user="me", api_pass="secret")
try:
    anidb_client.connect()
except anidb_client.errors.AniDBError as exc:
    raise SystemExit(f"cannot reach AniDB: {exc}")
```

It is **idempotent** — a session already up is left alone, and a handshake in
flight is waited on rather than duplicated — so calling it twice is safe. It also
adds no traffic in the ordinary case: the login happens either way, this just
moves it earlier.

**`timeout` bounds how long you wait, not how long the protocol gets.** Left out,
you wait the transport's own handshake budget — 60 seconds — which is longer than
most orchestrators will hold a container's startup open. Pass your own if your
budget is shorter:

```python
anidb_client.connect(timeout=5)
```

Giving up early is safe. The handshake is not cancelled: it settles on its own,
and a later `connect()` joins whatever it settled as rather than starting a
second one. So a startup probe can say *tell me within five seconds whether this
is up* without meaning *and abandon the session if not*. `timeout=0` asks without
waiting at all; a negative value is refused.

Note this is not the per-command reply timeout, which also governs retries —
wanting a five-second startup check is not wanting fewer retries for the rest of
the process's life.

**Do not poll it.** It is not a health check. In most clients the equivalent is a
free `ping()`; here every command is metered by a service that enforces with an IP
ban, so calling this on a readiness probe's timer is a way to earn one. For the
repeated question, read the transport's state instead — it answers from what it
already knows and sends nothing:

```python
link = anidb_client.get_link()
if link.is_banned:
    print(f"backing off for {link.ban_remaining:.0f}s ({link.ban_cause.name.lower()})")
```

`connect()` is optional. If you do not mind finding out on the first request, skip
it.

### close()

```python
anidb_client.close(timeout=None)
```

Ends the UDP session and gives back everything `init()` took: the transport is
stopped, the cache's pooled connections are released, the fanart key is cleared,
and the outgoing UDP port is free to bind again — before `close()` returns, not
shortly afterwards. Once it has returned, `init()` may be called again.

`timeout` bounds the wait for AniDB to acknowledge the logout, defaulting to the
transport's command timeout of 20 seconds. **Pass your own if your shutdown budget
is tighter than that.** A client AniDB has banned is never told its logout
arrived, so that wait runs to the full bound — and a container with a ten-second
stop grace period will be killed in the middle of it, losing whatever your own
shutdown does after this call. Logging out is best effort; the teardown happens
either way.

Calling `close()` twice, or without having called `init()`, does nothing.

## Reference

### Anime

```python
Anime(init)
```

`init` is either a title or an aid. Titles are matched against
`anime-titles.xml` using fuzzy text matching (via `difflib`), and only the single
best match becomes an `Anime`. Some titles are ambiguous: a search for `Ranma`
may return either `Ranma 1/2` (which has "Ranma" as a synonym) or
`Ranma 1/2 Nettou Hen` (which has it as an official title).

#### Attributes

* `aid` — AniDB anime ID
* `titles` — every title for this anime
* `title` — the main title
* `updated` — when this anime was last fetched from AniDB
* `tvdbid` — TVDB ID, or `None`. TV series only.
* `tmdbid` — TMDB *movie* ID, or `None`. May be a list when the anime maps to
  several movies; use `Episode.tmdbid` for a specific episode, and `extid()` for
  TV series.
* `imdbid` — IMDB ID, or `None`. May be a list when the anime maps to several
  movies; use `Episode.imdbid` for a specific episode. Movies only.
* `relations` — a list of `(relation_type, Anime)` tuples
* `fanart` — if [enabled](#fanart), a list of dicts translated directly from the
  [fanart.tv API](https://fanarttv.docs.apiary.io/). Empty list if not enabled.

The following attributes are returned from the AniDB API: `year`, `type`,
`nr_of_episodes`, `highest_episode_number`, `special_ep_count`, `air_date`,
`end_date`, `url`, `picname`, `rating`, `vote_count`, `temp_rating`,
`temp_vote_count`, `average_review_rating`, `review_count`, `is_18_restricted`,
`ann_id`, `allcinema_id`, `animenfo_id`, `anidb_updated`, `special_count`,
`credit_count`, `other_count`, `trailer_count`, `parody_count`.

#### Methods

```python
extid(source, id_type="tv")
```

Return external ID(s) for this anime. Valid `id_type` values are `'tv'` and
`'movie'`. Valid sources are `'thetvdb'` (tv only), `'tmdb'` (tv and movie) and
`'imdb'` (movie only). May return a list when the anime links to several titles
at the source, or `None` when no valid mapping exists for the combination.

```python
related_anime(exclude=None, follow=None, depth=None, budget=20, only_in_mylist=False)
```

Walk this anime's relations transitively and report what was reached.

Returns a `RelatedAnime` with three attributes: `root` (the anime you started
from), `related` (every anime reached, as the same `(relation_type, Anime)`
pairs `relations` uses), and `stopped_by` (the bound that ended the walk, or
`None` if it ran out of graph — `truncated` is the same thing as a boolean).

AniDB is the authority on what belongs to a show, so this hands you its answer
whole rather than deciding on your behalf. The relation type comes back with
every anime, and no type is filtered unless you ask:

* `follow` names the relation types to traverse, and follows all of them when
  unset. An anime reached only by a type outside the set is neither returned nor
  traversed through.
* `exclude` is an iterable of `Anime` treated as walls, the same way.
* `only_in_mylist` follows only anime already in your mylist. It is a use-case
  filter for cataloguing a collection, not a safety one, and is off by default.

The walk is bounded by `budget` — how many anime it may reach — and optionally
by `depth`, counting this anime's own relations as one. These cap work, not
relevance: AniDB's graph contains components far larger than any caller means by
"this show", and every anime reached can cost a rate-limited request.

```python
result = anidb_client.Anime(11372).related_anime(
    follow=("sequel", "prequel", "side story", "parent story"),
)
for relation_type, anime in result.related:
    print(relation_type, anime.title)
if result.truncated:
    print(f"stopped early: {result.stopped_by}")
```

A note on `other`: it carries both a franchise's ancestor and entries belonging
to the show itself, so no relation-type filter is right for everyone. That is
why the type is returned to you rather than applied here. A reasonable pattern is
a story-relations walk for the reliable core, plus a look at the root's own
`other` links judged by title and date.

### Episode

```python
Episode(anime=None, epno=None, eid=None)
```

Create from `anime` + `epno`, or from `eid` alone. `anime` may be a title, an aid
or an `Anime` object. `epno` is a string or int; `eid` is an int.

#### Attributes

* `eid` — AniDB episode ID
* `anime` — the `Anime` this episode belongs to
* `episode_number` — the episode number (note: a string)
* `updated` — when this episode was last fetched from AniDB
* `tvdb_episode` — `(season, episode)` if the episode maps to a TVDB episode.
  `episode` is usually an int, but may be an `(episode_number, part_number)`
  tuple or a list of ints when an AniDB episode maps to part of a TVDB episode
  or vice versa.
* `tmdb_episode` — as above, mapped to TMDB
* `tmdbid` — TMDB ID for this episode, or `None`
* `imdbid` — IMDB ID for this episode, or `None`
* `in_mylist` — whether the local cache holds a mylist entry for this episode

The following attributes are returned from the AniDB API: `length`, `rating`,
`votes`, `title_eng`, `title_romaji`, `title_kanji`, `aired`, `type`.

#### Methods

```python
add_to_mylist(state=None, watched=None, source=None, other=None)
```

Add a **generic** mylist entry for this episode — the same thing AniDB's *Add To
My List* button creates, with no file on disk and no ed2k hash involved. This is
the way to record "I have this episode" when the file you have is a re-encode
AniDB will never recognise.

`state`, `watched`, `source` and `other` mean what they do in
`File.update_mylist()`. An unrecognised `state` raises rather than being quietly
dropped.

It **only ever adds**. The command carries no edit flag, so an episode that
already has an entry is reported back as such and the existing entry — including
one you added from another client, against a real file — is left untouched.
Calling it twice is therefore harmless, which makes it safe to re-run after a
crash. It costs exactly one AniDB request per call.

Returns a `MylistAddition`:

* `outcome` — a `MylistAddOutcome`: `ADDED`, `ALREADY_PRESENT` or `REJECTED`
* `aid`, `episode_number` — what was asked for
* `rescode`, `reason` — AniDB's own answer, so `330 NO SUCH ANIME` and
  `340 NO SUCH EPISODE` stay distinguishable
* `lid` — the existing entry's mylist ID when AniDB volunteers one, else `None`

AniDB refusing the add is a returned result, not an exception. A request the
transport could not deliver — a ban, a timeout — still raises, as every mylist
write does.

The episode number must name exactly one episode. `MYLISTADD` reads a missing or
zero episode number as *every episode of the anime* and a negative one as *every
episode up to it*, so `0`, `-12` and ranges like `5-7` are refused locally before
anything reaches AniDB.

```python
anime = anidb_client.Anime(9227)
for epno in ["1", "2", "3", "S1"]:
    result = anidb_client.Episode(anime=anime, epno=epno).add_to_mylist(state="on hdd")
    print(epno, result.outcome)
```

There is deliberately no batch call. A mylist write that cannot reach AniDB
raises, and a batch that raises half way through a season would throw away the
record of the episodes that had already landed — your own loop keeps it.

Note that the local cache is **not** updated: AniDB returns no identifier for a
file-less entry, so `in_mylist` will not know about the entry until something
refreshes it from AniDB. See ADR-006.

### File

```python
File(path=None, fid=None, anime=None, episode=None)
```

Requires `path`, `fid`, or `anime` and `episode`. When given `anime` and
`episode`, the file is either a generic file or whatever you have in your mylist
for that anime and episode.

Given a `path`, the library first checks the file's size and ed2k hash against
AniDB. If the file exists there, the `File` represents it. If it does not, the
library infers which anime and episode the file contains: the episode number is
guessed from the filename by regex, and if none is found and the anime has only
one episode, episode `1` is assumed. The anime title is guessed from the parent
directory when that matches `anime-titles.xml` well enough, and from the filename
otherwise. See `_guess_anime_ep_from_file()` and `_guess_epno_from_filename()` in
`animeobjs.py`, and `get_titles()` in `anames.py`.

#### Methods

```python
update_mylist(state=None, watched=None, source=None, other=None)
remove_from_mylist()
```

`update_mylist()` both adds and edits. `state` is one of `'unknown'`, `'on hdd'`,
`'on cd'` or `'deleted'`. `watched` is `True`, `False`, or a `datetime` recording
when it was watched.

#### Attributes

* `anime` — the `Anime` this file contains
* `episode` — the `Episode` this file contains
* `group` — `Group` object for the release group
* `multiep` — list of episode numbers this file contains. Filename parsing
  supports multi-episode files but the AniDB API does not, so this is not
  reliable.
* `fid` — AniDB file ID
* `path` — full path (when created from a path)
* `size` — file size in bytes
* `ed2khash` — ed2k hash, which is what AniDB identifies files by
* `updated` — when this file was last fetched from AniDB

The following attributes are returned from the AniDB API: `lid`, `gid`,
`is_deprecated`, `is_generic`, `crc_ok`, `file_version`, `censored`,
`length_in_seconds`, `description`, `aired_date`, `mylist_state`,
`mylist_filestate`, `mylist_viewed`, `mylist_viewdate`, `mylist_storage`,
`mylist_source`, `mylist_other`.

### Group

```python
Group(name=None, gid=None)
```

Requires a `name` (short or long) or a `gid`. A group created from a name is
always considered valid and is saved to the database even when the name matches
no AniDB group; in that case both `name` and `short` are set to the given name
and the other attributes stay empty.

#### Attributes

* `updated` — when this group was last fetched from AniDB

The following attributes are returned from the AniDB API: `gid`, `rating`,
`votes`, `acount`, `fcount`, `name`, `short`, `irc_channel`, `irc_server`, `url`,
`picname`, `founded`, `disbanded`, `dateflag`, `last_release`, `last_activity`.

## Fanart

`Anime.fanart` fetches available fanart from [fanart.tv](https://fanart.tv) when
two conditions are met:

* you provide an [API key](https://fanart.tv/get-an-api-key/), either as the
  `fanart_api_key` argument to `init()` or via a [netrc file](#netrc)
* the series or movie is mapped to a tvdb/tmdb/imdb ID in
  [Anime-Lists](https://github.com/Anime-Lists/anime-lists)

The attribute returns metadata translated directly from the fanart.tv API, so
consult [their reference](https://fanarttv.docs.apiary.io/) for its structure —
it differs slightly between series and movies. Use `download_fanart()` to fetch
the images themselves.

```python
import anidb_client

anidb_client.init("sqlite:///anidb.db", netrc_file=".netrc", fanart_api_key="secret")

anime = anidb_client.Anime("Kemono no Souja Erin")
background_url = anime.fanart[0]["showbackground"][0]["url"]

with open("background.jpg", "wb") as f:
    # preview=True downloads a low-resolution version instead.
    anidb_client.download_fanart(f, background_url, preview=False)

anidb_client.close()
```

## netrc

Rather than passing credentials to `init()`, they can be read from a
[netrc](https://everything.curl.dev/usingcurl/netrc) file via the `netrc_file`
argument. The library looks for:

* AniDB username, password and [encryption key](#encryption). The `account`
  field holds the encryption key. The machine name must be one of
  `api.anidb.net`, `api.anidb.info` or `anidb.net`.
* Database credentials — machine name must match the hostname in `sql_db_url`,
  and only the hostname: no port, and no brackets around an IPv6 literal
  (`machine ::1`, not `machine [::1]:5432`). Matching is case-insensitive. This
  lookup only happens when the URL carries no password of its own; a password
  already in the URL is left alone. The entry needs a `login` as well as a
  `password`: the credential is used only when it belongs to the user the URL
  names, and one with no login belongs to no user.
* fanart.tv API key — machine name must be one of `fanart.tv`,
  `assets.fanart.tv`, `webservice.fanart.tv` or `api.fanart.tv`.

```netrc
machine api.anidb.net
        login <anidb-username>
        password <anidb-password>
        account <anidb-encryption-key>
machine sql.example.com
        login <database-username>
        password <database-password>
machine fanart.tv
        account <fanart-api-key>
```

## Encryption

Per the [UDP API specification](https://wiki.anidb.net/UDP_API_Definition), an
encrypted session is not enabled by default and must be turned on by the user.
Provide your encryption key as the `api_key` argument to `init()` or via a
[netrc file](#netrc). You choose the key yourself in your
[AniDB profile](https://anidb.net/perl-bin/animedb.pl?show=profile).

## Development

Everything runs in Docker; nothing needs to be installed on your machine beyond
Docker and [Task](https://taskfile.dev).

```console
task build          # build the development image
task test           # run the test suite
task test:cov       # ...with a coverage report
task lint           # ruff
task format         # ruff format
task typecheck      # mypy
task spell          # codespell
task check          # everything CI runs
```

The test suite never contacts AniDB. A fake UDP server on loopback stands in for
the real API, and an autouse fixture fails any test that tries to open a socket
or make an HTTP request to a non-loopback address. Please keep it that way — a
test that reaches the real API risks an IP ban for whoever runs it next.

Dependencies are pinned exactly and hash-locked in `uv.lock`, and the resolver
enforces a 45-day cooldown on new releases.

### Documentation

This project follows [Anchored Development](https://anchored-dev.org/). Behavior is
specified by domain in [`docs/specs/`](docs/specs/INDEX.md) and architectural reasoning
lives in [`docs/decisions/`](docs/decisions/INDEX.md); a CI check compares every merge
request against them so they cannot quietly go stale.

The specs are the authoritative description of what the library does. This README is
written for people installing the package rather than working on it, so it deliberately
repeats some of that material instead of pointing away —
[ADR-001](docs/decisions/ADR-001-readme-stays-a-complete-user-facing-document.md) explains
why. Where the two disagree, the spec is right.

## Upgrading

### Object API

The object API is intended to stay stable; code using `Anime`, `Episode`, `File`
and `Group` should keep working across releases.

### Database

The cache has no migration story. **Recreate the database after upgrading** —
delete the SQLite file, or drop and recreate the PostgreSQL/MySQL database. The
cache repopulates from AniDB as it is used.

## License

GPL-3.0-or-later. See [LICENSE](LICENSE).
